Skip to content

Commit 19be332

Browse files
therealalephclaude
andcommitted
feat: v1.9.17 — CORS response header injection (#561 / YouTube comments)
mhrv-rs already short-circuits CORS preflight (OPTIONS → 204 with permissive ACL headers, no relay round-trip). What was missing: the actual cross-origin fetch that follows the preflight also needs CORS-compliant headers on the response, or the browser drops the response and the JS layer sees a CORS failure even though the relay succeeded. Apps Script's UrlFetchApp.fetch() preserves the destination's response headers inconsistently — sometimes the origin returns `Access-Control-Allow-Origin: *` (which is incompatible with `Allow-Credentials: true`), sometimes drops ACL headers entirely. The visible symptom is YouTube comments not loading + the "restricted mode" error surfacing on responses the browser silently rejected before the JS handler could read them. Fix: after the relay returns, if the original request had an `Origin` header, we strip any `Access-Control-*` headers the destination emitted and inject a fresh permissive set echoing the request's origin (required for credentialed fetches; `*` is invalid alongside Allow-Credentials). The body is preserved byte-for-byte; only the header block before the first \r\n\r\n is rewritten. Malformed responses (no header/body separator) round-trip unchanged so we never corrupt non-HTTP/1.x bytes. Idea credit: ThisIsDara/mhr-cfw-go — Go rewrite of upstream Python's CFW variant added the same fix; reviewing their code surfaced the gap in mhrv-rs. Their other claimed improvements (HTTP/2, connection pooling, request coalescing, response caching, range-parallel) are already in mhrv-rs. Tests: 200 lib (was 197, +3 covering wildcard-origin replacement, non-ACL header preservation, malformed-response passthrough) + 36 tunnel-node green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2c9c693 commit 19be332

4 files changed

Lines changed: 168 additions & 2 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mhrv-rs"
3-
version = "1.9.16"
3+
version = "1.9.17"
44
edition = "2021"
55
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
66
license = "MIT"

docs/changelog/v1.9.17.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<!-- see docs/changelog/v1.1.0.md for the file format: Persian, then `---`, then English. -->
2+
• Inject CORS response headers after relay — اضافه شد به‌جای فقط preflight short-circuit. مرورگرها در درخواست‌های cross-origin (مثل YouTube’s `youtubei/v1/next` / `youtubei/v1/comments` که از script context fire می‌شه) responseـی نیاز دارن با `Access-Control-Allow-Origin` که با origin درخواست match کنه + `Allow-Credentials: true`. Apps Script's `UrlFetchApp.fetch()` گاهی header‌های ACL مقصد رو preserve نمی‌کنه، یا destination با `Allow-Origin: *` پاسخ می‌ده که با credentialed request ناسازگاره. mhrv-rs حالا header‌های `Access-Control-*` پاسخ relay رو strip می‌کنه + permissive set تزریق می‌کنه که با origin درخواست echo می‌شه. **علت ریشه‌ای**: YouTube comments نمی‌اومدن load بشن + گاهی restricted-mode error به همین دلیل ظاهر می‌شد. ایده credit: ThisIsDara/mhr-cfw-go (Go rewrite of upstream Python). فقط برای درخواست‌هایی با Origin header اعمال می‌شه — non-CORS traffic (curl، apps native) دست‌نخورده می‌مونه. ۱۹۷ → **۲۰۰ lib test** (+۳ regression test for CORS injection edge cases).
3+
---
4+
• Inject CORS response headers after relay (in addition to the existing preflight short-circuit). When browsers issue cross-origin fetches from script contexts — e.g. YouTube's `youtubei/v1/next` / `youtubei/v1/comments` calls, which fire from the player JS — they require the response to carry `Access-Control-Allow-Origin` matching the request's origin AND `Allow-Credentials: true`. Apps Script's `UrlFetchApp.fetch()` sometimes doesn't preserve the destination's ACL headers, or the destination returns `Allow-Origin: *` which is incompatible with credentialed requests. mhrv-rs now strips any `Access-Control-*` headers from the relay response and injects a permissive set keyed on the request's `Origin`. **Root cause**: YouTube comments not loading + the "restricted mode" error sometimes surfacing on cross-origin XHR responses the browser silently dropped. Idea credit: ThisIsDara/mhr-cfw-go (Go rewrite of upstream Python's CFW variant). Only applies when the original request had an `Origin` header — non-CORS traffic (curl, app-level HTTP clients) passes through byte-for-byte unchanged. 197 → **200 lib tests** (+3 regression tests for CORS injection edge cases: wildcard-origin replacement, non-ACL header preservation, malformed-response passthrough).

src/proxy_server.rs

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2474,6 +2474,31 @@ where
24742474
} else {
24752475
fronter.relay(&method, &url, &headers, &body).await
24762476
};
2477+
2478+
// CORS response-header injection. The preflight short-circuit
2479+
// above handles `OPTIONS`, but the *actual* fetch that follows
2480+
// also needs CORS-compliant headers on the way back, or the
2481+
// browser drops the response and the JS layer sees a CORS
2482+
// failure. Apps Script's `UrlFetchApp.fetch()` preserves the
2483+
// origin server's response headers inconsistently — sometimes the
2484+
// destination returns `Access-Control-Allow-Origin: *` (which is
2485+
// incompatible with `Allow-Credentials: true`), sometimes omits
2486+
// ACL headers entirely. The visible symptom on YouTube is comments
2487+
// not loading and the "restricted" gate firing on cross-origin
2488+
// XHR responses that the browser rejected before the JS handler
2489+
// could even read them. Idea credit: ThisIsDara/mhr-cfw-go.
2490+
//
2491+
// Only injects when the request had an `Origin` header — non-CORS
2492+
// requests (top-level navigation, plain image fetches) don't need
2493+
// the headers and adding them would be noise. The relay response
2494+
// is otherwise byte-identical, so this never affects non-browser
2495+
// clients (curl, wget, app-level HTTP clients).
2496+
let response = if let Some(origin) = header_value(&headers, "origin") {
2497+
inject_cors_response_headers(&response, origin)
2498+
} else {
2499+
response
2500+
};
2501+
24772502
stream.write_all(&response).await?;
24782503
stream.flush().await?;
24792504

@@ -2521,6 +2546,80 @@ fn header_value<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a s
25212546
.map(|(_, v)| v.as_str())
25222547
}
25232548

2549+
/// Strip any `Access-Control-*` response headers the origin server
2550+
/// emitted (or that Apps Script's `UrlFetchApp.fetch()` may have
2551+
/// mangled / dropped) and inject a permissive set keyed on the
2552+
/// browser's request `Origin`. Returns a new response buffer; never
2553+
/// mutates in place.
2554+
///
2555+
/// The body is preserved byte-for-byte; only the header block before
2556+
/// the first `\r\n\r\n` is rewritten. If the response can't be parsed
2557+
/// as HTTP/1.x (no header/body separator), it's returned unchanged so
2558+
/// edge-case responses (e.g. raw error blobs from upstream) aren't
2559+
/// corrupted.
2560+
///
2561+
/// Why permissive (`Allow-Methods: *`, `Allow-Headers: *`,
2562+
/// `Expose-Headers: *`): the browser already pre-cleared the request
2563+
/// via the preflight short-circuit (line ~2435), and the relay path
2564+
/// doesn't expose anything that wasn't already going to the
2565+
/// destination through the user's own MITM trust anchor. The wide
2566+
/// permissions only relax browser-side CORS gating; they don't widen
2567+
/// the underlying network reach. `Allow-Credentials: true` is
2568+
/// echo-only-with-explicit-origin (spec requires it; `*` is invalid
2569+
/// alongside credentials) — that's why we echo the request's origin
2570+
/// and never use `*`.
2571+
fn inject_cors_response_headers(response: &[u8], origin: &str) -> Vec<u8> {
2572+
// Find the header / body separator. If we can't parse the
2573+
// response as HTTP/1.x, hand it back unchanged.
2574+
let sep = b"\r\n\r\n";
2575+
let Some(idx) = response
2576+
.windows(sep.len())
2577+
.position(|w| w == sep)
2578+
else {
2579+
return response.to_vec();
2580+
};
2581+
let head = &response[..idx];
2582+
let body = &response[idx + sep.len()..];
2583+
2584+
// Rebuild the header block, dropping any pre-existing
2585+
// `Access-Control-*` lines so the destination's value can't
2586+
// conflict with ours.
2587+
let head_str = match std::str::from_utf8(head) {
2588+
Ok(s) => s,
2589+
Err(_) => return response.to_vec(),
2590+
};
2591+
let mut out = String::with_capacity(head.len() + 256);
2592+
let mut lines = head_str.split("\r\n");
2593+
if let Some(status) = lines.next() {
2594+
out.push_str(status);
2595+
out.push_str("\r\n");
2596+
}
2597+
for line in lines {
2598+
let lower = line.to_ascii_lowercase();
2599+
if lower.starts_with("access-control-") {
2600+
continue;
2601+
}
2602+
out.push_str(line);
2603+
out.push_str("\r\n");
2604+
}
2605+
// Inject our own. `Vary: Origin` tells downstream caches that the
2606+
// response varies per request origin (so CDN-shared caches don't
2607+
// serve one user's CORS-tagged response to a different origin).
2608+
out.push_str("Access-Control-Allow-Origin: ");
2609+
out.push_str(origin);
2610+
out.push_str("\r\n");
2611+
out.push_str("Access-Control-Allow-Credentials: true\r\n");
2612+
out.push_str("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD\r\n");
2613+
out.push_str("Access-Control-Allow-Headers: *\r\n");
2614+
out.push_str("Access-Control-Expose-Headers: *\r\n");
2615+
out.push_str("Vary: Origin\r\n");
2616+
out.push_str("\r\n");
2617+
2618+
let mut buf = out.into_bytes();
2619+
buf.extend_from_slice(body);
2620+
buf
2621+
}
2622+
25242623
fn expects_100_continue(headers: &[(String, String)]) -> bool {
25252624
header_value(headers, "expect")
25262625
.map(|v| {
@@ -3236,6 +3335,69 @@ mod tests {
32363335
assert!(!matches_passthrough("", &list));
32373336
}
32383337

3338+
#[test]
3339+
fn inject_cors_response_headers_replaces_existing_acl_with_origin_echo() {
3340+
// Origin server returned `Access-Control-Allow-Origin: *` which
3341+
// browsers reject when paired with `Allow-Credentials: true` (the
3342+
// YouTube comments failure mode). Our injection must strip the
3343+
// wildcard and substitute the request's actual origin so that
3344+
// credentialed requests succeed.
3345+
let response = b"HTTP/1.1 200 OK\r\n\
3346+
Content-Type: application/json\r\n\
3347+
Access-Control-Allow-Origin: *\r\n\
3348+
Access-Control-Allow-Methods: GET\r\n\
3349+
Content-Length: 12\r\n\
3350+
\r\n\
3351+
{\"a\":\"b\"}xx";
3352+
let injected = inject_cors_response_headers(response, "https://www.youtube.com");
3353+
let s = std::str::from_utf8(&injected).unwrap();
3354+
// Original wildcard must be gone.
3355+
assert!(
3356+
!s.contains("Access-Control-Allow-Origin: *"),
3357+
"wildcard origin must be stripped, got: {}",
3358+
s
3359+
);
3360+
// Echoed origin + credentials must be present.
3361+
assert!(s.contains("Access-Control-Allow-Origin: https://www.youtube.com\r\n"));
3362+
assert!(s.contains("Access-Control-Allow-Credentials: true\r\n"));
3363+
// Body preserved byte-for-byte.
3364+
assert!(injected.ends_with(b"{\"a\":\"b\"}xx"));
3365+
// Status line preserved.
3366+
assert!(s.starts_with("HTTP/1.1 200 OK\r\n"));
3367+
}
3368+
3369+
#[test]
3370+
fn inject_cors_response_headers_preserves_non_acl_headers() {
3371+
// Non-ACL headers (Content-Type, Set-Cookie, Cache-Control, …)
3372+
// must pass through unchanged. Only `Access-Control-*` lines
3373+
// are stripped.
3374+
let response = b"HTTP/1.1 200 OK\r\n\
3375+
Content-Type: text/html\r\n\
3376+
Set-Cookie: a=1\r\n\
3377+
Cache-Control: max-age=300\r\n\
3378+
Access-Control-Allow-Origin: https://other.example\r\n\
3379+
\r\n\
3380+
body";
3381+
let injected = inject_cors_response_headers(response, "https://www.youtube.com");
3382+
let s = std::str::from_utf8(&injected).unwrap();
3383+
assert!(s.contains("Content-Type: text/html\r\n"));
3384+
assert!(s.contains("Set-Cookie: a=1\r\n"));
3385+
assert!(s.contains("Cache-Control: max-age=300\r\n"));
3386+
// Wrong origin replaced.
3387+
assert!(!s.contains("Access-Control-Allow-Origin: https://other.example\r\n"));
3388+
assert!(s.contains("Access-Control-Allow-Origin: https://www.youtube.com\r\n"));
3389+
}
3390+
3391+
#[test]
3392+
fn inject_cors_response_headers_returns_unchanged_when_no_header_terminator() {
3393+
// A response missing the `\r\n\r\n` separator (e.g. raw error
3394+
// blob, truncated upstream) must round-trip unchanged so we
3395+
// don't corrupt non-HTTP/1.x bytes.
3396+
let response = b"not an http response";
3397+
let injected = inject_cors_response_headers(response, "https://x.com");
3398+
assert_eq!(injected.as_slice(), response);
3399+
}
3400+
32393401
#[test]
32403402
fn passthrough_hosts_ignores_empty_and_whitespace_entries() {
32413403
let list = vec!["".to_string(), " ".to_string(), "real.com".to_string()];

0 commit comments

Comments
 (0)