From 141cd6c7a8317306536cae8d78656e0f44678621 Mon Sep 17 00:00:00 2001 From: therealaleph Date: Thu, 7 May 2026 20:56:37 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20v1.9.17=20=E2=80=94=20CORS=20response?= =?UTF-8?q?=20header=20injection=20(#561=20/=20YouTube=20comments)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Cargo.lock | 2 +- Cargo.toml | 2 +- docs/changelog/v1.9.17.md | 4 + src/proxy_server.rs | 162 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 docs/changelog/v1.9.17.md diff --git a/Cargo.lock b/Cargo.lock index 1be9c4b..21642ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2624,7 +2624,7 @@ dependencies = [ [[package]] name = "mhrv-rs" -version = "1.9.16" +version = "1.9.17" dependencies = [ "base64 0.22.1", "bytes", diff --git a/Cargo.toml b/Cargo.toml index a5cb393..469126c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mhrv-rs" -version = "1.9.16" +version = "1.9.17" edition = "2021" description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting" license = "MIT" diff --git a/docs/changelog/v1.9.17.md b/docs/changelog/v1.9.17.md new file mode 100644 index 0000000..1c80361 --- /dev/null +++ b/docs/changelog/v1.9.17.md @@ -0,0 +1,4 @@ + +• 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). +--- +• 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). diff --git a/src/proxy_server.rs b/src/proxy_server.rs index 0d29082..549a4bb 100644 --- a/src/proxy_server.rs +++ b/src/proxy_server.rs @@ -2474,6 +2474,31 @@ where } else { fronter.relay(&method, &url, &headers, &body).await }; + + // CORS response-header injection. The preflight short-circuit + // above handles `OPTIONS`, but the *actual* fetch that follows + // also needs CORS-compliant headers on the way back, or the + // browser drops the response and the JS layer sees a CORS + // failure. Apps Script's `UrlFetchApp.fetch()` preserves the + // origin server's response headers inconsistently — sometimes the + // destination returns `Access-Control-Allow-Origin: *` (which is + // incompatible with `Allow-Credentials: true`), sometimes omits + // ACL headers entirely. The visible symptom on YouTube is comments + // not loading and the "restricted" gate firing on cross-origin + // XHR responses that the browser rejected before the JS handler + // could even read them. Idea credit: ThisIsDara/mhr-cfw-go. + // + // Only injects when the request had an `Origin` header — non-CORS + // requests (top-level navigation, plain image fetches) don't need + // the headers and adding them would be noise. The relay response + // is otherwise byte-identical, so this never affects non-browser + // clients (curl, wget, app-level HTTP clients). + let response = if let Some(origin) = header_value(&headers, "origin") { + inject_cors_response_headers(&response, origin) + } else { + response + }; + stream.write_all(&response).await?; stream.flush().await?; @@ -2521,6 +2546,80 @@ fn header_value<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a s .map(|(_, v)| v.as_str()) } +/// Strip any `Access-Control-*` response headers the origin server +/// emitted (or that Apps Script's `UrlFetchApp.fetch()` may have +/// mangled / dropped) and inject a permissive set keyed on the +/// browser's request `Origin`. Returns a new response buffer; never +/// mutates in place. +/// +/// The body is preserved byte-for-byte; only the header block before +/// the first `\r\n\r\n` is rewritten. If the response can't be parsed +/// as HTTP/1.x (no header/body separator), it's returned unchanged so +/// edge-case responses (e.g. raw error blobs from upstream) aren't +/// corrupted. +/// +/// Why permissive (`Allow-Methods: *`, `Allow-Headers: *`, +/// `Expose-Headers: *`): the browser already pre-cleared the request +/// via the preflight short-circuit (line ~2435), and the relay path +/// doesn't expose anything that wasn't already going to the +/// destination through the user's own MITM trust anchor. The wide +/// permissions only relax browser-side CORS gating; they don't widen +/// the underlying network reach. `Allow-Credentials: true` is +/// echo-only-with-explicit-origin (spec requires it; `*` is invalid +/// alongside credentials) — that's why we echo the request's origin +/// and never use `*`. +fn inject_cors_response_headers(response: &[u8], origin: &str) -> Vec { + // Find the header / body separator. If we can't parse the + // response as HTTP/1.x, hand it back unchanged. + let sep = b"\r\n\r\n"; + let Some(idx) = response + .windows(sep.len()) + .position(|w| w == sep) + else { + return response.to_vec(); + }; + let head = &response[..idx]; + let body = &response[idx + sep.len()..]; + + // Rebuild the header block, dropping any pre-existing + // `Access-Control-*` lines so the destination's value can't + // conflict with ours. + let head_str = match std::str::from_utf8(head) { + Ok(s) => s, + Err(_) => return response.to_vec(), + }; + let mut out = String::with_capacity(head.len() + 256); + let mut lines = head_str.split("\r\n"); + if let Some(status) = lines.next() { + out.push_str(status); + out.push_str("\r\n"); + } + for line in lines { + let lower = line.to_ascii_lowercase(); + if lower.starts_with("access-control-") { + continue; + } + out.push_str(line); + out.push_str("\r\n"); + } + // Inject our own. `Vary: Origin` tells downstream caches that the + // response varies per request origin (so CDN-shared caches don't + // serve one user's CORS-tagged response to a different origin). + out.push_str("Access-Control-Allow-Origin: "); + out.push_str(origin); + out.push_str("\r\n"); + out.push_str("Access-Control-Allow-Credentials: true\r\n"); + out.push_str("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD\r\n"); + out.push_str("Access-Control-Allow-Headers: *\r\n"); + out.push_str("Access-Control-Expose-Headers: *\r\n"); + out.push_str("Vary: Origin\r\n"); + out.push_str("\r\n"); + + let mut buf = out.into_bytes(); + buf.extend_from_slice(body); + buf +} + fn expects_100_continue(headers: &[(String, String)]) -> bool { header_value(headers, "expect") .map(|v| { @@ -3236,6 +3335,69 @@ mod tests { assert!(!matches_passthrough("", &list)); } + #[test] + fn inject_cors_response_headers_replaces_existing_acl_with_origin_echo() { + // Origin server returned `Access-Control-Allow-Origin: *` which + // browsers reject when paired with `Allow-Credentials: true` (the + // YouTube comments failure mode). Our injection must strip the + // wildcard and substitute the request's actual origin so that + // credentialed requests succeed. + let response = b"HTTP/1.1 200 OK\r\n\ + Content-Type: application/json\r\n\ + Access-Control-Allow-Origin: *\r\n\ + Access-Control-Allow-Methods: GET\r\n\ + Content-Length: 12\r\n\ + \r\n\ + {\"a\":\"b\"}xx"; + let injected = inject_cors_response_headers(response, "https://www.youtube.com"); + let s = std::str::from_utf8(&injected).unwrap(); + // Original wildcard must be gone. + assert!( + !s.contains("Access-Control-Allow-Origin: *"), + "wildcard origin must be stripped, got: {}", + s + ); + // Echoed origin + credentials must be present. + assert!(s.contains("Access-Control-Allow-Origin: https://www.youtube.com\r\n")); + assert!(s.contains("Access-Control-Allow-Credentials: true\r\n")); + // Body preserved byte-for-byte. + assert!(injected.ends_with(b"{\"a\":\"b\"}xx")); + // Status line preserved. + assert!(s.starts_with("HTTP/1.1 200 OK\r\n")); + } + + #[test] + fn inject_cors_response_headers_preserves_non_acl_headers() { + // Non-ACL headers (Content-Type, Set-Cookie, Cache-Control, …) + // must pass through unchanged. Only `Access-Control-*` lines + // are stripped. + let response = b"HTTP/1.1 200 OK\r\n\ + Content-Type: text/html\r\n\ + Set-Cookie: a=1\r\n\ + Cache-Control: max-age=300\r\n\ + Access-Control-Allow-Origin: https://other.example\r\n\ + \r\n\ + body"; + let injected = inject_cors_response_headers(response, "https://www.youtube.com"); + let s = std::str::from_utf8(&injected).unwrap(); + assert!(s.contains("Content-Type: text/html\r\n")); + assert!(s.contains("Set-Cookie: a=1\r\n")); + assert!(s.contains("Cache-Control: max-age=300\r\n")); + // Wrong origin replaced. + assert!(!s.contains("Access-Control-Allow-Origin: https://other.example\r\n")); + assert!(s.contains("Access-Control-Allow-Origin: https://www.youtube.com\r\n")); + } + + #[test] + fn inject_cors_response_headers_returns_unchanged_when_no_header_terminator() { + // A response missing the `\r\n\r\n` separator (e.g. raw error + // blob, truncated upstream) must round-trip unchanged so we + // don't corrupt non-HTTP/1.x bytes. + let response = b"not an http response"; + let injected = inject_cors_response_headers(response, "https://x.com"); + assert_eq!(injected.as_slice(), response); + } + #[test] fn passthrough_hosts_ignores_empty_and_whitespace_entries() { let list = vec!["".to_string(), " ".to_string(), "real.com".to_string()];