mirror of
https://github.com/therealaleph/MasterHttpRelayVPN-RUST.git
synced 2026-05-17 21:24:48 +03:00
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:
Generated
+1
-1
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 (مثل 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).
|
||||
@@ -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()];
|
||||
|
||||
Reference in New Issue
Block a user