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:
parent
de3ec87d16
commit
882defef12
1 changed files with 66 additions and 37 deletions
|
|
@ -393,9 +393,10 @@ impl BrowserTool {
|
||||||
anyhow::bail!("URL cannot be empty");
|
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://") {
|
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://") {
|
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(']'))
|
.and_then(|h| h.strip_suffix(']'))
|
||||||
.unwrap_or(host);
|
.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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse as IP address to catch all representations (decimal, hex, octal, mapped)
|
// Parse as IP address to catch all representations (decimal, hex, octal, mapped)
|
||||||
if let Ok(ip) = bare.parse::<std::net::IpAddr>() {
|
if let Ok(ip) = bare.parse::<std::net::IpAddr>() {
|
||||||
return match ip {
|
return match ip {
|
||||||
std::net::IpAddr::V4(v4) => {
|
std::net::IpAddr::V4(v4) => is_non_global_v4(v4),
|
||||||
v4.is_loopback()
|
std::net::IpAddr::V6(v6) => is_non_global_v6(v6),
|
||||||
|| v4.is_private()
|
|
||||||
|| v4.is_link_local()
|
|
||||||
|| v4.is_unspecified()
|
|
||||||
|| v4.is_broadcast()
|
|
||||||
}
|
|
||||||
std::net::IpAddr::V6(v6) => {
|
|
||||||
let segs = v6.segments();
|
|
||||||
v6.is_loopback()
|
|
||||||
|| v6.is_unspecified()
|
|
||||||
// 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
|
false
|
||||||
// (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))
|
/// 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()
|
||||||
|
|| 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
|
||||||
|
|| v6.to_ipv4_mapped().is_some_and(|v4| is_non_global_v4(v4))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn host_matches_allowlist(host: &str, allowed: &[String]) -> bool {
|
fn host_matches_allowlist(host: &str, allowed: &[String]) -> bool {
|
||||||
|
|
@ -2070,6 +2085,8 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn is_private_host_detects_local() {
|
fn is_private_host_detects_local() {
|
||||||
assert!(is_private_host("localhost"));
|
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("127.0.0.1"));
|
||||||
assert!(is_private_host("192.168.1.1"));
|
assert!(is_private_host("192.168.1.1"));
|
||||||
assert!(is_private_host("10.0.0.1"));
|
assert!(is_private_host("10.0.0.1"));
|
||||||
|
|
@ -2077,6 +2094,18 @@ mod tests {
|
||||||
assert!(!is_private_host("google.com"));
|
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]
|
#[test]
|
||||||
fn is_private_host_catches_ipv6() {
|
fn is_private_host_catches_ipv6() {
|
||||||
assert!(is_private_host("::1"));
|
assert!(is_private_host("::1"));
|
||||||
|
|
@ -2303,8 +2332,8 @@ mod tests {
|
||||||
// Invalid - not https
|
// Invalid - not https
|
||||||
assert!(tool.validate_url("ftp://example.com").is_err());
|
assert!(tool.validate_url("ftp://example.com").is_err());
|
||||||
|
|
||||||
// File URLs allowed
|
// file:// URLs blocked (local file exfiltration risk)
|
||||||
assert!(tool.validate_url("file:///tmp/test.html").is_ok());
|
assert!(tool.validate_url("file:///tmp/test.html").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue