fix: handle proxy authentication via CDP Fetch.authRequired (#1000)

* fix: handle proxy authentication via CDP Fetch.authRequired

Chrome's --proxy-server flag does not support credentials embedded in
the URL. When a proxy requires authentication, Chrome receives a 407
from the proxy but has no way to respond with credentials, resulting
in net::ERR_INVALID_AUTH_CREDENTIALS.

Fix by:
1. Parsing credentials from the proxy URL (already done by parse_proxy)
2. Storing them in DaemonState.proxy_credentials
3. Enabling Fetch.enable with handleAuthRequests: true
4. Responding to Fetch.authRequired events with Fetch.continueWithAuth
5. Passing only the server URL (without credentials) to --proxy-server
6. Forwarding credentials to the daemon via dedicated env vars

Also adds fallback to standard proxy env vars (HTTP_PROXY, HTTPS_PROXY,
ALL_PROXY, NO_PROXY) when AGENT_BROWSER_PROXY is not set.

Fixes #990

* refactor: use typed struct for parse_proxy, fix double Fetch.enable and username-only auth

- Replace serde_json::Value return from parse_proxy with a typed ParsedProxy struct
- Fix double Fetch.enable call when both proxy auth and domain filter are active
  (the second call could overwrite handleAuthRequests from the first)
- Allow username-only proxy auth (some proxies don't require a password)
- Handle empty username/password in parse_proxy as None instead of Some("")
- Use install_domain_filter_fetch in auto_launch for consistency
- Update unit tests to use typed struct fields

---------

Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
This commit is contained in:
Chris Tate
2026-03-24 12:52:39 -05:00
committed by GitHub
co-authored by ctate
parent 23a117c5c2
commit cd1f255129
7 changed files with 266 additions and 78 deletions
+141 -26
View File
@@ -195,6 +195,9 @@ pub struct DaemonState {
/// 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>>>>,
/// Proxy authentication credentials (username, password) for handling
/// Fetch.authRequired events from authenticated proxies.
pub proxy_credentials: Arc<RwLock<Option<(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.
@@ -242,6 +245,7 @@ impl DaemonState {
active_frame_id: None,
iframe_sessions: HashMap::new(),
origin_headers: Arc::new(RwLock::new(HashMap::new())),
proxy_credentials: Arc::new(RwLock::new(None)),
fetch_handler_task: None,
mouse_state: MouseState::default(),
pending_dialog: None,
@@ -272,8 +276,9 @@ impl DaemonState {
}
}
/// Start the background task that processes all Fetch.requestPaused events
/// in real-time (domain filtering, route interception, origin-scoped headers).
/// Start the background task that processes Fetch.requestPaused and
/// Fetch.authRequired events in real-time (domain filtering, route
/// interception, origin-scoped headers, proxy authentication).
/// Must be called after the browser is set and events are subscribed.
fn start_fetch_handler(&mut self) {
// Abort any existing handler.
@@ -290,10 +295,50 @@ impl DaemonState {
let domain_filter = self.domain_filter.clone();
let routes = self.routes.clone();
let origin_headers = self.origin_headers.clone();
let proxy_credentials = self.proxy_credentials.clone();
self.fetch_handler_task = Some(tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(event) if event.method == "Fetch.authRequired" => {
let request_id = event
.params
.get("requestId")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let sid = event.session_id.clone().unwrap_or_default();
let creds = proxy_credentials.read().await;
if let Some((ref user, ref pass)) = *creds {
let _ = client
.send_command(
"Fetch.continueWithAuth",
Some(json!({
"requestId": request_id,
"authChallengeResponse": {
"response": "ProvideCredentials",
"username": user,
"password": pass,
}
})),
Some(&sid),
)
.await;
} else {
let _ = client
.send_command(
"Fetch.continueWithAuth",
Some(json!({
"requestId": request_id,
"authChallengeResponse": {
"response": "CancelAuth",
}
})),
Some(&sid),
)
.await;
}
}
Ok(event) if event.method == "Fetch.requestPaused" => {
let request_id = event
.params
@@ -854,10 +899,12 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
// Install domain filter on new pages
let df = state.domain_filter.read().await;
if let Some(ref filter) = *df {
let has_proxy_creds = state.proxy_credentials.read().await.is_some();
let _ = network::install_domain_filter(
&mgr.client,
&attach.session_id,
&filter.allowed_domains,
has_proxy_creds,
)
.await;
}
@@ -1186,6 +1233,16 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
let options = launch_options_from_env();
let engine = env::var("AGENT_BROWSER_ENGINE").ok();
// Store proxy credentials for Fetch.authRequired handling
let has_proxy_auth = options.proxy_username.is_some();
if has_proxy_auth {
let mut creds = state.proxy_credentials.write().await;
*creds = Some((
options.proxy_username.clone().unwrap_or_default(),
options.proxy_password.clone().unwrap_or_default(),
));
}
if let Ok(cdp) = env::var("AGENT_BROWSER_CDP") {
let mgr = BrowserManager::connect_cdp(&cdp).await?;
state.reset_input_state();
@@ -1213,6 +1270,16 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
state.subscribe_to_browser_events();
state.start_fetch_handler();
state.update_stream_client().await;
// Enable Fetch with handleAuthRequests for proxy authentication
if has_proxy_auth {
if let Some(ref mgr) = state.browser {
if let Ok(session_id) = mgr.active_session_id() {
let _ = network::install_domain_filter_fetch(&mgr.client, session_id, true).await;
}
}
}
try_auto_restore_state(state).await;
Ok(())
}
@@ -1234,6 +1301,8 @@ fn launch_options_from_env() -> LaunchOptions {
executable_path: env::var("AGENT_BROWSER_EXECUTABLE_PATH").ok(),
proxy: env::var("AGENT_BROWSER_PROXY").ok(),
proxy_bypass: env::var("AGENT_BROWSER_PROXY_BYPASS").ok(),
proxy_username: env::var("AGENT_BROWSER_PROXY_USERNAME").ok(),
proxy_password: env::var("AGENT_BROWSER_PROXY_PASSWORD").ok(),
profile: env::var("AGENT_BROWSER_PROFILE").ok(),
allow_file_access: env::var("AGENT_BROWSER_ALLOW_FILE_ACCESS")
.map(|v| v == "1" || v == "true")
@@ -1437,6 +1506,18 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
.and_then(|v| v.get("bypass"))
.and_then(|v| v.as_str())
.map(String::from),
proxy_username: cmd
.get("proxy")
.and_then(|v| v.get("username"))
.and_then(|v| v.as_str())
.map(String::from)
.or_else(|| env::var("AGENT_BROWSER_PROXY_USERNAME").ok()),
proxy_password: cmd
.get("proxy")
.and_then(|v| v.get("password"))
.and_then(|v| v.as_str())
.map(String::from)
.or_else(|| env::var("AGENT_BROWSER_PROXY_PASSWORD").ok()),
user_agent: cmd
.get("userAgent")
.and_then(|v| v.as_str())
@@ -1455,6 +1536,16 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
.map(String::from),
};
// Store proxy credentials for Fetch.authRequired handling
let has_proxy_auth = options.proxy_username.is_some();
if has_proxy_auth {
let mut creds = state.proxy_credentials.write().await;
*creds = Some((
options.proxy_username.clone().unwrap_or_default(),
options.proxy_password.clone().unwrap_or_default(),
));
}
if let Some(ref domains) = cmd
.get("allowedDomains")
.and_then(|v| v.as_str())
@@ -1470,18 +1561,34 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
state.start_fetch_handler();
state.update_stream_client().await;
// Enable Fetch interception (domain filtering and/or proxy auth).
// Only call Fetch.enable once to avoid overwriting handleAuthRequests.
{
let df = state.domain_filter.read().await;
if let Some(ref filter) = *df {
let has_domain_filter = df.is_some();
if has_domain_filter || has_proxy_auth {
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;
if let Some(ref filter) = *df {
let _ = network::install_domain_filter(
&mgr.client,
session_id,
&filter.allowed_domains,
has_proxy_auth,
)
.await;
network::sanitize_existing_pages(&mgr.client, &mgr.pages_list(), filter)
.await;
} else {
// No domain filter, but proxy auth needs Fetch.enable
let _ = network::install_domain_filter_fetch(
&mgr.client,
session_id,
has_proxy_auth,
)
.await;
}
}
}
}
@@ -1635,12 +1742,13 @@ async fn handle_navigate(cmd: &Value, state: &mut DaemonState) -> Result<Value,
// routes already enabled it. Wildcard ensures we see all requests.
if first_origin_header {
let session_id = mgr.active_session_id()?.to_string();
let has_proxy_creds = state.proxy_credentials.read().await.is_some();
let mut params = json!({ "patterns": [{ "urlPattern": "*" }] });
if has_proxy_creds {
params["handleAuthRequests"] = json!(true);
}
mgr.client
.send_command(
"Fetch.enable",
Some(json!({ "patterns": [{ "urlPattern": "*" }] })),
Some(&session_id),
)
.send_command("Fetch.enable", Some(params), Some(&session_id))
.await?;
}
}
@@ -5813,13 +5921,26 @@ async fn build_fetch_patterns(state: &DaemonState) -> Vec<Value> {
.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"] == "*")
let has_proxy_creds = state.proxy_credentials.read().await.is_some();
if (has_domain_filter || has_origin_headers || has_proxy_creds)
&& !patterns.iter().any(|p| p["urlPattern"] == "*")
{
patterns.push(json!({ "urlPattern": "*" }));
}
patterns
}
/// Build the full Fetch.enable params object, including `handleAuthRequests`
/// when proxy credentials are configured.
async fn build_fetch_enable_params(state: &DaemonState, patterns: Vec<Value>) -> Value {
let has_proxy_creds = state.proxy_credentials.read().await.is_some();
if has_proxy_creds {
json!({ "patterns": patterns, "handleAuthRequests": true })
} else {
json!({ "patterns": 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();
@@ -5861,12 +5982,9 @@ async fn handle_route(cmd: &Value, state: &mut DaemonState) -> Result<Value, Str
}
let patterns = build_fetch_patterns(state).await;
let params = build_fetch_enable_params(state, patterns).await;
mgr.client
.send_command(
"Fetch.enable",
Some(json!({ "patterns": patterns })),
Some(&session_id),
)
.send_command("Fetch.enable", Some(params), Some(&session_id))
.await?;
Ok(json!({ "routed": url_pattern }))
@@ -5896,12 +6014,9 @@ async fn handle_unroute(cmd: &Value, state: &mut DaemonState) -> Result<Value, S
.send_command("Fetch.disable", None, Some(&session_id))
.await?;
} else {
let params = build_fetch_enable_params(state, patterns).await;
mgr.client
.send_command(
"Fetch.enable",
Some(json!({ "patterns": patterns })),
Some(&session_id),
)
.send_command("Fetch.enable", Some(params), Some(&session_id))
.await?;
}