From fdc09c95f44fa27b119e7527eebd73f1b104c9bc Mon Sep 17 00:00:00 2001 From: Myk Melez Date: Wed, 18 Mar 2026 14:52:21 -0700 Subject: [PATCH] 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) * style: apply cargo fmt formatting * revert inaccurate comment change --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: ctate <366502+ctate@users.noreply.github.com> --- cli/src/native/actions.rs | 519 ++++++++++++++++++--------- cli/src/native/e2e_tests.rs | 678 +++++++++++++++++++++++++++++++++++- 2 files changed, 1034 insertions(+), 163 deletions(-) diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index 06b8476..45e30a2 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -1,4 +1,5 @@ use serde_json::{json, Value}; +use std::collections::HashMap; use std::env; use std::io::Write; use std::path::PathBuf; @@ -80,7 +81,7 @@ pub struct RouteResponse { pub status: Option, pub body: Option, pub content_type: Option, - pub headers: Option>, + pub headers: Option>, } #[derive(Clone, serde::Serialize)] @@ -98,6 +99,9 @@ pub struct FetchPausedRequest { pub url: String, pub resource_type: String, pub session_id: String, + /// Original request headers from the Fetch.requestPaused event, needed + /// because Fetch.continueRequest replaces (not merges) headers. + pub request_headers: Option>, } pub enum BackendType { @@ -119,7 +123,7 @@ pub struct DaemonState { pub webdriver_backend: Option, pub backend_type: BackendType, pub ref_map: RefMap, - pub domain_filter: Option, + pub domain_filter: Arc>>, pub event_tracker: EventTracker, pub session_name: Option, pub session_id: String, @@ -133,10 +137,18 @@ pub struct DaemonState { pub har_entries: Vec, pub confirm_actions: Option, pub inspect_server: Option, - pub routes: Vec, + pub routes: Arc>>, pub tracked_requests: Vec, pub request_tracking: bool, pub active_frame_id: Option, + /// Origin-scoped extra HTTP headers set via `--headers` on navigate. + /// Key is the origin (scheme + host + port), value is the headers map. + /// Wrapped in Arc> so the background Fetch handler can read it. + pub origin_headers: Arc>>>, + /// Background task that processes Fetch.requestPaused events in real-time, + /// handling domain filtering, route interception, and origin-scoped headers + /// without deadlocking navigation/evaluate. + fetch_handler_task: Option>, pub mouse_state: MouseState, /// Shared slot for stream server to receive CDP client when browser launches. pub stream_client: Option>>>>, @@ -153,10 +165,12 @@ impl DaemonState { webdriver_backend: None, backend_type: BackendType::Cdp, ref_map: RefMap::new(), - domain_filter: env::var("AGENT_BROWSER_ALLOWED_DOMAINS") - .ok() - .filter(|s| !s.is_empty()) - .map(|s| DomainFilter::new(&s)), + domain_filter: Arc::new(RwLock::new( + env::var("AGENT_BROWSER_ALLOWED_DOMAINS") + .ok() + .filter(|s| !s.is_empty()) + .map(|s| DomainFilter::new(&s)), + )), event_tracker: EventTracker::new(), session_name: env::var("AGENT_BROWSER_SESSION_NAME").ok(), session_id: env::var("AGENT_BROWSER_SESSION").unwrap_or_else(|_| "default".to_string()), @@ -170,10 +184,12 @@ impl DaemonState { har_entries: Vec::new(), confirm_actions: ConfirmActions::from_env(), inspect_server: None, - routes: Vec::new(), + routes: Arc::new(RwLock::new(Vec::new())), tracked_requests: Vec::new(), request_tracking: false, active_frame_id: None, + origin_headers: Arc::new(RwLock::new(HashMap::new())), + fetch_handler_task: None, mouse_state: MouseState::default(), stream_client: None, stream_server: None, @@ -202,6 +218,78 @@ impl DaemonState { } } + /// Start the background task that processes all Fetch.requestPaused events + /// in real-time (domain filtering, route interception, origin-scoped headers). + /// Must be called after the browser is set and events are subscribed. + fn start_fetch_handler(&mut self) { + // Abort any existing handler. + if let Some(task) = self.fetch_handler_task.take() { + task.abort(); + } + + let Some(ref browser) = self.browser else { + return; + }; + + let client = browser.client.clone(); + let mut rx = browser.client.subscribe(); + let domain_filter = self.domain_filter.clone(); + let routes = self.routes.clone(); + let origin_headers = self.origin_headers.clone(); + + self.fetch_handler_task = Some(tokio::spawn(async move { + loop { + match rx.recv().await { + Ok(event) if event.method == "Fetch.requestPaused" => { + let request_id = event + .params + .get("requestId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let request_url = event + .params + .get("request") + .and_then(|r| r.get("url")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let resource_type = event + .params + .get("resourceType") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let request_headers = event + .params + .get("request") + .and_then(|r| r.get("headers")) + .and_then(|h| h.as_object()) + .cloned(); + let sid = event.session_id.clone().unwrap_or_default(); + + let paused = FetchPausedRequest { + request_id, + url: request_url, + resource_type, + session_id: sid, + request_headers, + }; + + let df = domain_filter.read().await; + let rt = routes.read().await; + let oh = origin_headers.read().await; + + resolve_fetch_paused(&client, df.as_ref(), &rt, &oh, &paused).await; + } + Ok(_) => continue, + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(_) => break, + } + } + })); + } + /// Update the stream server's CDP client slot when browser is set or cleared. pub async fn update_stream_client(&self) { if let Some(ref slot) = self.stream_client { @@ -250,23 +338,15 @@ impl DaemonState { recording::stop_recording_task(&mut self.recording_state).await } - fn drain_cdp_events( - &mut self, - ) -> ( - Vec, - Vec, - Vec, - Vec, - ) { + fn drain_cdp_events(&mut self) -> (Vec, Vec, Vec) { let rx = match self.event_rx.as_mut() { Some(rx) => rx, - None => return (Vec::new(), Vec::new(), Vec::new(), Vec::new()), + None => return (Vec::new(), Vec::new(), Vec::new()), }; let mut pending_acks: Vec = Vec::new(); let mut new_targets: Vec = Vec::new(); let mut destroyed_targets: Vec = Vec::new(); - let mut fetch_paused: Vec = Vec::new(); loop { match rx.try_recv() { @@ -525,35 +605,8 @@ impl DaemonState { } } } - "Fetch.requestPaused" => { - let request_id = event - .params - .get("requestId") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let request_url = event - .params - .get("request") - .and_then(|r| r.get("url")) - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let resource_type = event - .params - .get("resourceType") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let sid = event.session_id.clone().unwrap_or_default(); - - fetch_paused.push(FetchPausedRequest { - request_id, - url: request_url, - resource_type, - session_id: sid, - }); - } + // Fetch.requestPaused is handled by the background + // fetch_handler_task — no need to collect here. _ => {} } } @@ -566,7 +619,17 @@ impl DaemonState { } } - (pending_acks, new_targets, destroyed_targets, fetch_paused) + (pending_acks, new_targets, destroyed_targets) + } +} + +impl Drop for DaemonState { + fn drop(&mut self) { + // The background fetch handler sits in rx.recv().await indefinitely. + // Without aborting it, the tokio runtime won't shut down (tests hang). + if let Some(task) = self.fetch_handler_task.take() { + task.abort(); + } } } @@ -578,8 +641,8 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value { .unwrap_or("") .to_string(); - // Drain pending CDP events (console, errors, screencast frames, target lifecycle, fetch) - let (pending_acks, new_targets, destroyed_targets, fetch_paused) = state.drain_cdp_events(); + // Drain pending CDP events (console, errors, screencast frames, target lifecycle) + let (pending_acks, new_targets, destroyed_targets) = state.drain_cdp_events(); if !pending_acks.is_empty() { if let Some(ref browser) = state.browser { if let Ok(session_id) = browser.active_session_id() { @@ -614,7 +677,8 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value { let _ = mgr.enable_domains_pub(&attach.session_id).await; // Install domain filter on new pages - if let Some(ref filter) = state.domain_filter { + let df = state.domain_filter.read().await; + if let Some(ref filter) = *df { let _ = network::install_domain_filter( &mgr.client, &attach.session_id, @@ -634,14 +698,6 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value { } } - // Handle Fetch.requestPaused events (route interception + domain filter) - for paused in &fetch_paused { - if let Some(ref browser) = state.browser { - resolve_fetch_paused(browser, state.domain_filter.as_ref(), &state.routes, paused) - .await; - } - } - // Hot-reload and check action policy if let Some(ref mut policy) = state.policy { let _ = policy.reload(); @@ -937,6 +993,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> { state.reset_input_state(); state.browser = Some(mgr); state.subscribe_to_browser_events(); + state.start_fetch_handler(); state.update_stream_client().await; try_auto_restore_state(state).await; return Ok(()); @@ -946,6 +1003,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> { state.reset_input_state(); state.browser = Some(connect_auto_with_fresh_tab().await?); state.subscribe_to_browser_events(); + state.start_fetch_handler(); state.update_stream_client().await; try_auto_restore_state(state).await; return Ok(()); @@ -955,6 +1013,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> { state.reset_input_state(); state.browser = Some(mgr); state.subscribe_to_browser_events(); + state.start_fetch_handler(); state.update_stream_client().await; try_auto_restore_state(state).await; Ok(()) @@ -1000,7 +1059,7 @@ fn launch_options_from_env() -> LaunchOptions { } } -fn daemon_state_from_env(state: &mut DaemonState) { +async fn daemon_state_from_env(state: &mut DaemonState) { if let Ok(name) = env::var("AGENT_BROWSER_SESSION_NAME") { if !name.is_empty() { state.session_name = Some(name); @@ -1008,7 +1067,8 @@ fn daemon_state_from_env(state: &mut DaemonState) { } if let Ok(domains) = env::var("AGENT_BROWSER_ALLOWED_DOMAINS") { if !domains.is_empty() { - state.domain_filter = Some(DomainFilter::new(&domains)); + let mut df = state.domain_filter.write().await; + *df = Some(DomainFilter::new(&domains)); } } if state.policy.is_none() { @@ -1099,6 +1159,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result Result Result Result Result Result Result = headers_map - .iter() - .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) - .collect(); - network::set_extra_headers(&mgr.client, &session_id, &headers).await?; - } + if let Some(origin) = url::Url::parse(url) + .ok() + .map(|u| u.origin().ascii_serialization()) + { + let headers: HashMap = headers_map + .iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect(); - state.ref_map.clear(); - let result = mgr.navigate(url, wait_until).await; + let first_origin_header = { + let mut map = state.origin_headers.write().await; + let first = map.is_empty(); + map.insert(origin, headers); + first + }; - if scoped_headers.is_some() { - if let Ok(session_id) = mgr.active_session_id() { - let empty: std::collections::HashMap = std::collections::HashMap::new(); - let _ = network::set_extra_headers(&mgr.client, session_id, &empty).await; + // Enable Fetch interception the first time --headers is used. + // Fetch.enable is idempotent — safe even if domain filter or + // routes already enabled it. Wildcard ensures we see all requests. + if first_origin_header { + let session_id = mgr.active_session_id()?.to_string(); + mgr.client + .send_command( + "Fetch.enable", + Some(json!({ "patterns": [{ "urlPattern": "*" }] })), + Some(&session_id), + ) + .await?; + } } } - result + state.ref_map.clear(); + mgr.navigate(url, wait_until).await } async fn handle_url(state: &DaemonState) -> Result { @@ -1506,6 +1596,15 @@ async fn handle_close(state: &mut DaemonState) -> Result { state.reset_input_state(); state.update_stream_client().await; + // Stop background Fetch handler + if let Some(task) = state.fetch_handler_task.take() { + task.abort(); + } + { + let mut map = state.origin_headers.write().await; + map.clear(); + } + // Close WebDriver sessions if let Some(ref mut wb) = state.webdriver_backend { let _ = wb.close().await; @@ -2341,7 +2440,7 @@ async fn handle_headers(cmd: &Value, state: &DaemonState) -> Result = headers_value + let headers: HashMap = headers_value .as_object() .map(|m| { m.iter() @@ -4978,25 +5077,25 @@ fn browser_metadata_from_version(version: &Value) -> Option { } // --------------------------------------------------------------------------- -// Fetch interception resolver (routes + domain filter) +// Fetch interception resolver (domain filter + routes + origin headers) // --------------------------------------------------------------------------- async fn resolve_fetch_paused( - browser: &BrowserManager, + client: &CdpClient, domain_filter: Option<&DomainFilter>, routes: &[RouteEntry], + origin_headers: &HashMap>, paused: &FetchPausedRequest, ) { let session_id = &paused.session_id; - // Domain filter check (takes priority over routes) + // Domain filter check (takes priority over routes and origin headers) if let Some(filter) = domain_filter { if let Ok(parsed) = url::Url::parse(&paused.url) { let scheme = parsed.scheme(); if scheme != "http" && scheme != "https" { if paused.resource_type.eq_ignore_ascii_case("document") { - let _ = browser - .client + let _ = client .send_command( "Fetch.failRequest", Some(json!({ @@ -5007,8 +5106,7 @@ async fn resolve_fetch_paused( ) .await; } else { - let _ = browser - .client + let _ = client .send_command( "Fetch.continueRequest", Some(json!({ "requestId": paused.request_id })), @@ -5030,8 +5128,7 @@ async fn resolve_fetch_paused( &base64::engine::general_purpose::STANDARD, error_body.as_bytes(), ); - let _ = browser - .client + let _ = client .send_command( "Fetch.fulfillRequest", Some(json!({ @@ -5046,8 +5143,7 @@ async fn resolve_fetch_paused( ) .await; } else { - let _ = browser - .client + let _ = client .send_command( "Fetch.failRequest", Some(json!({ @@ -5081,8 +5177,7 @@ async fn resolve_fetch_paused( if matches { if route.abort { - let _ = browser - .client + let _ = client .send_command( "Fetch.failRequest", Some(json!({ @@ -5112,8 +5207,7 @@ async fn resolve_fetch_paused( } } - let _ = browser - .client + let _ = client .send_command( "Fetch.fulfillRequest", Some(json!({ @@ -5130,21 +5224,68 @@ async fn resolve_fetch_paused( } } - // No matching route -- continue the request - let _ = browser - .client - .send_command( - "Fetch.continueRequest", - Some(json!({ "requestId": paused.request_id })), - Some(session_id), - ) - .await; + // No matching route — continue, injecting origin-scoped headers if applicable. + let extra = url::Url::parse(&paused.url) + .ok() + .map(|u| u.origin().ascii_serialization()) + .and_then(|o| origin_headers.get(&o)); + + if let Some(extra_headers) = extra { + // Merge original request headers with extra headers. + // Fetch.continueRequest replaces (not merges), so include originals. + let mut combined: Vec = Vec::new(); + if let Some(ref orig) = paused.request_headers { + for (k, v) in orig { + if !extra_headers.keys().any(|ek| ek.eq_ignore_ascii_case(k)) { + if let Some(s) = v.as_str() { + combined.push(json!({ "name": k, "value": s })); + } + } + } + } + for (k, v) in extra_headers { + combined.push(json!({ "name": k, "value": v })); + } + let _ = client + .send_command( + "Fetch.continueRequest", + Some(json!({ "requestId": paused.request_id, "headers": combined })), + Some(session_id), + ) + .await; + } else { + let _ = client + .send_command( + "Fetch.continueRequest", + Some(json!({ "requestId": paused.request_id })), + Some(session_id), + ) + .await; + } } // --------------------------------------------------------------------------- // Route handlers // --------------------------------------------------------------------------- +/// Build the Fetch.enable patterns list from current routes, domain filter, +/// and origin headers state. When domain filtering or origin-scoped headers +/// are active a wildcard pattern is included so all requests are intercepted. +async fn build_fetch_patterns(state: &DaemonState) -> Vec { + let routes = state.routes.read().await; + let mut patterns: Vec = routes + .iter() + .map(|r| json!({ "urlPattern": r.url_pattern })) + .collect(); + let has_domain_filter = state.domain_filter.read().await.is_some(); + let has_origin_headers = !state.origin_headers.read().await.is_empty(); + if (has_domain_filter || has_origin_headers) && !patterns.iter().any(|p| p["urlPattern"] == "*") + { + patterns.push(json!({ "urlPattern": "*" })); + } + patterns +} + async fn handle_route(cmd: &Value, state: &mut DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); @@ -5176,24 +5317,16 @@ async fn handle_route(cmd: &Value, state: &mut DaemonState) -> Result = state - .routes - .iter() - .map(|r| json!({ "urlPattern": r.url_pattern })) - .collect(); - if state.domain_filter.is_some() && !patterns.iter().any(|p| p["urlPattern"] == "*") { - patterns.push(json!({ "urlPattern": "*" })); + { + let mut routes = state.routes.write().await; + routes.push(RouteEntry { + url_pattern: url_pattern.clone(), + response, + abort, + }); } + let patterns = build_fetch_patterns(state).await; mgr.client .send_command( "Fetch.enable", @@ -5211,36 +5344,24 @@ async fn handle_unroute(cmd: &Value, state: &mut DaemonState) -> Result { - state.routes.retain(|r| r.url_pattern != pattern); - } - None => { - state.routes.clear(); + { + let mut routes = state.routes.write().await; + match url { + Some(pattern) => { + routes.retain(|r| r.url_pattern != pattern); + } + None => { + routes.clear(); + } } } - if state.routes.is_empty() { - if state.domain_filter.is_some() { - // Domain filtering still needs Fetch interception; reset to wildcard - mgr.client - .send_command( - "Fetch.enable", - Some(json!({ "patterns": [{ "urlPattern": "*" }] })), - Some(&session_id), - ) - .await?; - } else { - mgr.client - .send_command("Fetch.disable", None, Some(&session_id)) - .await?; - } + let patterns = build_fetch_patterns(state).await; + if patterns.is_empty() { + mgr.client + .send_command("Fetch.disable", None, Some(&session_id)) + .await?; } else { - let patterns: Vec = state - .routes - .iter() - .map(|r| json!({ "urlPattern": r.url_pattern })) - .collect(); mgr.client .send_command( "Fetch.enable", @@ -5303,7 +5424,7 @@ async fn handle_http_credentials(cmd: &Value, state: &DaemonState) -> Result '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); +}