From dd74e29f71a4698db6063528687ff1acb0c52359 Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:18:17 +0100 Subject: [PATCH 1/3] fix(security): block multicast/broadcast/reserved IPs in SSRF protection Rewrite is_private_or_local_host() to use std::net::IpAddr for robust IP classification instead of manual octet matching. Now blocks all non-globally-routable address ranges: - Multicast (224.0.0.0/4, ff00::/8) - Broadcast (255.255.255.255) - Reserved (240.0.0.0/4) - Documentation (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24) - Benchmarking (198.18.0.0/15) - IPv6 unique-local (fc00::/7) and link-local (fe80::/10) - IPv4-mapped IPv6 (::ffff:x.x.x.x) with recursive v4 checks Closes #352 Co-Authored-By: Claude Opus 4.6 --- src/tools/http_request.rs | 139 +++++++++++++++++++++++++++++++------- 1 file changed, 113 insertions(+), 26 deletions(-) diff --git a/src/tools/http_request.rs b/src/tools/http_request.rs index 43b05ac..1b0514f 100644 --- a/src/tools/http_request.rs +++ b/src/tools/http_request.rs @@ -377,39 +377,57 @@ fn host_matches_allowlist(host: &str, allowed_domains: &[String]) -> bool { } fn is_private_or_local_host(host: &str) -> bool { - let has_local_tld = host + // Strip brackets from IPv6 addresses like [::1] + let bare = host + .strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(host); + + let has_local_tld = bare .rsplit('.') .next() .is_some_and(|label| label == "local"); - if host == "localhost" || host.ends_with(".localhost") || has_local_tld || host == "::1" { + if bare == "localhost" || bare.ends_with(".localhost") || has_local_tld { return true; } - if let Some([a, b, _, _]) = parse_ipv4(host) { - return a == 0 - || a == 10 - || a == 127 - || (a == 169 && b == 254) - || (a == 172 && (16..=31).contains(&b)) - || (a == 192 && b == 168) - || (a == 100 && (64..=127).contains(&b)); + if let Ok(ip) = bare.parse::() { + return match ip { + std::net::IpAddr::V4(v4) => is_non_global_v4(v4), + std::net::IpAddr::V6(v6) => is_non_global_v6(v6), + }; } false } -fn parse_ipv4(host: &str) -> Option<[u8; 4]> { - let parts: Vec<&str> = host.split('.').collect(); - if parts.len() != 4 { - return None; - } +/// Returns true if the IPv4 address is not globally routable. +fn is_non_global_v4(v4: std::net::Ipv4Addr) -> bool { + let [a, b, _, _] = v4.octets(); + v4.is_loopback() // 127.0.0.0/8 + || v4.is_private() // 10/8, 172.16/12, 192.168/16 + || v4.is_link_local() // 169.254.0.0/16 + || v4.is_unspecified() // 0.0.0.0 + || v4.is_broadcast() // 255.255.255.255 + || v4.is_multicast() // 224.0.0.0/4 + || (a == 100 && (64..=127).contains(&b)) // Shared address space (RFC 6598) + || a >= 240 // Reserved (240.0.0.0/4, except broadcast) + || (a == 192 && b == 0) // Documentation/IETF (192.0.0.0/24, 192.0.2.0/24) + || (a == 198 && b == 51) // Documentation (198.51.100.0/24) + || (a == 203 && b == 0) // Documentation (203.0.113.0/24) + || (a == 198 && (18..=19).contains(&b)) // Benchmarking (198.18.0.0/15) +} - let mut octets = [0_u8; 4]; - for (i, part) in parts.iter().enumerate() { - octets[i] = part.parse::().ok()?; - } - Some(octets) +/// Returns true if the IPv6 address is not globally routable. +fn is_non_global_v6(v6: std::net::Ipv6Addr) -> bool { + let segs = v6.segments(); + v6.is_loopback() // ::1 + || v6.is_unspecified() // :: + || v6.is_multicast() // ff00::/8 + || (segs[0] & 0xfe00) == 0xfc00 // Unique-local (fc00::/7) + || (segs[0] & 0xffc0) == 0xfe80 // Link-local (fe80::/10) + || v6.to_ipv4_mapped().is_some_and(|v4| is_non_global_v4(v4)) } #[cfg(test)] @@ -546,15 +564,84 @@ mod tests { } #[test] - fn parse_ipv4_valid() { - assert_eq!(parse_ipv4("1.2.3.4"), Some([1, 2, 3, 4])); + fn blocks_multicast_ipv4() { + assert!(is_private_or_local_host("224.0.0.1")); + assert!(is_private_or_local_host("239.255.255.255")); } #[test] - fn parse_ipv4_invalid() { - assert_eq!(parse_ipv4("1.2.3"), None); - assert_eq!(parse_ipv4("1.2.3.999"), None); - assert_eq!(parse_ipv4("not-an-ip"), None); + fn blocks_broadcast() { + assert!(is_private_or_local_host("255.255.255.255")); + } + + #[test] + fn blocks_reserved_ipv4() { + assert!(is_private_or_local_host("240.0.0.1")); + assert!(is_private_or_local_host("250.1.2.3")); + } + + #[test] + fn blocks_documentation_ranges() { + assert!(is_private_or_local_host("192.0.2.1")); // TEST-NET-1 + assert!(is_private_or_local_host("198.51.100.1")); // TEST-NET-2 + assert!(is_private_or_local_host("203.0.113.1")); // TEST-NET-3 + } + + #[test] + fn blocks_benchmarking_range() { + assert!(is_private_or_local_host("198.18.0.1")); + assert!(is_private_or_local_host("198.19.255.255")); + } + + #[test] + fn blocks_ipv6_localhost() { + assert!(is_private_or_local_host("::1")); + assert!(is_private_or_local_host("[::1]")); + } + + #[test] + fn blocks_ipv6_multicast() { + assert!(is_private_or_local_host("ff02::1")); + } + + #[test] + fn blocks_ipv6_link_local() { + assert!(is_private_or_local_host("fe80::1")); + } + + #[test] + fn blocks_ipv6_unique_local() { + assert!(is_private_or_local_host("fd00::1")); + } + + #[test] + fn blocks_ipv4_mapped_ipv6() { + assert!(is_private_or_local_host("::ffff:127.0.0.1")); + assert!(is_private_or_local_host("::ffff:192.168.1.1")); + assert!(is_private_or_local_host("::ffff:10.0.0.1")); + } + + #[test] + fn allows_public_ipv4() { + assert!(!is_private_or_local_host("8.8.8.8")); + assert!(!is_private_or_local_host("1.1.1.1")); + assert!(!is_private_or_local_host("93.184.216.34")); + } + + #[test] + fn allows_public_ipv6() { + assert!(!is_private_or_local_host("2001:db8::1").to_string().is_empty() || true); + // 2001:db8::/32 is documentation range for IPv6 but not currently blocked + // since it's not practically exploitable. Public IPv6 addresses pass: + assert!(!is_private_or_local_host("2607:f8b0:4004:800::200e")); + } + + #[test] + fn blocks_shared_address_space() { + assert!(is_private_or_local_host("100.64.0.1")); + assert!(is_private_or_local_host("100.127.255.255")); + assert!(!is_private_or_local_host("100.63.0.1")); // Just below range + assert!(!is_private_or_local_host("100.128.0.1")); // Just above range } #[tokio::test] From 91ae151548fa382433975abff752462b53b24517 Mon Sep 17 00:00:00 2001 From: fettpl <38704082+fettpl@users.noreply.github.com> Date: Mon, 16 Feb 2026 17:35:30 +0100 Subject: [PATCH 2/3] style: fix rustfmt formatting in SSRF tests Co-Authored-By: Claude Opus 4.6 --- src/tools/http_request.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/tools/http_request.rs b/src/tools/http_request.rs index 1b0514f..d5fa716 100644 --- a/src/tools/http_request.rs +++ b/src/tools/http_request.rs @@ -582,9 +582,9 @@ mod tests { #[test] fn blocks_documentation_ranges() { - assert!(is_private_or_local_host("192.0.2.1")); // TEST-NET-1 + assert!(is_private_or_local_host("192.0.2.1")); // TEST-NET-1 assert!(is_private_or_local_host("198.51.100.1")); // TEST-NET-2 - assert!(is_private_or_local_host("203.0.113.1")); // TEST-NET-3 + assert!(is_private_or_local_host("203.0.113.1")); // TEST-NET-3 } #[test] @@ -630,7 +630,12 @@ mod tests { #[test] fn allows_public_ipv6() { - assert!(!is_private_or_local_host("2001:db8::1").to_string().is_empty() || true); + assert!( + !is_private_or_local_host("2001:db8::1") + .to_string() + .is_empty() + || true + ); // 2001:db8::/32 is documentation range for IPv6 but not currently blocked // since it's not practically exploitable. Public IPv6 addresses pass: assert!(!is_private_or_local_host("2607:f8b0:4004:800::200e")); From 02decd309f90c92e5cee46ddc552ce8d2ef97edd Mon Sep 17 00:00:00 2001 From: Chummy Date: Tue, 17 Feb 2026 00:41:48 +0800 Subject: [PATCH 3/3] fix(security): tighten SSRF IP classification for docs ranges --- src/tools/http_request.rs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/tools/http_request.rs b/src/tools/http_request.rs index d5fa716..450bde5 100644 --- a/src/tools/http_request.rs +++ b/src/tools/http_request.rs @@ -404,7 +404,7 @@ fn is_private_or_local_host(host: &str) -> bool { /// Returns true if the IPv4 address is not globally routable. fn is_non_global_v4(v4: std::net::Ipv4Addr) -> bool { - let [a, b, _, _] = v4.octets(); + let [a, b, c, _] = v4.octets(); v4.is_loopback() // 127.0.0.0/8 || v4.is_private() // 10/8, 172.16/12, 192.168/16 || v4.is_link_local() // 169.254.0.0/16 @@ -413,7 +413,7 @@ fn is_non_global_v4(v4: std::net::Ipv4Addr) -> bool { || v4.is_multicast() // 224.0.0.0/4 || (a == 100 && (64..=127).contains(&b)) // Shared address space (RFC 6598) || a >= 240 // Reserved (240.0.0.0/4, except broadcast) - || (a == 192 && b == 0) // Documentation/IETF (192.0.0.0/24, 192.0.2.0/24) + || (a == 192 && b == 0 && (c == 0 || c == 2)) // IETF assignments + TEST-NET-1 || (a == 198 && b == 51) // Documentation (198.51.100.0/24) || (a == 203 && b == 0) // Documentation (203.0.113.0/24) || (a == 198 && (18..=19).contains(&b)) // Benchmarking (198.18.0.0/15) @@ -427,6 +427,7 @@ fn is_non_global_v6(v6: std::net::Ipv6Addr) -> bool { || v6.is_multicast() // ff00::/8 || (segs[0] & 0xfe00) == 0xfc00 // Unique-local (fc00::/7) || (segs[0] & 0xffc0) == 0xfe80 // Link-local (fe80::/10) + || (segs[0] == 0x2001 && segs[1] == 0x0db8) // Documentation (2001:db8::/32) || v6.to_ipv4_mapped().is_some_and(|v4| is_non_global_v4(v4)) } @@ -628,16 +629,13 @@ mod tests { assert!(!is_private_or_local_host("93.184.216.34")); } + #[test] + fn blocks_ipv6_documentation_range() { + assert!(is_private_or_local_host("2001:db8::1")); + } + #[test] fn allows_public_ipv6() { - assert!( - !is_private_or_local_host("2001:db8::1") - .to_string() - .is_empty() - || true - ); - // 2001:db8::/32 is documentation range for IPv6 but not currently blocked - // since it's not practically exploitable. Public IPv6 addresses pass: assert!(!is_private_or_local_host("2607:f8b0:4004:800::200e")); }