From 2be55c23d9f0045edf9aea6bc293f388e0b3a022 Mon Sep 17 00:00:00 2001 From: May Knott Date: Sat, 23 May 2026 21:09:09 +0330 Subject: [PATCH] fix(proxy): fail closed on unsafe Apps Script uploads Apps Script receives relay requests as fully materialized HTTP bodies before script code can inspect, stream, or reject them. A mutating upload with an oversized body, chunked transfer encoding, or no declared Content-Length cannot be bounded reliably once it has entered the Apps Script execution path. The local proxy now enforces that boundary before reading or forwarding the body. The Apps Script proxy path now defines a conservative 5 MiB request-body ceiling for mutating methods. POST, PUT, and PATCH requests are rejected when Content-Length exceeds that ceiling, when Transfer-Encoding includes chunked, or when Content-Length is absent. Non-mutating requests are ignored by this policy, and malformed Content-Length parsing remains delegated to the existing body parser so unrelated request-validation behavior is unchanged. The HTTPS MITM relay path applies the guard immediately after parsing the request head and before read_body can buffer application bytes. The plain HTTP relay path receives the current runtime mode and applies the same guard only in apps_script mode. Rejected requests receive a local HTTP/1.1 413 Payload Too Large response with Connection: close and a short body explaining the Apps Script 5 MiB limit. The user guide now documents the visible 413 behavior in both English and Persian so operators understand that this is a client-side safety boundary for Apps Script mode rather than an upstream server failure. Focused proxy tests cover allowed small mutating requests, ignored non-mutating requests, oversized Content-Length rejection, chunked mutating upload rejection, missing-length mutating upload rejection, and the HTTPS MITM path returning 413 before body bytes are required. --- docs/guide.fa.md | 2 + docs/guide.md | 1 + src/proxy_server.rs | 170 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 172 insertions(+), 1 deletion(-) 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(&[]);