Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/zeroclaw-config/fixtures/v1.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 9 additions & 0 deletions crates/zeroclaw-config/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

fn default_browser_allowed_domains() -> Vec<String> {
Expand All @@ -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![],
}
}
}
Expand Down Expand Up @@ -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();
Expand Down
7 changes: 6 additions & 1 deletion crates/zeroclaw-runtime/src/tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down Expand Up @@ -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())));
Expand Down
128 changes: 126 additions & 2 deletions crates/zeroclaw-tools/src/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ impl Default for ComputerUseConfig {
pub struct BrowserTool {
security: Arc<SecurityPolicy>,
allowed_domains: Vec<String>,
allowed_private_hosts: Vec<String>,
session_name: Option<String>,
backend: String,
headed: Option<bool>,
Expand Down Expand Up @@ -216,6 +217,7 @@ impl BrowserTool {
"http://127.0.0.1:9515".into(),
None,
ComputerUseConfig::default(),
Vec::new(),
)
}

Expand All @@ -230,13 +232,18 @@ impl BrowserTool {
native_webdriver_url: String,
native_chrome_path: Option<String>,
computer_use: ComputerUseConfig,
allowed_private_hosts: Vec<String>,
) -> anyhow::Result<Self> {
Ok(Self {
security,
allowed_domains: domain_guard::normalize_allowed_domains(
allowed_domains,
"browser.allowed_domains",
)?,
allowed_private_hosts: domain_guard::normalize_allowed_domains(
allowed_private_hosts,
"browser.allowed_private_hosts",
)?,
session_name,
backend,
headed,
Expand Down Expand Up @@ -449,19 +456,26 @@ 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"
);
}

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");
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand All @@ -2462,6 +2479,7 @@ mod tests {
"http://127.0.0.1:9515".into(),
None,
ComputerUseConfig::default(),
Vec::new(),
)
.unwrap();
assert_eq!(
Expand All @@ -2486,6 +2504,7 @@ mod tests {
endpoint: "http://computer-use.example.com/v1/actions".into(),
..ComputerUseConfig::default()
},
Vec::new(),
)
.unwrap();

Expand All @@ -2509,6 +2528,7 @@ mod tests {
allow_remote_endpoint: true,
..ComputerUseConfig::default()
},
Vec::new(),
)
.unwrap();

Expand All @@ -2532,6 +2552,7 @@ mod tests {
max_coordinate_y: Some(100),
..ComputerUseConfig::default()
},
Vec::new(),
)
.unwrap();

Expand Down Expand Up @@ -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"));
}
}
Loading