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>
This commit is contained in:
therealaleph
2026-05-07 20:56:37 +03:00
parent 5a4f535f90
commit 141cd6c7a8
4 changed files with 168 additions and 2 deletions
Generated
+1 -1
View File
@@ -2624,7 +2624,7 @@ dependencies = [
[[package]]
name = "mhrv-rs"
version = "1.9.16"
version = "1.9.17"
dependencies = [
"base64 0.22.1",
"bytes",
+1 -1
View File
@@ -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"
+4
View File
@@ -0,0 +1,4 @@
<!-- see docs/changelog/v1.1.0.md for the file format: Persian, then `---`, then English. -->
• Inject CORS response headers after relay — اضافه شد به‌جای فقط preflight short-circuit. مرورگرها در درخواست‌های cross-origin (مثل YouTubes `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).
+162
View File
@@ -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<u8> {
// 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()];