diff --git a/docs/guide.fa.md b/docs/guide.fa.md index d0247453..2d782a56 100644 --- a/docs/guide.fa.md +++ b/docs/guide.fa.md @@ -392,6 +392,8 @@ sni_hosts = ["www.google.com", "drive.google.com", "docs.google.com"] HTML یوتیوب سریع می‌آید (از تونل بازنویسی SNI)، اما chunkهای ویدیو از `googlevideo.com` از Apps Script رد می‌شوند. سهمیهٔ رایگان: ~۲۰٬۰۰۰ `UrlFetchApp` در روز، سقف بدنهٔ ۵۰ مگابایت per fetch. +**آپلودهای بزرگ در حالت Apps Script محلی fail می‌شوند.** در حالت `apps_script`، درخواست‌های دارای بدنه (`POST`، `PUT`، `PATCH`) اگر بزرگ‌تر از ۵ MiB باشند، `Transfer-Encoding: chunked` داشته باشند، یا `Content-Length` مشخص نداشته باشند، با `HTTP 413` توسط proxy محلی رد می‌شوند. Apps Script بدنهٔ درخواست را قبل از اینکه کد اسکریپت بتواند آن را stream یا validate کند در حافظه materialize می‌کند، پس کلاینت fail-closed می‌شود. + برای مرور متنی خوب است، برای ۱۰۸۰p دردناک. چند `script_id` بچرخان برای هد روم بیشتر، یا VPN واقعی برای ویدیو. ### Brotli حذف می‌شود diff --git a/docs/guide.md b/docs/guide.md index 679a35d0..b39a34b7 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -378,6 +378,7 @@ These are inherent to the Apps Script + domain-fronting approach, not bugs in th - **User-Agent fixed to `Google-Apps-Script`** for traffic through the relay. `UrlFetchApp.fetch()` doesn't allow override. Sites that detect bots (Google search, some CAPTCHAs) serve degraded / no-JS pages. Workaround: add the affected domain to the `hosts` map so it's routed through the SNI-rewrite tunnel with your real browser's UA. `google.com`, `youtube.com`, `fonts.googleapis.com` are already there. - **Video playback slow and quota-limited** for anything through the relay. YouTube HTML loads fast (SNI-rewrite tunnel), but `googlevideo.com` chunks go through Apps Script. Free tier: ~20k `UrlFetchApp` calls / day, 50 MB body cap per fetch. Fine for text browsing, painful for 1080p. Rotate multiple `script_id`s for headroom, or use a real VPN for video. +- **Large Apps Script uploads fail locally.** In `apps_script` mode, mutating requests (`POST`, `PUT`, `PATCH`) with bodies over 5 MiB, chunked transfer encoding, or no declared `Content-Length` are rejected by the local proxy with `HTTP 413`. Apps Script materializes request bodies before script code can stream or validate them, so the client fails closed rather than sending an upload it cannot bound safely. - **Brotli stripped** from forwarded `Accept-Encoding`. Apps Script can decompress gzip but not `br`; forwarding `br` would garble responses. Minor size overhead. - **WebSockets don't work** through the relay — it's request / response JSON. Sites that upgrade to WS fail (ChatGPT streaming, Discord voice, etc.). - **HSTS-preloaded / hard-pinned sites** reject the MITM cert. Most sites are fine; a handful aren't. diff --git a/src/proxy_server.rs b/src/proxy_server.rs index 209bbc58..2b118b69 100644 --- a/src/proxy_server.rs +++ b/src/proxy_server.rs @@ -118,6 +118,11 @@ const YOUTUBE_RELAY_HOSTS: &[&str] = &[ "youtubei.googleapis.com", ]; +/// Apps Script request bodies are materialized server-side before user code can +/// inspect them. Keep the client-side ceiling conservative so mutating uploads +/// fail locally instead of risking relay corruption or V8 heap pressure. +pub const APPS_SCRIPT_UPLOAD_MAX_BYTES: usize = 5 * 1024 * 1024; + /// Built-in list of DNS-over-HTTPS endpoints. CONNECTs to these (when /// `tunnel_doh` is left at the default of `false`, i.e. bypass enabled) /// skip the Apps Script tunnel and exit via plain TCP. Mix of the @@ -848,7 +853,7 @@ async fn handle_http_client( // `http://example.com` URL used to return a 502 here even // though `https://example.com` (CONNECT) worked fine. match fronter { - Some(f) => do_plain_http(sock, &head, &leftover, f).await, + Some(f) => do_plain_http(sock, &head, &leftover, f, rewrite_ctx.mode).await, None => do_plain_http_passthrough(sock, &head, &leftover, &rewrite_ctx).await, } } @@ -2420,6 +2425,18 @@ where None => return Ok(false), }; + if let Some(reason) = apps_script_upload_rejection_reason(&method, &headers) { + tracing::warn!( + "rejecting {} upload to {}:{} before Apps Script relay: {}", + method, + host, + port, + reason + ); + write_payload_too_large(stream).await?; + return Ok(false); + } + let body = read_body(stream, &leftover, &headers).await?; // ── Per-host URL fix-ups ────────────────────────────────────────── @@ -2711,6 +2728,64 @@ fn invalid_body(msg: impl Into) -> std::io::Error { std::io::Error::new(std::io::ErrorKind::InvalidData, msg.into()) } +fn is_mutating_upload_method(method: &str) -> bool { + method.eq_ignore_ascii_case("POST") + || method.eq_ignore_ascii_case("PUT") + || method.eq_ignore_ascii_case("PATCH") +} + +fn apps_script_upload_rejection_reason( + method: &str, + headers: &[(String, String)], +) -> Option<&'static str> { + if !is_mutating_upload_method(method) { + return None; + } + + let transfer_encoding = header_value(headers, "transfer-encoding"); + if transfer_encoding + .map(|v| { + v.split(',') + .any(|part| part.trim().eq_ignore_ascii_case("chunked")) + }) + .unwrap_or(false) + { + return Some("chunked mutating upload has no safe Apps Script size bound"); + } + + let Some(content_length) = header_value(headers, "content-length") else { + return Some("mutating upload is missing Content-Length"); + }; + + let Ok(content_length) = content_length.parse::() else { + return None; + }; + + if content_length > APPS_SCRIPT_UPLOAD_MAX_BYTES { + return Some("mutating upload exceeds Apps Script payload ceiling"); + } + + None +} + +async fn write_payload_too_large(stream: &mut S) -> std::io::Result<()> +where + S: tokio::io::AsyncWrite + Unpin, +{ + const BODY: &str = + "Payload Too Large: Apps Script mode supports request bodies up to 5 MiB.\n"; + let response = format!( + "HTTP/1.1 413 Payload Too Large\r\n\ + Connection: close\r\n\ + Content-Type: text/plain; charset=utf-8\r\n\ + Content-Length: {}\r\n\r\n{}", + BODY.len(), + BODY + ); + stream.write_all(response.as_bytes()).await?; + stream.flush().await +} + async fn read_body( stream: &mut S, leftover: &[u8], @@ -2870,10 +2945,24 @@ async fn do_plain_http( head: &[u8], leftover: &[u8], fronter: Arc, + mode: Mode, ) -> std::io::Result<()> { let (method, target, _version, headers) = parse_request_head(head) .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "bad request"))?; + if mode == Mode::AppsScript { + if let Some(reason) = apps_script_upload_rejection_reason(&method, &headers) { + tracing::warn!( + "rejecting {} upload to {} before Apps Script relay: {}", + method, + target, + reason + ); + write_payload_too_large(&mut sock).await?; + return Ok(()); + } + } + let body = read_body(&mut sock, leftover, &headers).await?; // Browser sends `GET http://example.com/path HTTP/1.1` on plain proxy. @@ -3095,6 +3184,85 @@ mod tests { .collect() } + fn apps_script_test_fronter() -> DomainFronter { + let toml = r#" +[relay] +mode = "apps_script" +auth_key = "MY_SECRET_KEY_123" +script_id = "TEST_DEPLOYMENT" +"#; + let cfg = Config::from(toml::from_str::(toml).unwrap()); + DomainFronter::new(&cfg).unwrap() + } + + #[test] + fn apps_script_upload_guard_allows_small_mutating_request() { + assert!(apps_script_upload_rejection_reason( + "POST", + &headers(&[("Content-Length", "1024")]), + ) + .is_none()); + } + + #[test] + fn apps_script_upload_guard_ignores_non_mutating_request() { + assert!(apps_script_upload_rejection_reason("GET", &headers(&[])).is_none()); + } + + #[test] + fn apps_script_upload_guard_rejects_large_mutating_request() { + let oversized = (APPS_SCRIPT_UPLOAD_MAX_BYTES + 1).to_string(); + assert!(apps_script_upload_rejection_reason( + "PUT", + &headers(&[("Content-Length", oversized.as_str())]), + ) + .is_some()); + } + + #[test] + fn apps_script_upload_guard_rejects_chunked_mutating_request() { + assert!(apps_script_upload_rejection_reason( + "PATCH", + &headers(&[("Transfer-Encoding", "chunked")]), + ) + .is_some()); + } + + #[test] + fn apps_script_upload_guard_rejects_unknown_length_mutating_request() { + assert!(apps_script_upload_rejection_reason("POST", &headers(&[])).is_some()); + } + + #[tokio::test(flavor = "current_thread")] + async fn mitm_large_upload_returns_413_before_reading_body() { + let fronter = apps_script_test_fronter(); + let (mut client, mut server) = duplex(4096); + + client + .write_all( + format!( + "POST /upload HTTP/1.1\r\nHost: example.com\r\nContent-Length: {}\r\n\r\n", + APPS_SCRIPT_UPLOAD_MAX_BYTES + 1 + ) + .as_bytes(), + ) + .await + .unwrap(); + + let keep_alive = + handle_mitm_request(&mut server, "example.com", 443, &fronter, "https") + .await + .unwrap(); + + let mut response = vec![0u8; 256]; + let n = client.read(&mut response).await.unwrap(); + let response = String::from_utf8_lossy(&response[..n]); + + assert!(!keep_alive); + assert!(response.starts_with("HTTP/1.1 413 Payload Too Large")); + assert!(response.contains("Apps Script mode supports request bodies up to 5 MiB")); + } + #[test] fn resolve_plain_http_target_parses_absolute_form() { let h = headers(&[]);