security(browser): harden SSRF blocking and block file:// URLs

- Block file:// URLs which bypassed all SSRF and domain-allowlist
  controls, enabling arbitrary local file exfiltration via browser
- Harden is_private_host() to match http_request.rs coverage:
  multicast, broadcast, reserved (240/4), shared address space
  (100.64/10), documentation IPs, benchmarking IPs
- Add .localhost subdomain and .local mDNS TLD blocking
- Extract is_non_global_v4() and is_non_global_v6() helpers

Closes #361

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
fettpl 2026-02-16 17:49:21 +01:00
parent de3ec87d16
commit 882defef12

View file

@ -393,9 +393,10 @@ impl BrowserTool {
anyhow::bail!("URL cannot be empty");
}
// Allow file:// URLs for local testing
// Block file:// URLs — browser file access bypasses all SSRF and
// domain-allowlist controls and can exfiltrate arbitrary local files.
if url.starts_with("file://") {
return Ok(());
anyhow::bail!("file:// URLs are not allowed in browser automation");
}
if !url.starts_with("https://") && !url.starts_with("http://") {
@ -1966,49 +1967,63 @@ fn is_private_host(host: &str) -> bool {
.and_then(|h| h.strip_suffix(']'))
.unwrap_or(host);
if bare == "localhost" {
if bare == "localhost" || bare.ends_with(".localhost") {
return true;
}
// .local TLD (mDNS)
if bare
.rsplit('.')
.next()
.is_some_and(|label| label == "local")
{
return true;
}
// Parse as IP address to catch all representations (decimal, hex, octal, mapped)
if let Ok(ip) = bare.parse::<std::net::IpAddr>() {
return match ip {
std::net::IpAddr::V4(v4) => {
std::net::IpAddr::V4(v4) => is_non_global_v4(v4),
std::net::IpAddr::V6(v6) => is_non_global_v6(v6),
};
}
false
}
/// Returns `true` for any IPv4 address that is not globally routable.
fn is_non_global_v4(v4: std::net::Ipv4Addr) -> bool {
let [a, b, _, _] = v4.octets();
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.is_broadcast()
}
std::net::IpAddr::V6(v6) => {
|| v4.is_multicast()
// Shared address space (100.64/10)
|| (a == 100 && (64..=127).contains(&b))
// Reserved (240.0.0.0/4)
|| a >= 240
// Documentation (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24)
|| (a == 192 && b == 0)
|| (a == 198 && b == 51)
|| (a == 203 && b == 0)
// Benchmarking (198.18.0.0/15)
|| (a == 198 && (18..=19).contains(&b))
}
/// Returns `true` for any IPv6 address that is not globally routable.
fn is_non_global_v6(v6: std::net::Ipv6Addr) -> bool {
let segs = v6.segments();
v6.is_loopback()
|| v6.is_unspecified()
|| v6.is_multicast()
// Unique-local (fc00::/7) — IPv6 equivalent of RFC 1918
|| (segs[0] & 0xfe00) == 0xfc00
// Link-local (fe80::/10)
|| (segs[0] & 0xffc0) == 0xfe80
// IPv4-mapped addresses (::ffff:127.0.0.1)
|| v6.to_ipv4_mapped().is_some_and(|v4| {
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.is_broadcast()
})
}
};
}
// Fallback string patterns for hostnames that look like IPs but don't parse
// (e.g., partial addresses used in DNS names).
let string_patterns = [
"127.", "10.", "192.168.", "0.0.0.0", "172.16.", "172.17.", "172.18.", "172.19.",
"172.20.", "172.21.", "172.22.", "172.23.", "172.24.", "172.25.", "172.26.", "172.27.",
"172.28.", "172.29.", "172.30.", "172.31.",
];
string_patterns.iter().any(|p| bare.starts_with(p))
// IPv4-mapped addresses
|| v6.to_ipv4_mapped().is_some_and(|v4| is_non_global_v4(v4))
}
fn host_matches_allowlist(host: &str, allowed: &[String]) -> bool {
@ -2070,6 +2085,8 @@ mod tests {
#[test]
fn is_private_host_detects_local() {
assert!(is_private_host("localhost"));
assert!(is_private_host("app.localhost"));
assert!(is_private_host("printer.local"));
assert!(is_private_host("127.0.0.1"));
assert!(is_private_host("192.168.1.1"));
assert!(is_private_host("10.0.0.1"));
@ -2077,6 +2094,18 @@ mod tests {
assert!(!is_private_host("google.com"));
}
#[test]
fn is_private_host_blocks_multicast_and_reserved() {
assert!(is_private_host("224.0.0.1")); // multicast
assert!(is_private_host("255.255.255.255")); // broadcast
assert!(is_private_host("100.64.0.1")); // shared address space
assert!(is_private_host("240.0.0.1")); // reserved
assert!(is_private_host("192.0.2.1")); // documentation
assert!(is_private_host("198.51.100.1")); // documentation
assert!(is_private_host("203.0.113.1")); // documentation
assert!(is_private_host("198.18.0.1")); // benchmarking
}
#[test]
fn is_private_host_catches_ipv6() {
assert!(is_private_host("::1"));
@ -2303,8 +2332,8 @@ mod tests {
// Invalid - not https
assert!(tool.validate_url("ftp://example.com").is_err());
// File URLs allowed
assert!(tool.validate_url("file:///tmp/test.html").is_ok());
// file:// URLs blocked (local file exfiltration risk)
assert!(tool.validate_url("file:///tmp/test.html").is_err());
}
#[test]