Skip to content

Commit 929927b

Browse files
committed
perf(relay): strip CDN noise response headers to reduce per-request JSON payload
CDN stacks (Cloudflare, AWS, Fastly) attach metadata headers to every response — report-to, nel, alt-svc, server-timing, cf-ray, origin-trial, etc. — that add 400-700 bytes of JSON per GAS relay response for zero benefit through a MITM proxy. The relay ignores them and the browser never reads them. On a Cloudflare-backed page with 50 subresource requests this wastes ~25-35 KB of transfer, ~40-50ms at 600 KB/s. Rust side (config-driven): - Add `strip_noise_response_headers: bool` to Config and TomlRelay, default true. Controls the primary user-facing toggle via config.toml. - Add NOISE_RESPONSE_HEADERS static in domain_fronter.rs listing the 12 useless header names. - Update parse_relay_json() to accept a strip_noise bool and skip listed headers in the output loop when enabled. - Pass self.strip_noise_response_headers at both call sites in do_relay_once_inner (h2 and h1 paths). Code.gs side (GAS payload reduction): - Add STRIP_NOISE_RESPONSE_HEADERS constant (default true) and STRIP_RESPONSE_HEADERS lookup object near DIAGNOSTIC_MODE. - Update _respHeaders() to filter the blocklist when the constant is true, reducing the JSON payload that travels over the GAS->Rust leg. - Both _doSingle and _doBatch call _respHeaders, so both relay paths get the filter automatically. The two layers are independent: Code.gs reduces GAS->Rust bandwidth; the Rust config controls what the browser sees. Setting strip_noise_response_headers = false in config.toml passes all headers through to the browser regardless of what Code.gs sends.
1 parent eb1d00c commit 929927b

3 files changed

Lines changed: 89 additions & 13 deletions

File tree

assets/apps_script/Code.gs

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,32 @@ const AUTH_KEY = "CHANGE_ME_TO_A_STRONG_SECRET";
4646
// (Inspired by #365 Section 3, mhrv-rs v1.8.0+.)
4747
const DIAGNOSTIC_MODE = false;
4848

49+
// ── Response header noise filtering ────────────────────────────────────────
50+
// CDN stacks (Cloudflare, AWS, Fastly, Google) attach metadata headers to
51+
// every response that are useless through a MITM relay: report-to, nel,
52+
// alt-svc, server-timing, etc. These add 400-700 bytes of JSON per response
53+
// for no benefit — the relay ignores them and the browser never reads them.
54+
//
55+
// STRIP_NOISE_RESPONSE_HEADERS controls whether _respHeaders() filters them
56+
// before returning. Hardcoded true here for GAS-side payload reduction.
57+
// The primary user toggle is `strip_noise_response_headers` in config.toml
58+
// on the Rust client side, which drops them even if Code.gs sends them.
59+
//
60+
// Set to false only if you need to see raw origin headers in GAS logs.
61+
// ---------------------------------------------------------------------------
62+
const STRIP_NOISE_RESPONSE_HEADERS = true;
63+
64+
const STRIP_RESPONSE_HEADERS = {
65+
"report-to": 1, "reporting-endpoints": 1,
66+
"nel": 1,
67+
"alt-svc": 1,
68+
"server-timing": 1,
69+
"origin-trial": 1,
70+
"cf-ray": 1, "cf-cache-status": 1,
71+
"x-amzn-requestid": 1, "x-amzn-trace-id": 1,
72+
"x-request-id": 1, "x-correlation-id": 1,
73+
};
74+
4975
// ── Optional Spreadsheet Cache ──────────────────────────────
5076
// Set to a valid Spreadsheet ID to enable response caching.
5177
// Leave as-is to disable caching entirely (zero overhead).
@@ -329,12 +355,20 @@ function _buildOpts(req) {
329355
}
330356

331357
function _respHeaders(resp) {
358+
var raw;
332359
try {
333-
if (typeof resp.getAllHeaders === "function") {
334-
return resp.getAllHeaders();
335-
}
336-
} catch (err) {}
337-
return resp.getHeaders();
360+
raw = typeof resp.getAllHeaders === "function"
361+
? resp.getAllHeaders()
362+
: resp.getHeaders();
363+
} catch (err) {
364+
raw = {};
365+
}
366+
if (!STRIP_NOISE_RESPONSE_HEADERS) return raw;
367+
var out = {};
368+
for (var k in raw) {
369+
if (!STRIP_RESPONSE_HEADERS[k.toLowerCase()]) out[k] = raw[k];
370+
}
371+
return out;
338372
}
339373

340374
function _json(obj) {

src/config.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,16 @@ pub struct Config {
433433
/// Default 500.
434434
#[serde(default = "default_quota_safety_buffer")]
435435
pub quota_safety_buffer: u64,
436+
437+
/// Strip CDN noise headers from relay responses before forwarding to
438+
/// the browser. Headers such as `report-to`, `nel`, `alt-svc`, and
439+
/// `server-timing` are attached by modern CDNs (Cloudflare, AWS,
440+
/// Fastly) and add 400–700 bytes per response for no benefit through
441+
/// a MITM relay — the proxy ignores them and the browser never reads
442+
/// them. Default `true`. Set to `false` only to inspect raw origin
443+
/// headers for debugging.
444+
#[serde(default = "default_strip_noise_response_headers")]
445+
pub strip_noise_response_headers: bool,
436446
}
437447

438448
/// Configuration for the optional second-hop exit node.
@@ -563,6 +573,7 @@ fn default_auto_blacklist_window_secs() -> u64 { 30 }
563573
fn default_auto_blacklist_cooldown_secs() -> u64 { 120 }
564574
fn default_quota_daily_limit() -> u64 { 20_000 }
565575
fn default_quota_safety_buffer() -> u64 { 500 }
576+
fn default_strip_noise_response_headers() -> bool { true }
566577

567578
/// Default for `request_timeout_secs`: 30s, matching the historical
568579
/// hard-coded `BATCH_TIMEOUT` and Apps Script's typical response cliff.
@@ -811,6 +822,8 @@ pub struct TomlRelay {
811822
pub request_timeout_secs: u64,
812823
#[serde(default = "default_stream_timeout_secs")]
813824
pub stream_timeout_secs: u64,
825+
#[serde(default = "default_strip_noise_response_headers")]
826+
pub strip_noise_response_headers: bool,
814827
}
815828

816829
/// [network] section of config.toml.
@@ -969,6 +982,7 @@ impl From<TomlConfig> for Config {
969982
exit_node: t.exit_node,
970983
quota_daily_limit: default_quota_daily_limit(),
971984
quota_safety_buffer: default_quota_safety_buffer(),
985+
strip_noise_response_headers: t.relay.strip_noise_response_headers,
972986
}
973987
}
974988
}
@@ -997,6 +1011,7 @@ impl From<&Config> for TomlConfig {
9971011
auto_blacklist_cooldown_secs: c.auto_blacklist_cooldown_secs,
9981012
request_timeout_secs: c.request_timeout_secs,
9991013
stream_timeout_secs: c.stream_timeout_secs,
1014+
strip_noise_response_headers: c.strip_noise_response_headers,
10001015
},
10011016
network: TomlNetwork {
10021017
google_ip: c.google_ip.clone(),

src/domain_fronter.rs

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,10 @@ pub struct DomainFronter {
413413
/// payloads. Mirrors `Config::disable_padding` (#391). Default false
414414
/// (padding active = stronger DPI defense at +25% bandwidth cost).
415415
disable_padding: bool,
416+
/// Strip CDN noise headers (report-to, nel, alt-svc, etc.) from the
417+
/// relay response before forwarding to the browser. Default true.
418+
/// Mirrors `Config::strip_noise_response_headers`.
419+
strip_noise_response_headers: bool,
416420
zstd_enabled: Arc<AtomicBool>,
417421
/// Per-instance auto-blacklist tuning. Mirrors `Config::auto_blacklist_*`
418422
/// (#391, #444). Cached here so the hot path in `record_timeout_strike`
@@ -648,6 +652,7 @@ impl DomainFronter {
648652
today_bytes: AtomicU64::new(0),
649653
today_key: std::sync::Mutex::new(current_pt_day_key()),
650654
disable_padding: config.disable_padding,
655+
strip_noise_response_headers: config.strip_noise_response_headers,
651656
zstd_enabled: Arc::new(AtomicBool::new(false)),
652657
auto_blacklist_strikes: config.auto_blacklist_strikes.max(1),
653658
auto_blacklist_window: Duration::from_secs(
@@ -2851,7 +2856,7 @@ impl DomainFronter {
28512856
status, body_txt
28522857
)));
28532858
}
2854-
return parse_relay_json(&resp_body).map_err(|e| {
2859+
return parse_relay_json(&resp_body, self.strip_noise_response_headers).map_err(|e| {
28552860
if let FronterError::Relay(ref msg) = e {
28562861
if looks_like_quota_error(msg) {
28572862
self.blacklist_script(&script_id, msg);
@@ -2960,7 +2965,7 @@ impl DomainFronter {
29602965
status, body_txt
29612966
)));
29622967
}
2963-
match parse_relay_json(&resp_body) {
2968+
match parse_relay_json(&resp_body, self.strip_noise_response_headers) {
29642969
Ok(bytes) => Ok::<_, FronterError>((bytes, true)),
29652970
Err(e) => {
29662971
if let FronterError::Relay(ref msg) = e {
@@ -5001,8 +5006,27 @@ fn is_h2_fronting_refusal_status(status: u16) -> bool {
50015006
status == 421
50025007
}
50035008

5009+
/// CDN metadata headers that carry no value through a MITM relay.
5010+
/// Stripped when `strip_noise_response_headers = true` (the default).
5011+
/// The browser never reads them through a proxy, and they add 400-700 bytes
5012+
/// of JSON per CDN-backed response for zero benefit.
5013+
static NOISE_RESPONSE_HEADERS: &[&str] = &[
5014+
"report-to",
5015+
"reporting-endpoints",
5016+
"nel",
5017+
"alt-svc",
5018+
"server-timing",
5019+
"origin-trial",
5020+
"cf-ray",
5021+
"cf-cache-status",
5022+
"x-amzn-requestid",
5023+
"x-amzn-trace-id",
5024+
"x-request-id",
5025+
"x-correlation-id",
5026+
];
5027+
50045028
/// Parse the JSON envelope from Apps Script and build a raw HTTP response.
5005-
fn parse_relay_json(body: &[u8]) -> Result<Vec<u8>, FronterError> {
5029+
fn parse_relay_json(body: &[u8], strip_noise: bool) -> Result<Vec<u8>, FronterError> {
50065030
let text = std::str::from_utf8(body)
50075031
.map_err(|_| FronterError::BadResponse("non-utf8 json".into()))?
50085032
.trim();
@@ -5084,6 +5108,9 @@ fn parse_relay_json(body: &[u8]) -> Result<Vec<u8>, FronterError> {
50845108
if SKIP.contains(&lk.as_str()) {
50855109
continue;
50865110
}
5111+
if strip_noise && NOISE_RESPONSE_HEADERS.contains(&lk.as_str()) {
5112+
continue;
5113+
}
50875114
match v {
50885115
Value::Array(arr) => {
50895116
for item in arr {
@@ -5905,7 +5932,7 @@ mod tests {
59055932
#[test]
59065933
fn parse_relay_basic_json() {
59075934
let body = r#"{"s":200,"h":{"Content-Type":"text/plain"},"b":"SGVsbG8="}"#;
5908-
let raw = parse_relay_json(body.as_bytes()).unwrap();
5935+
let raw = parse_relay_json(body.as_bytes(), true).unwrap();
59095936
let s = String::from_utf8_lossy(&raw);
59105937
assert!(s.starts_with("HTTP/1.1 200 OK\r\n"));
59115938
assert!(s.contains("Content-Type: text/plain\r\n"));
@@ -6804,14 +6831,14 @@ hello";
68046831
#[test]
68056832
fn parse_relay_error_field() {
68066833
let body = r#"{"e":"unauthorized"}"#;
6807-
let err = parse_relay_json(body.as_bytes()).unwrap_err();
6834+
let err = parse_relay_json(body.as_bytes(), true).unwrap_err();
68086835
assert!(matches!(err, FronterError::Relay(_)));
68096836
}
68106837

68116838
#[test]
68126839
fn parse_relay_rejects_invalid_body_base64() {
68136840
let body = r#"{"s":200,"b":"***not-base64***"}"#;
6814-
let err = parse_relay_json(body.as_bytes()).unwrap_err();
6841+
let err = parse_relay_json(body.as_bytes(), true).unwrap_err();
68156842
assert!(matches!(err, FronterError::BadResponse(_)));
68166843
}
68176844

@@ -6870,7 +6897,7 @@ hello";
68706897
#[test]
68716898
fn parse_relay_array_set_cookie() {
68726899
let body = r#"{"s":200,"h":{"Set-Cookie":["a=1","b=2"]},"b":""}"#;
6873-
let raw = parse_relay_json(body.as_bytes()).unwrap();
6900+
let raw = parse_relay_json(body.as_bytes(), true).unwrap();
68746901
let s = String::from_utf8_lossy(&raw);
68756902
assert!(s.contains("Set-Cookie: a=1\r\n"));
68766903
assert!(s.contains("Set-Cookie: b=2\r\n"));
@@ -6938,7 +6965,7 @@ hello";
69386965
// to fail with `key must be a string at line 2`.
69396966
let inner_json = r#"{"s":200,"h":{},"b":""}"#;
69406967
let wrapped = build_goog_script_init_wrapper(inner_json);
6941-
let raw = parse_relay_json(wrapped.as_bytes()).unwrap();
6968+
let raw = parse_relay_json(wrapped.as_bytes(), true).unwrap();
69426969
let s = String::from_utf8_lossy(&raw);
69436970
assert!(s.starts_with("HTTP/1.1 200 "), "got: {}", s);
69446971
}

0 commit comments

Comments
 (0)