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
+359 -160
View File
@@ -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<u16>,
pub body: Option<String>,
pub content_type: Option<String>,
pub headers: Option<std::collections::HashMap<String, String>>,
pub headers: Option<HashMap<String, String>>,
}
#[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<serde_json::Map<String, Value>>,
}
pub enum BackendType {
@@ -119,7 +123,7 @@ pub struct DaemonState {
pub webdriver_backend: Option<super::webdriver::backend::WebDriverBackend>,
pub backend_type: BackendType,
pub ref_map: RefMap,
pub domain_filter: Option<DomainFilter>,
pub domain_filter: Arc<RwLock<Option<DomainFilter>>>,
pub event_tracker: EventTracker,
pub session_name: Option<String>,
pub session_id: String,
@@ -133,10 +137,18 @@ pub struct DaemonState {
pub har_entries: Vec<HarEntry>,
pub confirm_actions: Option<ConfirmActions>,
pub inspect_server: Option<InspectServer>,
pub routes: Vec<RouteEntry>,
pub routes: Arc<RwLock<Vec<RouteEntry>>>,
pub tracked_requests: Vec<TrackedRequest>,
pub request_tracking: bool,
pub active_frame_id: Option<String>,
/// 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<RwLock<>> so the background Fetch handler can read it.
pub origin_headers: Arc<RwLock<HashMap<String, HashMap<String, String>>>>,
/// 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<tokio::task::JoinHandle<()>>,
pub mouse_state: MouseState,
/// Shared slot for stream server to receive CDP client when browser launches.
pub stream_client: Option<Arc<RwLock<Option<Arc<CdpClient>>>>>,
@@ -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<i64>,
Vec<TargetCreatedEvent>,
Vec<String>,
Vec<FetchPausedRequest>,
) {
fn drain_cdp_events(&mut self) -> (Vec<i64>, Vec<TargetCreatedEvent>, Vec<String>) {
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<i64> = Vec::new();
let mut new_targets: Vec<TargetCreatedEvent> = Vec::new();
let mut destroyed_targets: Vec<String> = Vec::new();
let mut fetch_paused: Vec<FetchPausedRequest> = 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<Value, St
state.reset_input_state();
state.browser = Some(BrowserManager::connect_cdp(url).await?);
state.subscribe_to_browser_events();
state.start_fetch_handler();
state.update_stream_client().await;
return Ok(json!({ "launched": true }));
}
@@ -1107,6 +1168,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
state.reset_input_state();
state.browser = Some(BrowserManager::connect_cdp(&port.to_string()).await?);
state.subscribe_to_browser_events();
state.start_fetch_handler();
state.update_stream_client().await;
return Ok(json!({ "launched": true }));
}
@@ -1115,6 +1177,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
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;
return Ok(json!({ "launched": true }));
}
@@ -1134,6 +1197,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
state.reset_input_state();
state.browser = Some(mgr);
state.subscribe_to_browser_events();
state.start_fetch_handler();
state.update_stream_client().await;
return Ok(json!({ "launched": true, "provider": provider }));
}
@@ -1215,24 +1279,29 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
.and_then(|v| v.as_str())
.map(String::from)
{
state.domain_filter = Some(DomainFilter::new(domains));
let mut df = state.domain_filter.write().await;
*df = Some(DomainFilter::new(domains));
}
state.reset_input_state();
state.browser = Some(BrowserManager::launch(options, engine.as_deref()).await?);
state.subscribe_to_browser_events();
state.start_fetch_handler();
state.update_stream_client().await;
if let Some(ref filter) = state.domain_filter {
if let Some(ref mgr) = state.browser {
if let Ok(session_id) = mgr.active_session_id() {
let _ = network::install_domain_filter(
&mgr.client,
session_id,
&filter.allowed_domains,
)
.await;
network::sanitize_existing_pages(&mgr.client, &mgr.pages_list(), filter).await;
{
let df = state.domain_filter.read().await;
if let Some(ref filter) = *df {
if let Some(ref mgr) = state.browser {
if let Ok(session_id) = mgr.active_session_id() {
let _ = network::install_domain_filter(
&mgr.client,
session_id,
&filter.allowed_domains,
)
.await;
network::sanitize_existing_pages(&mgr.client, &mgr.pages_list(), filter).await;
}
}
}
}
@@ -1329,8 +1398,11 @@ async fn handle_navigate(cmd: &Value, state: &mut DaemonState) -> Result<Value,
.and_then(|v| v.as_str())
.ok_or("Missing 'url' parameter")?;
if let Some(ref filter) = state.domain_filter {
filter.check_url(url)?;
{
let df = state.domain_filter.read().await;
if let Some(ref filter) = *df {
filter.check_url(url)?;
}
}
// WebDriver backend path
@@ -1352,31 +1424,49 @@ async fn handle_navigate(cmd: &Value, state: &mut DaemonState) -> Result<Value,
.map(WaitUntil::from_str)
.unwrap_or(WaitUntil::Load);
// If --headers was passed, store them keyed by origin and enable Fetch
// interception. The background fetch_handler_task (started on launch)
// injects them into matching requests in real-time.
let scoped_headers = cmd
.get("headers")
.and_then(|v| v.as_object())
.filter(|m| !m.is_empty());
if let Some(headers_map) = scoped_headers {
let session_id = mgr.active_session_id()?.to_string();
let headers: std::collections::HashMap<String, String> = 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<String, String> = 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<String, String> = 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<Value, String> {
@@ -1506,6 +1596,15 @@ async fn handle_close(state: &mut DaemonState) -> Result<Value, String> {
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<Value, Strin
let headers_value = cmd.get("headers").ok_or("Missing 'headers' parameter")?;
let headers: std::collections::HashMap<String, String> = headers_value
let headers: HashMap<String, String> = headers_value
.as_object()
.map(|m| {
m.iter()
@@ -4978,25 +5077,25 @@ fn browser_metadata_from_version(version: &Value) -> Option<Value> {
}
// ---------------------------------------------------------------------------
// 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<String, HashMap<String, String>>,
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<Value> = 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<Value> {
let routes = state.routes.read().await;
let mut patterns: Vec<Value> = 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<Value, String> {
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<Value, Str
})
});
state.routes.push(RouteEntry {
url_pattern: url_pattern.clone(),
response,
abort,
});
// Re-enable Fetch with all route patterns combined.
// When domain filtering is active, include a wildcard so all requests
// continue to be intercepted for domain checks.
let mut patterns: Vec<Value> = 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<Value, S
let url = cmd.get("url").and_then(|v| v.as_str());
match url {
Some(pattern) => {
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<Value> = 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<Val
format!("{}:{}", username, password),
);
let mut headers = std::collections::HashMap::new();
let mut headers = HashMap::new();
headers.insert("Authorization".to_string(), format!("Basic {}", encoded));
network::set_extra_headers(&mgr.client, &session_id, &headers).await?;
@@ -5987,11 +6108,11 @@ mod tests {
assert_eq!(resp["error"], "Something went wrong");
}
#[test]
fn test_daemon_state_new() {
#[tokio::test]
async fn test_daemon_state_new() {
let state = DaemonState::new();
assert!(state.browser.is_none());
assert!(state.domain_filter.is_none());
assert!(state.domain_filter.read().await.is_none());
assert_eq!(state.session_id, "default");
assert!(!state.tracing_state.active);
assert!(!state.recording_state.active);
@@ -6389,7 +6510,10 @@ mod tests {
#[tokio::test]
async fn test_navigate_without_browser() {
let mut state = DaemonState::new();
state.domain_filter = Some(DomainFilter::new("example.com"));
{
let mut df = state.domain_filter.write().await;
*df = Some(DomainFilter::new("example.com"));
}
let cmd = json!({
"action": "navigate",
"url": "https://blocked.com",
@@ -6457,4 +6581,79 @@ mod tests {
assert_eq!(result["success"], true);
assert!(result["data"]["files"].is_array());
}
#[tokio::test]
async fn test_build_fetch_patterns_empty_state() {
let state = DaemonState::new();
let patterns = build_fetch_patterns(&state).await;
assert!(
patterns.is_empty(),
"No routes/filters/headers → no patterns"
);
}
#[tokio::test]
async fn test_build_fetch_patterns_with_routes() {
let state = DaemonState::new();
{
let mut routes = state.routes.write().await;
routes.push(super::RouteEntry {
url_pattern: "https://example.com/*".to_string(),
response: None,
abort: true,
});
}
let patterns = build_fetch_patterns(&state).await;
assert_eq!(patterns.len(), 1);
assert_eq!(patterns[0]["urlPattern"], "https://example.com/*");
}
#[tokio::test]
async fn test_build_fetch_patterns_adds_wildcard_for_domain_filter() {
let state = DaemonState::new();
{
let mut df = state.domain_filter.write().await;
*df = Some(super::super::network::DomainFilter::new("example.com"));
}
let patterns = build_fetch_patterns(&state).await;
assert_eq!(patterns.len(), 1);
assert_eq!(patterns[0]["urlPattern"], "*");
}
#[tokio::test]
async fn test_build_fetch_patterns_adds_wildcard_for_origin_headers() {
let state = DaemonState::new();
{
let mut oh = state.origin_headers.write().await;
let mut headers = HashMap::new();
headers.insert("Authorization".to_string(), "Bearer xxx".to_string());
oh.insert("http://example.com".to_string(), headers);
}
let patterns = build_fetch_patterns(&state).await;
assert_eq!(patterns.len(), 1);
assert_eq!(patterns[0]["urlPattern"], "*");
}
#[tokio::test]
async fn test_build_fetch_patterns_no_duplicate_wildcard() {
let state = DaemonState::new();
{
let mut routes = state.routes.write().await;
routes.push(super::RouteEntry {
url_pattern: "*".to_string(),
response: None,
abort: false,
});
}
{
let mut df = state.domain_filter.write().await;
*df = Some(super::super::network::DomainFilter::new("example.com"));
}
let patterns = build_fetch_patterns(&state).await;
assert_eq!(
patterns.len(),
1,
"Should not add a second wildcard when routes already contain one"
);
}
}
+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);
}