fix: restore origin-scoped --headers persistence across commands (#894)

* fix: restore origin-scoped --headers persistence across commands

In the v0.20 Rust rewrite, headers passed via --headers on open were
only applied to that single navigation via Network.setExtraHTTPHeaders,
which did not persist them for subsequent commands. In v0.19
(Playwright-based), these headers persisted for all subsequent
same-origin requests.

This restores the v0.19 behavior using CDP Fetch interception:

- A background task processes Fetch.requestPaused events in real-time,
  injecting origin-scoped headers into matching requests and continuing
  non-matching requests unmodified. This avoids the deadlock that occurs
  when Fetch interception pauses requests during Page.navigate or
  Runtime.evaluate (which block waiting for completion).

- The same background task also handles domain filtering and route
  interception, replacing the previous drain-between-commands approach
  that couldn't process events during navigation or script evaluation.

Fixes:
- --headers persist for same-origin navigations without re-passing flag
- --headers persist for in-page fetch/XHR to the same origin
- --headers do not leak to cross-origin navigations or sub-resources
- `set headers` (global) is unaffected and stacks with --headers
- Domain filter Fetch interception no longer deadlocks during navigation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: apply cargo fmt formatting

* revert inaccurate comment change

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
This commit is contained in:
Myk Melez
2026-03-18 16:52:21 -05:00
committed by GitHub
co-authored by Claude Opus 4.6 ctate
parent 486e1b341f
commit fdc09c95f4
2 changed files with 1034 additions and 163 deletions
+675 -3
View File
@@ -9,6 +9,7 @@
use base64::{engine::general_purpose::STANDARD, Engine};
use serde_json::{json, Value};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use super::actions::{execute_command, DaemonState};
@@ -1483,6 +1484,13 @@ async fn e2e_state_management() {
async fn e2e_domain_filter() {
let mut state = DaemonState::new();
// Set domain filter BEFORE launch so Fetch.enable is called during
// launch and the background fetch handler intercepts from the start.
{
let mut df = state.domain_filter.write().await;
*df = Some(super::network::DomainFilter::new("example.com"));
}
let resp = execute_command(
&json!({ "id": "1", "action": "launch", "headless": true }),
&mut state,
@@ -1490,9 +1498,6 @@ async fn e2e_domain_filter() {
.await;
assert_success(&resp);
// Set domain filter after launch to avoid Fetch.enable deadlock in tests.
state.domain_filter = Some(super::network::DomainFilter::new("example.com"));
// Allowed domain
let resp = execute_command(
&json!({ "id": "2", "action": "navigate", "url": "https://example.com" }),
@@ -1515,6 +1520,34 @@ async fn e2e_domain_filter() {
error
);
// Verify that in-page fetch to a blocked domain is also blocked by
// the Fetch interception layer (not just the navigate-level check).
// First navigate to the allowed domain.
let resp = execute_command(
&json!({ "id": "4", "action": "navigate", "url": "https://example.com" }),
&mut state,
)
.await;
assert_success(&resp);
// Attempt a cross-origin fetch to a blocked domain from the page.
let resp = execute_command(
&json!({
"id": "5", "action": "evaluate",
"script": "fetch('https://blocked.com/data').then(() => 'ok').catch(e => 'blocked:' + e.message)",
"await": true,
}),
&mut state,
)
.await;
assert_success(&resp);
let result = get_data(&resp)["result"].as_str().unwrap_or("");
assert!(
result.starts_with("blocked:"),
"Fetch to blocked domain should fail, got: {}",
result,
);
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
assert_success(&resp);
}
@@ -2529,3 +2562,642 @@ async fn e2e_snapshot_inline_text_box_filtered() {
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
assert_success(&resp);
}
// ---------------------------------------------------------------------------
// Helper: tiny HTTP server that echoes request headers as JSON
// ---------------------------------------------------------------------------
/// Starts a TCP listener on localhost:0 and spawns a task that accepts
/// connections, reads the HTTP request, and responds with a JSON body
/// containing all received request headers. Returns the server's base URL.
async fn start_echo_server() -> (String, tokio::task::JoinHandle<()>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let base_url = format!("http://127.0.0.1:{}", port);
let handle = tokio::spawn(async move {
// Serve up to 20 requests then exit (enough for all tests).
for _ in 0..20 {
let Ok((mut stream, _)) = listener.accept().await else {
break;
};
tokio::spawn(async move {
let mut buf = vec![0u8; 8192];
let n = stream.read(&mut buf).await.unwrap_or(0);
let request = String::from_utf8_lossy(&buf[..n]);
// Parse headers from the HTTP request.
let mut headers = serde_json::Map::new();
for line in request.lines().skip(1) {
if line.is_empty() {
break;
}
if let Some((key, value)) = line.split_once(": ") {
headers.insert(key.to_string(), Value::String(value.to_string()));
}
}
let body = serde_json::to_string(&json!({ "headers": headers })).unwrap();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
Access-Control-Allow-Origin: *\r\nContent-Length: {}\r\n\
Connection: close\r\n\r\n{}",
body.len(),
body,
);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
});
}
});
(base_url, handle)
}
// ---------------------------------------------------------------------------
// Origin-scoped --headers tests
// ---------------------------------------------------------------------------
/// Headers passed via --headers on open persist for subsequent same-origin
/// navigations (the core regression from the Rust rewrite).
#[tokio::test]
#[ignore]
async fn e2e_headers_persist_same_origin_navigation() {
let (base_url, _server) = start_echo_server().await;
let mut state = DaemonState::new();
let resp = execute_command(
&json!({ "id": "1", "action": "launch", "headless": true }),
&mut state,
)
.await;
assert_success(&resp);
// Navigate with --headers.
let resp = execute_command(
&json!({
"id": "2", "action": "navigate",
"url": format!("{}/first", base_url),
"headers": { "X-Test": "scoped" },
}),
&mut state,
)
.await;
assert_success(&resp);
// Navigate to the same origin WITHOUT --headers.
let resp = execute_command(
&json!({
"id": "3", "action": "navigate",
"url": format!("{}/second", base_url),
}),
&mut state,
)
.await;
assert_success(&resp);
// The page body is the echo JSON. Read it via evaluate.
let resp = execute_command(
&json!({
"id": "4", "action": "evaluate",
"script": "JSON.parse(document.body.innerText)",
}),
&mut state,
)
.await;
assert_success(&resp);
let result = &get_data(&resp)["result"];
assert_eq!(
result["headers"]["X-Test"], "scoped",
"X-Test header should persist on same-origin navigation without --headers"
);
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
assert_success(&resp);
}
/// Headers passed via --headers on open persist for in-page fetch/XHR to
/// the same origin.
#[tokio::test]
#[ignore]
async fn e2e_headers_persist_same_origin_fetch() {
let (base_url, _server) = start_echo_server().await;
let mut state = DaemonState::new();
let resp = execute_command(
&json!({ "id": "1", "action": "launch", "headless": true }),
&mut state,
)
.await;
assert_success(&resp);
// Navigate with --headers.
let resp = execute_command(
&json!({
"id": "2", "action": "navigate",
"url": format!("{}/page", base_url),
"headers": { "X-Test": "fetched" },
}),
&mut state,
)
.await;
assert_success(&resp);
// In-page fetch to the same origin (relative URL).
let resp = execute_command(
&json!({
"id": "3", "action": "evaluate",
"script": "fetch('/echo').then(r => r.json())",
"await": true,
}),
&mut state,
)
.await;
assert_success(&resp);
let result = &get_data(&resp)["result"];
assert_eq!(
result["headers"]["X-Test"], "fetched",
"X-Test header should be present on in-page fetch to same origin"
);
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
assert_success(&resp);
}
/// Headers set via --headers do NOT leak to a different origin.
#[tokio::test]
#[ignore]
async fn e2e_headers_do_not_leak_cross_origin() {
let (server_a, _ha) = start_echo_server().await;
let (server_b, _hb) = start_echo_server().await;
let mut state = DaemonState::new();
let resp = execute_command(
&json!({ "id": "1", "action": "launch", "headless": true }),
&mut state,
)
.await;
assert_success(&resp);
// Navigate to server A with --headers.
let resp = execute_command(
&json!({
"id": "2", "action": "navigate",
"url": format!("{}/page", server_a),
"headers": { "X-Secret": "a-only" },
}),
&mut state,
)
.await;
assert_success(&resp);
// Navigate to server B (different origin) without --headers.
let resp = execute_command(
&json!({
"id": "3", "action": "navigate",
"url": format!("{}/page", server_b),
}),
&mut state,
)
.await;
assert_success(&resp);
let resp = execute_command(
&json!({
"id": "4", "action": "evaluate",
"script": "JSON.parse(document.body.innerText)",
}),
&mut state,
)
.await;
assert_success(&resp);
let result = &get_data(&resp)["result"];
assert!(
result["headers"].get("X-Secret").is_none(),
"X-Secret header must NOT leak to a different origin, got: {}",
result["headers"],
);
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
assert_success(&resp);
}
/// In-page fetch to a cross-origin URL must NOT include the origin-scoped
/// headers (sub-resource isolation).
#[tokio::test]
#[ignore]
async fn e2e_headers_do_not_leak_cross_origin_fetch() {
let (server_a, _ha) = start_echo_server().await;
let (server_b, _hb) = start_echo_server().await;
let mut state = DaemonState::new();
let resp = execute_command(
&json!({ "id": "1", "action": "launch", "headless": true }),
&mut state,
)
.await;
assert_success(&resp);
// Navigate to server A with --headers.
let resp = execute_command(
&json!({
"id": "2", "action": "navigate",
"url": format!("{}/page", server_a),
"headers": { "X-Secret": "a-only" },
}),
&mut state,
)
.await;
assert_success(&resp);
// Fetch from the page to server B (cross-origin sub-resource).
let resp = execute_command(
&json!({
"id": "3", "action": "evaluate",
"script": format!("fetch('{}/echo').then(r => r.json())", server_b),
"await": true,
}),
&mut state,
)
.await;
assert_success(&resp);
let result = &get_data(&resp)["result"];
assert!(
result["headers"].get("X-Secret").is_none(),
"X-Secret header must NOT leak to cross-origin fetch, got: {}",
result["headers"],
);
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
assert_success(&resp);
}
/// `set headers` (global headers via the headers action) must not be
/// regressed — they should persist across navigations without being
/// cleared by the origin-scoped header logic.
#[tokio::test]
#[ignore]
async fn e2e_set_headers_not_regressed() {
let (base_url, _server) = start_echo_server().await;
let mut state = DaemonState::new();
let resp = execute_command(
&json!({ "id": "1", "action": "launch", "headless": true }),
&mut state,
)
.await;
assert_success(&resp);
// Set global headers via the `headers` action (not --headers on navigate).
let resp = execute_command(
&json!({
"id": "2", "action": "headers",
"headers": { "X-Global": "everywhere" },
}),
&mut state,
)
.await;
assert_success(&resp);
// Navigate — global headers should be present.
let resp = execute_command(
&json!({
"id": "3", "action": "navigate",
"url": format!("{}/page", base_url),
}),
&mut state,
)
.await;
assert_success(&resp);
let resp = execute_command(
&json!({
"id": "4", "action": "evaluate",
"script": "JSON.parse(document.body.innerText)",
}),
&mut state,
)
.await;
assert_success(&resp);
let result = &get_data(&resp)["result"];
assert_eq!(
result["headers"]["X-Global"], "everywhere",
"Global headers set via `set headers` must persist across navigations"
);
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
assert_success(&resp);
}
/// Multiple origins each get their own independent headers.
#[tokio::test]
#[ignore]
async fn e2e_headers_multiple_origins_independent() {
let (server_a, _ha) = start_echo_server().await;
let (server_b, _hb) = start_echo_server().await;
let mut state = DaemonState::new();
let resp = execute_command(
&json!({ "id": "1", "action": "launch", "headless": true }),
&mut state,
)
.await;
assert_success(&resp);
// Set headers for origin A.
let resp = execute_command(
&json!({
"id": "2", "action": "navigate",
"url": format!("{}/page", server_a),
"headers": { "X-From": "alpha" },
}),
&mut state,
)
.await;
assert_success(&resp);
// Set different headers for origin B.
let resp = execute_command(
&json!({
"id": "3", "action": "navigate",
"url": format!("{}/page", server_b),
"headers": { "X-From": "beta" },
}),
&mut state,
)
.await;
assert_success(&resp);
// Verify B got its own header.
let resp = execute_command(
&json!({ "id": "4", "action": "evaluate", "script": "JSON.parse(document.body.innerText)" }),
&mut state,
)
.await;
assert_success(&resp);
assert_eq!(get_data(&resp)["result"]["headers"]["X-From"], "beta");
// Navigate back to A — should get A's header, not B's.
let resp = execute_command(
&json!({ "id": "5", "action": "navigate", "url": format!("{}/check", server_a) }),
&mut state,
)
.await;
assert_success(&resp);
let resp = execute_command(
&json!({ "id": "6", "action": "evaluate", "script": "JSON.parse(document.body.innerText)" }),
&mut state,
)
.await;
assert_success(&resp);
assert_eq!(get_data(&resp)["result"]["headers"]["X-From"], "alpha");
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
assert_success(&resp);
}
/// Headers persist when navigating away to a different origin and back.
#[tokio::test]
#[ignore]
async fn e2e_headers_persist_after_roundtrip() {
let (server_a, _ha) = start_echo_server().await;
let (server_b, _hb) = start_echo_server().await;
let mut state = DaemonState::new();
let resp = execute_command(
&json!({ "id": "1", "action": "launch", "headless": true }),
&mut state,
)
.await;
assert_success(&resp);
// Set headers for origin A.
let resp = execute_command(
&json!({
"id": "2", "action": "navigate",
"url": format!("{}/page", server_a),
"headers": { "X-Persist": "roundtrip" },
}),
&mut state,
)
.await;
assert_success(&resp);
// Navigate away to B (no headers).
let resp = execute_command(
&json!({ "id": "3", "action": "navigate", "url": format!("{}/page", server_b) }),
&mut state,
)
.await;
assert_success(&resp);
// Navigate back to A without --headers.
let resp = execute_command(
&json!({ "id": "4", "action": "navigate", "url": format!("{}/back", server_a) }),
&mut state,
)
.await;
assert_success(&resp);
let resp = execute_command(
&json!({ "id": "5", "action": "evaluate", "script": "JSON.parse(document.body.innerText)" }),
&mut state,
)
.await;
assert_success(&resp);
assert_eq!(
get_data(&resp)["result"]["headers"]["X-Persist"],
"roundtrip",
"Headers should persist after navigating away and back to the same origin"
);
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
assert_success(&resp);
}
/// Passing --headers a second time to the same origin replaces the previous headers.
#[tokio::test]
#[ignore]
async fn e2e_headers_override_same_origin() {
let (base_url, _server) = start_echo_server().await;
let mut state = DaemonState::new();
let resp = execute_command(
&json!({ "id": "1", "action": "launch", "headless": true }),
&mut state,
)
.await;
assert_success(&resp);
// Set initial headers.
let resp = execute_command(
&json!({
"id": "2", "action": "navigate",
"url": format!("{}/first", base_url),
"headers": { "X-Version": "v1" },
}),
&mut state,
)
.await;
assert_success(&resp);
// Override with new headers.
let resp = execute_command(
&json!({
"id": "3", "action": "navigate",
"url": format!("{}/second", base_url),
"headers": { "X-Version": "v2" },
}),
&mut state,
)
.await;
assert_success(&resp);
let resp = execute_command(
&json!({ "id": "4", "action": "evaluate", "script": "JSON.parse(document.body.innerText)" }),
&mut state,
)
.await;
assert_success(&resp);
assert_eq!(
get_data(&resp)["result"]["headers"]["X-Version"],
"v2",
"Second --headers should replace the first for the same origin"
);
// Subsequent navigation without --headers should use v2.
let resp = execute_command(
&json!({ "id": "5", "action": "navigate", "url": format!("{}/third", base_url) }),
&mut state,
)
.await;
assert_success(&resp);
let resp = execute_command(
&json!({ "id": "6", "action": "evaluate", "script": "JSON.parse(document.body.innerText)" }),
&mut state,
)
.await;
assert_success(&resp);
assert_eq!(get_data(&resp)["result"]["headers"]["X-Version"], "v2");
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
assert_success(&resp);
}
/// `set headers` (global) and `--headers` (origin-scoped) stack together.
#[tokio::test]
#[ignore]
async fn e2e_global_and_scoped_headers_stack() {
let (base_url, _server) = start_echo_server().await;
let mut state = DaemonState::new();
let resp = execute_command(
&json!({ "id": "1", "action": "launch", "headless": true }),
&mut state,
)
.await;
assert_success(&resp);
// Set global headers via `set headers`.
let resp = execute_command(
&json!({
"id": "2", "action": "headers",
"headers": { "X-Global": "everywhere" },
}),
&mut state,
)
.await;
assert_success(&resp);
// Set origin-scoped headers via --headers.
let resp = execute_command(
&json!({
"id": "3", "action": "navigate",
"url": format!("{}/page", base_url),
"headers": { "X-Scoped": "this-origin" },
}),
&mut state,
)
.await;
assert_success(&resp);
let resp = execute_command(
&json!({ "id": "4", "action": "evaluate", "script": "JSON.parse(document.body.innerText)" }),
&mut state,
)
.await;
assert_success(&resp);
let headers = &get_data(&resp)["result"]["headers"];
assert_eq!(
headers["X-Global"], "everywhere",
"Global header should be present alongside scoped header"
);
assert_eq!(
headers["X-Scoped"], "this-origin",
"Scoped header should be present alongside global header"
);
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
assert_success(&resp);
}
/// Origin-scoped headers with different casing than the browser's original
/// request headers must not produce duplicates (HTTP headers are
/// case-insensitive per RFC 7230).
#[tokio::test]
#[ignore]
async fn e2e_headers_case_insensitive_no_duplicates() {
let (base_url, _server) = start_echo_server().await;
let mut state = DaemonState::new();
let resp = execute_command(
&json!({ "id": "1", "action": "launch", "headless": true }),
&mut state,
)
.await;
assert_success(&resp);
// Chrome sends "Accept: ..." by default on navigations. Pass "accept"
// (lowercase) via --headers to verify the merge is case-insensitive
// and doesn't produce a duplicate Accept header.
let resp = execute_command(
&json!({
"id": "2", "action": "navigate",
"url": format!("{}/page", base_url),
"headers": { "accept": "application/test" },
}),
&mut state,
)
.await;
assert_success(&resp);
let resp = execute_command(
&json!({
"id": "3", "action": "evaluate",
"script": "JSON.parse(document.body.innerText)",
}),
&mut state,
)
.await;
assert_success(&resp);
let result = &get_data(&resp)["result"]["headers"];
// The echo server stores headers keyed by name as received on the wire.
// If deduplication works, only our custom "accept" value should appear
// (Chrome's original "Accept: text/html,..." should be suppressed).
let accept_val = result
.get("accept")
.or_else(|| result.get("Accept"))
.and_then(|v| v.as_str())
.unwrap_or("");
assert_eq!(
accept_val, "application/test",
"Case-insensitive merge should replace Chrome's Accept header, got headers: {}",
result,
);
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
assert_success(&resp);
}