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:
co-authored by
Claude Opus 4.6
ctate
parent
486e1b341f
commit
fdc09c95f4
+359
-160
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user