@@ -1210,18 +1210,20 @@ fn query_tls_mode(
12101210 }
12111211}
12121212
1213- /// Check if an IP address is internal (loopback, private RFC1918, or link-local).
1213+ /// Check if an IP address is internal (loopback, private RFC1918, link-local, or unspecified ).
12141214///
12151215/// This is a defense-in-depth check to prevent SSRF via the CONNECT proxy.
12161216/// It covers:
1217- /// - IPv4 loopback (127.0.0.0/8), private (10/8, 172.16/12, 192.168/16), link-local (169.254/16)
1218- /// - IPv6 loopback (`::1`), link-local (`fe80::/10`), ULA (`fc00::/7`)
1217+ /// - IPv4 loopback (127.0.0.0/8), private (10/8, 172.16/12, 192.168/16), link-local (169.254/16), unspecified (`0.0.0.0`)
1218+ /// - IPv6 loopback (`::1`), link-local (`fe80::/10`), ULA (`fc00::/7`), unspecified (`::`)
12191219/// - IPv4-mapped IPv6 addresses (`::ffff:x.x.x.x`) are unwrapped and checked as IPv4
12201220fn is_internal_ip ( ip : IpAddr ) -> bool {
12211221 match ip {
1222- IpAddr :: V4 ( v4) => v4. is_loopback ( ) || v4. is_private ( ) || v4. is_link_local ( ) ,
1222+ IpAddr :: V4 ( v4) => {
1223+ v4. is_loopback ( ) || v4. is_private ( ) || v4. is_link_local ( ) || v4. is_unspecified ( )
1224+ }
12231225 IpAddr :: V6 ( v6) => {
1224- if v6. is_loopback ( ) {
1226+ if v6. is_loopback ( ) || v6 . is_unspecified ( ) {
12251227 return true ;
12261228 }
12271229 // fe80::/10 — IPv6 link-local
@@ -1234,7 +1236,10 @@ fn is_internal_ip(ip: IpAddr) -> bool {
12341236 }
12351237 // Check IPv4-mapped IPv6 (::ffff:x.x.x.x)
12361238 if let Some ( v4) = v6. to_ipv4_mapped ( ) {
1237- return v4. is_loopback ( ) || v4. is_private ( ) || v4. is_link_local ( ) ;
1239+ return v4. is_loopback ( )
1240+ || v4. is_private ( )
1241+ || v4. is_link_local ( )
1242+ || v4. is_unspecified ( ) ;
12381243 }
12391244 false
12401245 }
@@ -1287,14 +1292,14 @@ async fn resolve_and_reject_internal(
12871292
12881293/// Check if an IP address is always blocked regardless of policy.
12891294///
1290- /// Loopback and link-local addresses are never allowed even when an endpoint
1295+ /// Loopback, link-local, and unspecified addresses are never allowed even when an endpoint
12911296/// has `allowed_ips` configured. This prevents proxy bypass (loopback) and
12921297/// cloud metadata SSRF (link-local 169.254.x.x).
12931298fn is_always_blocked_ip ( ip : IpAddr ) -> bool {
12941299 match ip {
1295- IpAddr :: V4 ( v4) => v4. is_loopback ( ) || v4. is_link_local ( ) ,
1300+ IpAddr :: V4 ( v4) => v4. is_loopback ( ) || v4. is_link_local ( ) || v4 . is_unspecified ( ) ,
12961301 IpAddr :: V6 ( v6) => {
1297- if v6. is_loopback ( ) {
1302+ if v6. is_loopback ( ) || v6 . is_unspecified ( ) {
12981303 return true ;
12991304 }
13001305 // fe80::/10 — IPv6 link-local
@@ -1303,7 +1308,7 @@ fn is_always_blocked_ip(ip: IpAddr) -> bool {
13031308 }
13041309 // Check IPv4-mapped IPv6 (::ffff:x.x.x.x)
13051310 if let Some ( v4) = v6. to_ipv4_mapped ( ) {
1306- return v4. is_loopback ( ) || v4. is_link_local ( ) ;
1311+ return v4. is_loopback ( ) || v4. is_link_local ( ) || v4 . is_unspecified ( ) ;
13071312 }
13081313 false
13091314 }
@@ -2023,6 +2028,11 @@ mod tests {
20232028 assert ! ( is_internal_ip( IpAddr :: V4 ( Ipv4Addr :: new( 169 , 254 , 0 , 1 ) ) ) ) ;
20242029 }
20252030
2031+ #[ test]
2032+ fn test_rejects_ipv4_unspecified ( ) {
2033+ assert ! ( is_internal_ip( IpAddr :: V4 ( Ipv4Addr :: UNSPECIFIED ) ) ) ;
2034+ }
2035+
20262036 #[ test]
20272037 fn test_allows_ipv4_public ( ) {
20282038 assert ! ( !is_internal_ip( IpAddr :: V4 ( Ipv4Addr :: new( 8 , 8 , 8 , 8 ) ) ) ) ;
@@ -2043,6 +2053,11 @@ mod tests {
20432053 assert ! ( is_internal_ip( IpAddr :: V6 ( Ipv6Addr :: LOCALHOST ) ) ) ;
20442054 }
20452055
2056+ #[ test]
2057+ fn test_rejects_ipv6_unspecified ( ) {
2058+ assert ! ( is_internal_ip( IpAddr :: V6 ( Ipv6Addr :: UNSPECIFIED ) ) ) ;
2059+ }
2060+
20462061 #[ test]
20472062 fn test_rejects_ipv6_link_local ( ) {
20482063 // fe80::1
@@ -2325,6 +2340,16 @@ mod tests {
23252340 ) ) ) ) ;
23262341 }
23272342
2343+ #[ test]
2344+ fn test_always_blocked_ipv4_unspecified ( ) {
2345+ assert ! ( is_always_blocked_ip( IpAddr :: V4 ( Ipv4Addr :: UNSPECIFIED ) ) ) ;
2346+ }
2347+
2348+ #[ test]
2349+ fn test_always_blocked_ipv6_unspecified ( ) {
2350+ assert ! ( is_always_blocked_ip( IpAddr :: V6 ( Ipv6Addr :: UNSPECIFIED ) ) ) ;
2351+ }
2352+
23282353 #[ test]
23292354 fn test_always_blocked_ipv4_mapped_v6_loopback ( ) {
23302355 let v6 = Ipv4Addr :: LOCALHOST . to_ipv6_mapped ( ) ;
@@ -2430,6 +2455,18 @@ mod tests {
24302455 ) ;
24312456 }
24322457
2458+ #[ tokio:: test]
2459+ async fn test_resolve_check_allowed_ips_blocks_unspecified ( ) {
2460+ let nets = parse_allowed_ips ( & [ "0.0.0.0/0" . to_string ( ) ] ) . unwrap ( ) ;
2461+ let result = resolve_and_check_allowed_ips ( "0.0.0.0" , 80 , & nets) . await ;
2462+ assert ! ( result. is_err( ) ) ;
2463+ let err = result. unwrap_err ( ) ;
2464+ assert ! (
2465+ err. contains( "always-blocked" ) ,
2466+ "expected 'always-blocked' in error: {err}"
2467+ ) ;
2468+ }
2469+
24332470 #[ tokio:: test]
24342471 async fn test_resolve_check_allowed_ips_rejects_outside_allowlist ( ) {
24352472 // 8.8.8.8 resolves to a public IP which is NOT in 10.0.0.0/8
0 commit comments