fix: harden private host detection against SSRF bypass via IP parsing (#133)

- Handle IPv6 addresses with brackets correctly
- Parse IP addresses properly to catch all representations (decimal, hex, octal)
- Check for IPv4-mapped IPv6 addresses
- Check for IPv6 private ranges (unique-local fc00::/7, link-local fe80::/10)
- Add tests for IPv6 SSRF protection

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Argenis 2026-02-15 08:52:01 -05:00 committed by GitHub
parent 031683aae6
commit 1e21c24e1b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -677,14 +677,16 @@ fn extract_host(url_str: &str) -> anyhow::Result<String> {
.or_else(|| url.strip_prefix("file://")) .or_else(|| url.strip_prefix("file://"))
.unwrap_or(url); .unwrap_or(url);
// Extract host (before first / or :) // Extract host — handle bracketed IPv6 addresses like [::1]:8080
let host = without_scheme let authority = without_scheme.split('/').next().unwrap_or(without_scheme);
.split('/')
.next() let host = if authority.starts_with('[') {
.unwrap_or(without_scheme) // IPv6: take everything up to and including the closing ']'
.split(':') authority.find(']').map_or(authority, |i| &authority[..=i])
.next() } else {
.unwrap_or(without_scheme); // IPv4 or hostname: take everything before the port separator
authority.split(':').next().unwrap_or(authority)
};
if host.is_empty() { if host.is_empty() {
anyhow::bail!("Invalid URL: no host"); anyhow::bail!("Invalid URL: no host");
@ -694,35 +696,55 @@ fn extract_host(url_str: &str) -> anyhow::Result<String> {
} }
fn is_private_host(host: &str) -> bool { fn is_private_host(host: &str) -> bool {
let private_patterns = [ // Strip brackets from IPv6 addresses like [::1]
"localhost", let bare = host
"127.", .strip_prefix('[')
"10.", .and_then(|h| h.strip_suffix(']'))
"192.168.", .unwrap_or(host);
"172.16.",
"172.17.", if bare == "localhost" {
"172.18.", return true;
"172.19.", }
"172.20.",
"172.21.", // Parse as IP address to catch all representations (decimal, hex, octal, mapped)
"172.22.", if let Ok(ip) = bare.parse::<std::net::IpAddr>() {
"172.23.", return match ip {
"172.24.", std::net::IpAddr::V4(v4) => {
"172.25.", v4.is_loopback()
"172.26.", || v4.is_private()
"172.27.", || v4.is_link_local()
"172.28.", || v4.is_unspecified()
"172.29.", || v4.is_broadcast()
"172.30.", }
"172.31.", std::net::IpAddr::V6(v6) => {
"0.0.0.0", let segs = v6.segments();
"::1", v6.is_loopback()
"[::1]", || 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
// (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.",
]; ];
private_patterns string_patterns.iter().any(|p| bare.starts_with(p))
.iter()
.any(|p| host.starts_with(p) || host == *p)
} }
fn host_matches_allowlist(host: &str, allowed: &[String]) -> bool { fn host_matches_allowlist(host: &str, allowed: &[String]) -> bool {
@ -778,6 +800,43 @@ mod tests {
assert!(!is_private_host("google.com")); assert!(!is_private_host("google.com"));
} }
#[test]
fn is_private_host_catches_ipv6() {
assert!(is_private_host("::1"));
assert!(is_private_host("[::1]"));
assert!(is_private_host("0.0.0.0"));
}
#[test]
fn is_private_host_catches_mapped_ipv4() {
// IPv4-mapped IPv6 addresses
assert!(is_private_host("::ffff:127.0.0.1"));
assert!(is_private_host("::ffff:10.0.0.1"));
assert!(is_private_host("::ffff:192.168.1.1"));
}
#[test]
fn is_private_host_catches_ipv6_private_ranges() {
// Unique-local (fc00::/7)
assert!(is_private_host("fd00::1"));
assert!(is_private_host("fc00::1"));
// Link-local (fe80::/10)
assert!(is_private_host("fe80::1"));
// Public IPv6 should pass
assert!(!is_private_host("2001:db8::1"));
}
#[test]
fn validate_url_blocks_ipv6_ssrf() {
let security = Arc::new(SecurityPolicy::default());
let tool = BrowserTool::new(security, vec!["*".into()], None);
assert!(tool.validate_url("https://[::1]/").is_err());
assert!(tool.validate_url("https://[::ffff:127.0.0.1]/").is_err());
assert!(tool
.validate_url("https://[::ffff:10.0.0.1]:8080/")
.is_err());
}
#[test] #[test]
fn host_matches_allowlist_exact() { fn host_matches_allowlist_exact() {
let allowed = vec!["example.com".into()]; let allowed = vec!["example.com".into()];