From 3fe8e6b5b24c11bcaebe4b93bc0692b593e63e5e Mon Sep 17 00:00:00 2001 From: rentianyue-jk Date: Mon, 22 Jun 2026 19:13:09 +0800 Subject: [PATCH] feat(browser): support allowed_private_hosts for browser tools Add `[browser].allowed_private_hosts` so the `browser` and `browser_open` tools can reach explicitly listed private/LAN hosts (or `["*"]` for all), mirroring the existing `http_request` escape hatch. Listed private hosts bypass the SSRF block and the `allowed_domains` allowlist; everything else is unchanged. Default is empty (deny-by-default, fully backward compatible), public hosts still flow through `allowed_domains`, `file://` stays blocked, and `browser_open` remains HTTPS-only. --- crates/zeroclaw-config/fixtures/v1.toml | 1 + crates/zeroclaw-config/src/schema.rs | 9 ++ crates/zeroclaw-runtime/src/tools/mod.rs | 7 +- crates/zeroclaw-tools/src/browser.rs | 128 +++++++++++++++++++++- crates/zeroclaw-tools/src/browser_open.rs | 97 +++++++++++++++- 5 files changed, 237 insertions(+), 5 deletions(-) diff --git a/crates/zeroclaw-config/fixtures/v1.toml b/crates/zeroclaw-config/fixtures/v1.toml index fa52c0253f6..59735ecfe4d 100644 --- a/crates/zeroclaw-config/fixtures/v1.toml +++ b/crates/zeroclaw-config/fixtures/v1.toml @@ -534,6 +534,7 @@ allowed_domains = ["*"] backend = "agent_browser" native_headless = true native_webdriver_url = "http://127.0.0.1:9515" +allowed_private_hosts = [] [browser.computer_use] endpoint = "http://127.0.0.1:8787/v1/actions" diff --git a/crates/zeroclaw-config/src/schema.rs b/crates/zeroclaw-config/src/schema.rs index a5dd79405b1..17e38bdb6f0 100644 --- a/crates/zeroclaw-config/src/schema.rs +++ b/crates/zeroclaw-config/src/schema.rs @@ -6618,6 +6618,13 @@ pub struct BrowserConfig { #[serde(default)] #[nested] pub computer_use: BrowserComputerUseConfig, + /// Private/internal hosts allowed to bypass SSRF protection. + /// Exact and subdomain matches are supported; `["*"]` permits **all** private/local + /// hosts (RFC 1918, loopback, link-local, `.local`). Default: empty (deny). + /// Listed hosts also bypass `allowed_domains` and (for `browser_open`) the + /// HTTPS-only restriction — internal services frequently lack a public TLS cert. + #[serde(default)] + pub allowed_private_hosts: Vec, } fn default_browser_allowed_domains() -> Vec { @@ -6644,6 +6651,7 @@ impl Default for BrowserConfig { native_webdriver_url: default_browser_webdriver_url(), native_chrome_path: None, computer_use: BrowserComputerUseConfig::default(), + allowed_private_hosts: vec![], } } } @@ -22791,6 +22799,7 @@ default_temperature = 0.7 max_coordinate_x: Some(3840), max_coordinate_y: Some(2160), }, + allowed_private_hosts: vec![], }; let toml_str = toml::to_string(&b).unwrap(); let parsed: BrowserConfig = toml::from_str(&toml_str).unwrap(); diff --git a/crates/zeroclaw-runtime/src/tools/mod.rs b/crates/zeroclaw-runtime/src/tools/mod.rs index faeb4e24a1c..a9db179b876 100644 --- a/crates/zeroclaw-runtime/src/tools/mod.rs +++ b/crates/zeroclaw-runtime/src/tools/mod.rs @@ -720,7 +720,11 @@ pub fn all_tools_with_runtime( if browser_config.enabled { // Add legacy browser_open tool for simple URL opening - match BrowserOpenTool::new(security.clone(), browser_config.allowed_domains.clone()) { + match BrowserOpenTool::new_with_private_hosts( + security.clone(), + browser_config.allowed_domains.clone(), + browser_config.allowed_private_hosts.clone(), + ) { Ok(tool) => { tool_arcs.push(Arc::new(tool)); } @@ -753,6 +757,7 @@ pub fn all_tools_with_runtime( max_coordinate_x: browser_config.computer_use.max_coordinate_x, max_coordinate_y: browser_config.computer_use.max_coordinate_y, }, + browser_config.allowed_private_hosts.clone(), ) { Ok(tool) => { tool_arcs.push(Arc::new(RateLimitedTool::new(tool, security.clone()))); diff --git a/crates/zeroclaw-tools/src/browser.rs b/crates/zeroclaw-tools/src/browser.rs index 3c578eec02d..0a02df16905 100644 --- a/crates/zeroclaw-tools/src/browser.rs +++ b/crates/zeroclaw-tools/src/browser.rs @@ -61,6 +61,7 @@ impl Default for ComputerUseConfig { pub struct BrowserTool { security: Arc, allowed_domains: Vec, + allowed_private_hosts: Vec, session_name: Option, backend: String, headed: Option, @@ -216,6 +217,7 @@ impl BrowserTool { "http://127.0.0.1:9515".into(), None, ComputerUseConfig::default(), + Vec::new(), ) } @@ -230,6 +232,7 @@ impl BrowserTool { native_webdriver_url: String, native_chrome_path: Option, computer_use: ComputerUseConfig, + allowed_private_hosts: Vec, ) -> anyhow::Result { Ok(Self { security, @@ -237,6 +240,10 @@ impl BrowserTool { allowed_domains, "browser.allowed_domains", )?, + allowed_private_hosts: domain_guard::normalize_allowed_domains( + allowed_private_hosts, + "browser.allowed_private_hosts", + )?, session_name, backend, headed, @@ -449,7 +456,7 @@ impl BrowserTool { anyhow::bail!("Only http:// and https:// URLs are allowed"); } - if self.allowed_domains.is_empty() { + if self.allowed_domains.is_empty() && self.allowed_private_hosts.is_empty() { anyhow::bail!( "Browser tool enabled but no allowed_domains configured. \ Add [browser].allowed_domains in config.toml" @@ -457,11 +464,18 @@ impl BrowserTool { } let host = extract_host(url)?; + let private_host = domain_guard::is_private_or_local_host(&host); + let private_host_allowed = private_host + && domain_guard::host_matches_allowlist(&host, &self.allowed_private_hosts); - if domain_guard::is_private_or_local_host(&host) { + if private_host && !private_host_allowed { anyhow::bail!("Blocked local/private host: {host}"); } + if private_host_allowed { + return Ok(()); + } + if !domain_guard::host_matches_allowlist(&host, &self.allowed_domains) { anyhow::bail!("Host '{host}' not in browser.allowed_domains"); } @@ -2390,6 +2404,7 @@ mod tests { "http://127.0.0.1:9515".into(), None, ComputerUseConfig::default(), + Vec::new(), ) .unwrap(); let cmd = tool.agent_browser_command(); @@ -2417,6 +2432,7 @@ mod tests { "http://127.0.0.1:9515".into(), None, ComputerUseConfig::default(), + Vec::new(), ) .unwrap(); let cmd = tool.agent_browser_command(); @@ -2444,6 +2460,7 @@ mod tests { "http://127.0.0.1:9515".into(), None, ComputerUseConfig::default(), + Vec::new(), ) .unwrap(); assert_eq!(tool.configured_backend().unwrap(), BrowserBackendKind::Auto); @@ -2462,6 +2479,7 @@ mod tests { "http://127.0.0.1:9515".into(), None, ComputerUseConfig::default(), + Vec::new(), ) .unwrap(); assert_eq!( @@ -2486,6 +2504,7 @@ mod tests { endpoint: "http://computer-use.example.com/v1/actions".into(), ..ComputerUseConfig::default() }, + Vec::new(), ) .unwrap(); @@ -2509,6 +2528,7 @@ mod tests { allow_remote_endpoint: true, ..ComputerUseConfig::default() }, + Vec::new(), ) .unwrap(); @@ -2532,6 +2552,7 @@ mod tests { max_coordinate_y: Some(100), ..ComputerUseConfig::default() }, + Vec::new(), ) .unwrap(); @@ -2741,4 +2762,107 @@ mod tests { assert_eq!(cmd, "agent-browser"); } } + + // ── allowed_private_hosts opt-in tests ────────────────────── + + fn private_host_tool( + allowed_domains: Vec<&str>, + allowed_private_hosts: Vec<&str>, + ) -> BrowserTool { + let security = Arc::new(SecurityPolicy::default()); + BrowserTool::new_with_backend( + security, + allowed_domains.into_iter().map(String::from).collect(), + None, + "agent_browser".into(), + None, + true, + "http://127.0.0.1:9515".into(), + None, + ComputerUseConfig::default(), + allowed_private_hosts + .into_iter() + .map(String::from) + .collect(), + ) + .unwrap() + } + + #[test] + fn wildcard_private_allowlist_permits_localhost() { + let tool = private_host_tool(vec![], vec!["*"]); + assert!(tool.validate_url("http://localhost:8080").is_ok()); + assert!(tool.validate_url("https://localhost:8443").is_ok()); + } + + #[test] + fn wildcard_private_allowlist_permits_rfc1918() { + let tool = private_host_tool(vec![], vec!["*"]); + assert!(tool.validate_url("http://192.168.1.5").is_ok()); + assert!(tool.validate_url("http://10.0.0.1").is_ok()); + assert!(tool.validate_url("http://172.16.0.1").is_ok()); + } + + #[test] + fn wildcard_private_allowlist_does_not_loosen_file_scheme() { + // file:// is always blocked, regardless of allowed_private_hosts. + let tool = private_host_tool(vec!["*"], vec!["*"]); + let err = tool + .validate_url("file:///etc/passwd") + .unwrap_err() + .to_string(); + assert!(err.contains("file://")); + } + + #[test] + fn allowed_private_hosts_entry_permits_listed_host() { + let tool = private_host_tool(vec![], vec!["10.0.0.1"]); + assert!(tool.validate_url("http://10.0.0.1").is_ok()); + } + + #[test] + fn allowed_private_hosts_does_not_permit_unlisted_host() { + let tool = private_host_tool(vec![], vec!["10.0.0.1"]); + let err = tool + .validate_url("http://10.0.0.2") + .unwrap_err() + .to_string(); + assert!(err.contains("local/private")); + } + + #[test] + fn empty_private_allowlist_still_rejects_private() { + let tool = private_host_tool(vec!["*"], vec![]); + let err = tool + .validate_url("https://localhost") + .unwrap_err() + .to_string(); + assert!(err.contains("local/private")); + } + + #[test] + fn wildcard_private_allowlist_satisfies_allowlist_requirement() { + // allowed_domains empty + allowed_private_hosts=["*"] should not surface + // the "no allowed_domains configured" error for private hosts. + let tool = private_host_tool(vec![], vec!["*"]); + assert!(tool.validate_url("http://localhost").is_ok()); + } + + #[test] + fn specific_private_host_alone_satisfies_allowlist_requirement() { + let tool = private_host_tool(vec![], vec!["192.168.1.5"]); + assert!(tool.validate_url("http://192.168.1.5").is_ok()); + } + + #[test] + fn wildcard_private_allowlist_does_not_widen_public_allowlist() { + // Public hosts are still subject to allowed_domains when private hosts + // are wide-open — the bypass is scoped to private/local hosts only. + let tool = private_host_tool(vec!["example.com"], vec!["*"]); + let err = tool + .validate_url("https://other.com") + .unwrap_err() + .to_string(); + assert!(err.contains("allowed_domains")); + } } diff --git a/crates/zeroclaw-tools/src/browser_open.rs b/crates/zeroclaw-tools/src/browser_open.rs index 1dab6b38c75..44182f78d3a 100644 --- a/crates/zeroclaw-tools/src/browser_open.rs +++ b/crates/zeroclaw-tools/src/browser_open.rs @@ -9,12 +9,21 @@ use zeroclaw_config::policy::SecurityPolicy; pub struct BrowserOpenTool { security: Arc, allowed_domains: Vec, + allowed_private_hosts: Vec, } impl BrowserOpenTool { pub fn new( security: Arc, allowed_domains: Vec, + ) -> anyhow::Result { + Self::new_with_private_hosts(security, allowed_domains, Vec::new()) + } + + pub fn new_with_private_hosts( + security: Arc, + allowed_domains: Vec, + allowed_private_hosts: Vec, ) -> anyhow::Result { Ok(Self { security, @@ -22,6 +31,10 @@ impl BrowserOpenTool { allowed_domains, "browser.allowed_domains", )?, + allowed_private_hosts: domain_guard::normalize_allowed_domains( + allowed_private_hosts, + "browser.allowed_private_hosts", + )?, }) } @@ -40,18 +53,25 @@ impl BrowserOpenTool { anyhow::bail!("Only https:// URLs are allowed"); } - if self.allowed_domains.is_empty() { + if self.allowed_domains.is_empty() && self.allowed_private_hosts.is_empty() { anyhow::bail!( "Browser tool is enabled but no allowed_domains are configured. Add [browser].allowed_domains in config.toml" ); } let host = extract_host(url)?; + let private_host = domain_guard::is_private_or_local_host(&host); + let private_host_allowed = private_host + && domain_guard::host_matches_allowlist(&host, &self.allowed_private_hosts); - if domain_guard::is_private_or_local_host(&host) { + if private_host && !private_host_allowed { anyhow::bail!("Blocked local/private host: {host}"); } + if private_host_allowed { + return Ok(url.to_string()); + } + if !domain_guard::host_matches_allowlist(&host, &self.allowed_domains) { anyhow::bail!("Host '{host}' is not in browser.allowed_domains"); } @@ -312,6 +332,25 @@ mod tests { .unwrap() } + fn test_tool_with_private( + allowed_domains: Vec<&str>, + allowed_private_hosts: Vec<&str>, + ) -> BrowserOpenTool { + let security = Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::Supervised, + ..SecurityPolicy::default() + }); + BrowserOpenTool::new_with_private_hosts( + security, + allowed_domains.into_iter().map(String::from).collect(), + allowed_private_hosts + .into_iter() + .map(String::from) + .collect(), + ) + .unwrap() + } + #[test] fn validate_accepts_exact_domain() { let tool = test_tool(vec!["example.com"]); @@ -441,4 +480,58 @@ mod tests { assert!(!result.success); assert!(result.error.unwrap().contains("rate limit")); } + + // ── allowed_private_hosts opt-in tests ────────────────────── + + #[test] + fn wildcard_private_allowlist_permits_localhost() { + let tool = test_tool_with_private(vec![], vec!["*"]); + assert!(tool.validate_url("https://localhost:8443").is_ok()); + } + + #[test] + fn wildcard_private_allowlist_permits_private_ipv4() { + let tool = test_tool_with_private(vec![], vec!["*"]); + assert!(tool.validate_url("https://192.168.1.5").is_ok()); + } + + #[test] + fn allowed_private_hosts_entry_permits_listed_host() { + let tool = test_tool_with_private(vec![], vec!["10.0.0.1"]); + assert!(tool.validate_url("https://10.0.0.1").is_ok()); + } + + #[test] + fn allowed_private_hosts_does_not_permit_unlisted_host() { + let tool = test_tool_with_private(vec![], vec!["10.0.0.1"]); + let err = tool + .validate_url("https://10.0.0.2") + .unwrap_err() + .to_string(); + assert!(err.contains("local/private")); + } + + #[test] + fn empty_private_allowlist_still_rejects_private() { + let tool = test_tool_with_private(vec!["*"], vec![]); + let err = tool + .validate_url("https://localhost") + .unwrap_err() + .to_string(); + assert!(err.contains("local/private")); + } + + #[test] + fn wildcard_private_allowlist_alone_satisfies_allowlist_requirement() { + // allowed_domains empty + allowed_private_hosts=["*"] should not surface + // the "no allowed_domains configured" error for private hosts. + let tool = test_tool_with_private(vec![], vec!["*"]); + assert!(tool.validate_url("https://localhost").is_ok()); + } + + #[test] + fn specific_private_host_alone_satisfies_allowlist_requirement() { + let tool = test_tool_with_private(vec![], vec!["192.168.1.5"]); + assert!(tool.validate_url("https://192.168.1.5").is_ok()); + } }