Skip to content

Commit b4d0f73

Browse files
committed
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
1 parent 598a890 commit b4d0f73

2 files changed

Lines changed: 37 additions & 34 deletions

File tree

assets/apps_script/Code.gs

Lines changed: 19 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,6 @@ function _doSingle(req) {
161161
return _json({ e: "bad url" });
162162
}
163163

164-
// ── Optional cache path ────────────────────────────────
165-
// Only entered when CACHE_SPREADSHEET_ID is configured and
166-
// the request qualifies as a public, cachable GET.
167164
if (_canUseCache(req)) {
168165
var cached = _getFromCache(req.u, req.h);
169166
if (cached) {
@@ -184,32 +181,25 @@ function _doSingle(req) {
184181
cached: false,
185182
});
186183
}
187-
// If _fetchAndCache returns null (spreadsheet unavailable),
188-
// fall through to the normal relay path below.
189-
}
190-
191-
// ── Normal relay (cache disabled or unavailable) ────────
192-
// Wrap the fetch + body encode in try/catch so any failure surfaces as
193-
// a JSON error envelope the Rust client can parse. Without this, throws
194-
// from UrlFetchApp.fetch (URL too long, payload too large, quota
195-
// exhausted, 6-minute execution timeout) or from base64Encode (response
196-
// body near Apps Script's ~50 MB ceiling can blow the V8 heap during
197-
// encode) propagate unhandled, and Apps Script serves its default
198-
// `<title>Web App</title>` HTML error page — which the client then
199-
// reports as "Relay failed: bad response: no json in: <title>Web App>..."
200-
// and the user has no signal as to the actual cause. Mirrors the
201-
// per-item try/catch in _doBatch below.
202-
try {
203-
var opts = _buildOpts(req);
204-
var resp = UrlFetchApp.fetch(req.u, opts);
205-
return _json({
206-
s: resp.getResponseCode(),
207-
h: _respHeaders(resp),
208-
b: Utilities.base64Encode(resp.getContent()),
209-
});
210-
} catch (err) {
211-
return _json({ e: "fetch failed: " + String(err) });
184+
// _fetchAndCache returned null → fall through to normal relay
185+
}
186+
187+
var opts = _buildOpts(req);
188+
var resp = UrlFetchApp.fetch(req.u, opts);
189+
190+
// Raw-return mode for exit-node path.
191+
// r:true = return destination body verbatim so Rust gets {s,h,b} unwrapped.
192+
if (req.r === true) {
193+
return ContentService
194+
.createTextOutput(resp.getContentText())
195+
.setMimeType(ContentService.MimeType.JSON);
212196
}
197+
198+
return _json({
199+
s: resp.getResponseCode(),
200+
h: _respHeaders(resp),
201+
b: Utilities.base64Encode(resp.getContent()),
202+
});
213203
}
214204

215205
// ── Batch Request ──────────────────────────────────────────
@@ -307,7 +297,7 @@ function _buildOpts(req) {
307297
var opts = {
308298
method: (req.m || "GET").toLowerCase(),
309299
muteHttpExceptions: true,
310-
followRedirects: req.r !== false,
300+
followRedirects: true, // ← always true; r flag now has different meaning
311301
validateHttpsCertificates: true,
312302
escaping: false,
313303
};

src/domain_fronter.rs

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2686,9 +2686,15 @@ impl DomainFronter {
26862686
.send_prebuilt_payload_through_relay(outer_payload)
26872687
.await?;
26882688

2689-
// exit-node's JSON envelope: {s: u16, h: {...}, b: "<base64>"} on
2690-
// success, {e: "..."} on its own internal error.
2691-
parse_exit_node_response(&app_body)
2689+
tracing::warn!(
2690+
"EXIT_DIAG app_body len={} first_200={:?}",
2691+
app_body.len(),
2692+
String::from_utf8_lossy(&app_body[..app_body.len().min(200)])
2693+
);
2694+
2695+
let result = parse_exit_node_response(&app_body);
2696+
tracing::warn!("EXIT_DIAG parse_result ok={}", result.is_ok());
2697+
result
26922698
}
26932699

26942700
/// 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) {
39613967
/// MITM TLS write-back path sees the same shape it gets from the regular
39623968
/// Apps Script relay (status line + headers + body).
39633969
fn parse_exit_node_response(body: &[u8]) -> Result<Vec<u8>, FronterError> {
3964-
let v: Value = serde_json::from_slice(body).map_err(|e| {
3970+
let json_start = body
3971+
.windows(4)
3972+
.position(|w| w == b"\r\n\r\n")
3973+
.map(|i| i + 4)
3974+
.unwrap_or(0);
3975+
let json_bytes = &body[json_start..];
3976+
let v: Value = serde_json::from_slice(json_bytes).map_err(|e| {
39653977
FronterError::Relay(format!(
39663978
"exit-node response not valid JSON ({}): {}",
39673979
e,
3968-
String::from_utf8_lossy(&body[..body.len().min(200)])
3980+
String::from_utf8_lossy(&json_bytes[..json_bytes.len().min(200)])
39693981
))
39703982
})?;
39713983

@@ -4001,6 +4013,7 @@ fn parse_exit_node_response(body: &[u8]) -> Result<Vec<u8>, FronterError> {
40014013
"transfer-encoding",
40024014
"connection",
40034015
"keep-alive",
4016+
"content-encoding", // exit node's fetch() auto-decompresses; header is stale
40044017
];
40054018

40064019
let mut out = Vec::with_capacity(body_bytes.len() + 256);

0 commit comments

Comments
 (0)