From b4d0f737fba3b07b84f37cae219a397122e0b4b3 Mon Sep 17 00:00:00 2001 From: Captain Mirage <87281406+CaptainMirage@users.noreply.github.com> Date: Fri, 15 May 2026 02:49:27 +0330 Subject: [PATCH 1/4] fix(exit-node): fix double-wrapped response envelope in relay path (#1022) Fixes #1022 -- exit-node-routed requests returned raw {s,h,b} JSON to the browser instead of actual page content. Root cause: Code.gs had no raw-return handler. The Rust client sets r:true on the outer Apps Script request to signal verbatim passthrough, but without a matching branch Code.gs was wrapping the exit node's {s,h,b} response in a second {s,h,b} envelope. parse_exit_node_response() peeled one layer and handed the inner {s,h,b} JSON string to the browser as the body. Code.gs (_doSingle): - add req.r === true branch returning resp.getContentText() verbatim via ContentService before the normal {s,h,b} wrap - _buildOpts: followRedirects is now unconditionally true; r controls raw-return mode only, not redirect following domain_fronter.rs (parse_exit_node_response): - scan for \r\n\r\n separator and skip any HTTP framing prefix before JSON parsing; some Apps Script edge nodes prepend HTTP headers to the response body - add content-encoding to SKIP_RESPONSE_HEADERS; exit node fetch() auto-decompresses so forwarding the header causes Content Encoding Error in the browser --- assets/apps_script/Code.gs | 48 +++++++++++++++----------------------- src/domain_fronter.rs | 23 ++++++++++++++---- 2 files changed, 37 insertions(+), 34 deletions(-) diff --git a/assets/apps_script/Code.gs b/assets/apps_script/Code.gs index 13922255..e2adece3 100644 --- a/assets/apps_script/Code.gs +++ b/assets/apps_script/Code.gs @@ -161,9 +161,6 @@ function _doSingle(req) { return _json({ e: "bad url" }); } - // ── Optional cache path ──────────────────────────────── - // Only entered when CACHE_SPREADSHEET_ID is configured and - // the request qualifies as a public, cachable GET. if (_canUseCache(req)) { var cached = _getFromCache(req.u, req.h); if (cached) { @@ -184,32 +181,25 @@ function _doSingle(req) { cached: false, }); } - // If _fetchAndCache returns null (spreadsheet unavailable), - // fall through to the normal relay path below. - } - - // ── Normal relay (cache disabled or unavailable) ──────── - // Wrap the fetch + body encode in try/catch so any failure surfaces as - // a JSON error envelope the Rust client can parse. Without this, throws - // from UrlFetchApp.fetch (URL too long, payload too large, quota - // exhausted, 6-minute execution timeout) or from base64Encode (response - // body near Apps Script's ~50 MB ceiling can blow the V8 heap during - // encode) propagate unhandled, and Apps Script serves its default - // `Web App` HTML error page — which the client then - // reports as "Relay failed: bad response: no json in: Web App>..." - // and the user has no signal as to the actual cause. Mirrors the - // per-item try/catch in _doBatch below. - try { - var opts = _buildOpts(req); - var resp = UrlFetchApp.fetch(req.u, opts); - return _json({ - s: resp.getResponseCode(), - h: _respHeaders(resp), - b: Utilities.base64Encode(resp.getContent()), - }); - } catch (err) { - return _json({ e: "fetch failed: " + String(err) }); + // _fetchAndCache returned null → fall through to normal relay + } + + var opts = _buildOpts(req); + var resp = UrlFetchApp.fetch(req.u, opts); + + // Raw-return mode for exit-node path. + // r:true = return destination body verbatim so Rust gets {s,h,b} unwrapped. + if (req.r === true) { + return ContentService + .createTextOutput(resp.getContentText()) + .setMimeType(ContentService.MimeType.JSON); } + + return _json({ + s: resp.getResponseCode(), + h: _respHeaders(resp), + b: Utilities.base64Encode(resp.getContent()), + }); } // ── Batch Request ────────────────────────────────────────── @@ -307,7 +297,7 @@ function _buildOpts(req) { var opts = { method: (req.m || "GET").toLowerCase(), muteHttpExceptions: true, - followRedirects: req.r !== false, + followRedirects: true, // ← always true; r flag now has different meaning validateHttpsCertificates: true, escaping: false, }; diff --git a/src/domain_fronter.rs b/src/domain_fronter.rs index a41fe3e4..ea237921 100644 --- a/src/domain_fronter.rs +++ b/src/domain_fronter.rs @@ -2686,9 +2686,15 @@ impl DomainFronter { .send_prebuilt_payload_through_relay(outer_payload) .await?; - // exit-node's JSON envelope: {s: u16, h: {...}, b: "<base64>"} on - // success, {e: "..."} on its own internal error. - parse_exit_node_response(&app_body) + tracing::warn!( + "EXIT_DIAG app_body len={} first_200={:?}", + app_body.len(), + String::from_utf8_lossy(&app_body[..app_body.len().min(200)]) + ); + + let result = parse_exit_node_response(&app_body); + tracing::warn!("EXIT_DIAG parse_result ok={}", result.is_ok()); + result } /// Build the inner-layer payload that the exit node will execute. @@ -3961,11 +3967,17 @@ fn unix_to_ymd_utc(secs: u64) -> (i64, u32, u32) { /// MITM TLS write-back path sees the same shape it gets from the regular /// Apps Script relay (status line + headers + body). fn parse_exit_node_response(body: &[u8]) -> Result<Vec<u8>, FronterError> { - let v: Value = serde_json::from_slice(body).map_err(|e| { + let json_start = body + .windows(4) + .position(|w| w == b"\r\n\r\n") + .map(|i| i + 4) + .unwrap_or(0); + let json_bytes = &body[json_start..]; + let v: Value = serde_json::from_slice(json_bytes).map_err(|e| { FronterError::Relay(format!( "exit-node response not valid JSON ({}): {}", e, - String::from_utf8_lossy(&body[..body.len().min(200)]) + String::from_utf8_lossy(&json_bytes[..json_bytes.len().min(200)]) )) })?; @@ -4001,6 +4013,7 @@ fn parse_exit_node_response(body: &[u8]) -> Result<Vec<u8>, FronterError> { "transfer-encoding", "connection", "keep-alive", + "content-encoding", // exit node's fetch() auto-decompresses; header is stale ]; let mut out = Vec::with_capacity(body_bytes.len() + 256); From 23464f3ff95b2ec58d23f41ba2596d6ca0fba315 Mon Sep 17 00:00:00 2001 From: Captain Mirage <87281406+CaptainMirage@users.noreply.github.com> Date: Fri, 15 May 2026 02:50:57 +0330 Subject: [PATCH 2/4] fix(domain_fronter): fix panic from non-char-boundary slice in error paths Four error format strings truncated a &str at byte offset 200 using &text[..text.len().min(200)]. When the response body contains multi-byte UTF-8 characters (quota error HTML, brotli-compressed or binary Apps Script responses, cold-start warning pages), byte offset 200 can fall inside a character boundary, causing a panic and SIGILL core dump. Replace byte-offset truncation with char-aware truncation via .chars().take(200).collect::<String>() at all four sites: - parse_relay_json: "no json in" and "no json end in" messages - finalize_tunnel_response: "no json in tunnel response" message - finalize_batch_response: "no json in batch response" message Reproducible under normal operating conditions when Apps Script returns a non-JSON body under quota pressure or transient errors. --- src/domain_fronter.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/domain_fronter.rs b/src/domain_fronter.rs index ea237921..e7202341 100644 --- a/src/domain_fronter.rs +++ b/src/domain_fronter.rs @@ -3037,7 +3037,7 @@ impl DomainFronter { let start = text.find('{').ok_or_else(|| { FronterError::BadResponse(format!( "no json in tunnel response: {}", - &text[..text.len().min(200)] + &text.chars().take(200).collect::<String>() )) })?; let end = text.rfind('}').ok_or_else(|| { @@ -3205,7 +3205,7 @@ impl DomainFronter { let start = text.find('{').ok_or_else(|| { FronterError::BadResponse(format!( "no json in batch response: {}", - &text[..text.len().min(200)] + &text.chars().take(200).collect::<String>() )) })?; let end = text.rfind('}').ok_or_else(|| { @@ -4578,13 +4578,13 @@ fn parse_relay_json(body: &[u8]) -> Result<Vec<u8>, FronterError> { let start = text.find('{').ok_or_else(|| { FronterError::BadResponse(format!( "no json in: {}", - &text[..text.len().min(200)] + &text.chars().take(200).collect::<String>() )) })?; let end = text.rfind('}').ok_or_else(|| { FronterError::BadResponse(format!( "no json end in: {}", - &text[..text.len().min(200)] + &text.chars().take(200).collect::<String>() )) })?; serde_json::from_str(&text[start..=end])? From eff77073af4d1f653486495582ae83ce8e0f886c Mon Sep 17 00:00:00 2001 From: Captain Mirage <87281406+CaptainMirage@users.noreply.github.com> Date: Fri, 15 May 2026 12:35:10 +0330 Subject: [PATCH 3/4] fix(pr-review): restored the try/catch in _doSingle, remove EXIT_DIAG debug logging --- assets/apps_script/Code.gs | 53 ++++++++++++++++++++++++++------------ src/domain_fronter.rs | 7 ++--- 2 files changed, 40 insertions(+), 20 deletions(-) diff --git a/assets/apps_script/Code.gs b/assets/apps_script/Code.gs index e2adece3..1b1972a4 100644 --- a/assets/apps_script/Code.gs +++ b/assets/apps_script/Code.gs @@ -161,6 +161,9 @@ function _doSingle(req) { return _json({ e: "bad url" }); } + // ── Optional cache path ──────────────────────────────── + // Only entered when CACHE_SPREADSHEET_ID is configured and + // the request qualifies as a public, cachable GET. if (_canUseCache(req)) { var cached = _getFromCache(req.u, req.h); if (cached) { @@ -181,25 +184,41 @@ function _doSingle(req) { cached: false, }); } - // _fetchAndCache returned null → fall through to normal relay - } - - var opts = _buildOpts(req); - var resp = UrlFetchApp.fetch(req.u, opts); + // If _fetchAndCache returns null (spreadsheet unavailable), + // fall through to the normal relay path below. + } + + // ── Normal relay (cache disabled or unavailable) ──────── + // Wrap the fetch + body encode in try/catch so any failure surfaces as + // a JSON error envelope the Rust client can parse. Without this, throws + // from UrlFetchApp.fetch (URL too long, payload too large, quota + // exhausted, 6-minute execution timeout) or from base64Encode (response + // body near Apps Script's ~50 MB ceiling can blow the V8 heap during + // encode) propagate unhandled, and Apps Script serves its default + // `<title>Web App` HTML error page — which the client then + // reports as "Relay failed: bad response: no json in: Web App>..." + // and the user has no signal as to the actual cause. Mirrors the + // per-item try/catch in _doBatch below. + try { + var opts = _buildOpts(req); + var resp = UrlFetchApp.fetch(req.u, opts); + + // Raw-return mode for exit-node path. + // r:true = return destination body verbatim so Rust gets {s,h,b} unwrapped. + if (req.r === true) { + return ContentService + .createTextOutput(resp.getContentText()) + .setMimeType(ContentService.MimeType.JSON); + } - // Raw-return mode for exit-node path. - // r:true = return destination body verbatim so Rust gets {s,h,b} unwrapped. - if (req.r === true) { - return ContentService - .createTextOutput(resp.getContentText()) - .setMimeType(ContentService.MimeType.JSON); + return _json({ + s: resp.getResponseCode(), + h: _respHeaders(resp), + b: Utilities.base64Encode(resp.getContent()), + }); + } catch (err) { + return _json({ e: "fetch failed: " + String(err) }); } - - return _json({ - s: resp.getResponseCode(), - h: _respHeaders(resp), - b: Utilities.base64Encode(resp.getContent()), - }); } // ── Batch Request ────────────────────────────────────────── diff --git a/src/domain_fronter.rs b/src/domain_fronter.rs index e7202341..ab037946 100644 --- a/src/domain_fronter.rs +++ b/src/domain_fronter.rs @@ -2686,14 +2686,15 @@ impl DomainFronter { .send_prebuilt_payload_through_relay(outer_payload) .await?; - tracing::warn!( + // temporary diagnostics for exit-node response debugging. + // Logs the raw app_body before parse_exit_node_response() is called. + tracing::debug!( "EXIT_DIAG app_body len={} first_200={:?}", app_body.len(), String::from_utf8_lossy(&app_body[..app_body.len().min(200)]) ); let result = parse_exit_node_response(&app_body); - tracing::warn!("EXIT_DIAG parse_result ok={}", result.is_ok()); result } @@ -3969,7 +3970,7 @@ fn unix_to_ymd_utc(secs: u64) -> (i64, u32, u32) { fn parse_exit_node_response(body: &[u8]) -> Result<Vec<u8>, FronterError> { let json_start = body .windows(4) - .position(|w| w == b"\r\n\r\n") + .position(|w| w == b"\r\n\r\n") .map(|i| i + 4) .unwrap_or(0); let json_bytes = &body[json_start..]; From 2fe2c4177c3bf232eab6226f4dc75c034106fb88 Mon Sep 17 00:00:00 2001 From: Captain Mirage <87281406+CaptainMirage@users.noreply.github.com> Date: Fri, 15 May 2026 18:12:18 +0330 Subject: [PATCH 4/4] fix(PR-re-review): removed EXIT_DIAG debug entirely --- src/domain_fronter.rs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/domain_fronter.rs b/src/domain_fronter.rs index ab037946..b66db041 100644 --- a/src/domain_fronter.rs +++ b/src/domain_fronter.rs @@ -2685,15 +2685,7 @@ impl DomainFronter { let app_body = self .send_prebuilt_payload_through_relay(outer_payload) .await?; - - // temporary diagnostics for exit-node response debugging. - // Logs the raw app_body before parse_exit_node_response() is called. - tracing::debug!( - "EXIT_DIAG app_body len={} first_200={:?}", - app_body.len(), - String::from_utf8_lossy(&app_body[..app_body.len().min(200)]) - ); - + let result = parse_exit_node_response(&app_body); result }