@@ -1915,8 +1915,17 @@ fn is_host_gateway_alias(host: &str) -> bool {
19151915
19161916/// Returns `true` if `ip` is a known cloud instance metadata endpoint that
19171917/// must never be exempted from SSRF blocking.
1918+ ///
1919+ /// IPv4-mapped IPv6 addresses (e.g. `::ffff:169.254.169.254`) are normalized
1920+ /// to their embedded IPv4 representation before comparison, so the invariant
1921+ /// holds regardless of how the address is represented.
19181922fn is_cloud_metadata_ip ( ip : IpAddr ) -> bool {
1919- CLOUD_METADATA_IPS . contains ( & ip)
1923+ match ip {
1924+ IpAddr :: V4 ( _) => CLOUD_METADATA_IPS . contains ( & ip) ,
1925+ IpAddr :: V6 ( v6) => v6
1926+ . to_ipv4_mapped ( )
1927+ . is_some_and ( |v4| CLOUD_METADATA_IPS . contains ( & IpAddr :: V4 ( v4) ) ) ,
1928+ }
19201929}
19211930
19221931/// Read the proxy's own `/etc/hosts` at startup and return the IP mapped to
@@ -1958,13 +1967,15 @@ fn detect_trusted_host_gateway() -> Option<IpAddr> {
19581967 return None ;
19591968 }
19601969 // The exemption exists solely for link-local IPs used by rootless Podman
1961- // with pasta. Loopback (127.x), unspecified (0.0.0.0), and other
1962- // always-blocked non-link-local addresses are never legitimate host
1963- // gateway IPs and must not receive the exemption.
1964- if is_always_blocked_ip ( ip) && !is_link_local_ip ( ip) {
1970+ // with pasta. Private RFC 1918 addresses (e.g. Docker bridge 172.17.0.1,
1971+ // Kubernetes node 192.168.x.x), loopback, unspecified, and all other
1972+ // non-link-local addresses are never legitimate candidates for the
1973+ // link-local SSRF exemption — they must fall through to the normal
1974+ // allowed_ips / resolve_and_reject_internal() enforcement path.
1975+ if !is_link_local_ip ( ip) {
19651976 warn ! (
19661977 %ip,
1967- "host.openshell.internal maps to an always-blocked non-link-local IP; \
1978+ "host.openshell.internal maps to a non-link-local IP; \
19681979 trusted-gateway SSRF exemption disabled"
19691980 ) ;
19701981 return None ;
@@ -2022,13 +2033,12 @@ async fn resolve_and_check_trusted_gateway(
20222033 ) ) ;
20232034 }
20242035 // Defense-in-depth: even if the resolved IP matches trusted_gw, reject
2025- // always-blocked non-link-local addresses (loopback, unspecified). This
2026- // catches the case where detect_trusted_host_gateway somehow admitted a
2027- // bad IP, or where trusted_gw was set to a loopback/unspecified address
2028- // through a code path we haven't anticipated.
2029- if is_always_blocked_ip ( addr. ip ( ) ) && !is_link_local_ip ( addr. ip ( ) ) {
2036+ // any non-link-local address. detect_trusted_host_gateway() already
2037+ // enforces this at startup, but we re-check here to guard against any
2038+ // unanticipated code path that might admit a private or loopback IP.
2039+ if !is_link_local_ip ( addr. ip ( ) ) {
20302040 return Err ( format ! (
2031- "{host} resolves to always-blocked non-link-local address {}, \
2041+ "{host} resolves to non-link-local address {}, \
20322042 connection rejected",
20332043 addr. ip( )
20342044 ) ) ;
@@ -3910,6 +3920,29 @@ mod tests {
39103920 assert ! ( !is_cloud_metadata_ip( IpAddr :: V4 ( Ipv4Addr :: new( 8 , 8 , 8 , 8 ) ) ) ) ;
39113921 }
39123922
3923+ #[ test]
3924+ fn test_is_cloud_metadata_ip_blocks_ipv4_mapped_metadata ( ) {
3925+ // ::ffff:169.254.169.254 is the IPv4-mapped IPv6 representation of the
3926+ // AWS/GCP/Azure IMDS endpoint. is_link_local_ip() recognizes it as
3927+ // link-local, so is_cloud_metadata_ip() must also catch it — otherwise
3928+ // the trusted-gateway exemption would be granted to the metadata service.
3929+ let mapped = Ipv4Addr :: new ( 169 , 254 , 169 , 254 ) . to_ipv6_mapped ( ) ;
3930+ assert ! (
3931+ is_cloud_metadata_ip( IpAddr :: V6 ( mapped) ) ,
3932+ "::ffff:169.254.169.254 must be recognized as cloud metadata"
3933+ ) ;
3934+ }
3935+
3936+ #[ test]
3937+ fn test_is_cloud_metadata_ip_allows_other_ipv4_mapped_link_local ( ) {
3938+ // Other IPv4-mapped link-local addresses are NOT metadata.
3939+ let mapped = Ipv4Addr :: new ( 169 , 254 , 1 , 2 ) . to_ipv6_mapped ( ) ;
3940+ assert ! (
3941+ !is_cloud_metadata_ip( IpAddr :: V6 ( mapped) ) ,
3942+ "::ffff:169.254.1.2 should not be flagged as cloud metadata"
3943+ ) ;
3944+ }
3945+
39133946 // -- detect_trusted_host_gateway --
39143947
39153948 #[ test]
@@ -3941,44 +3974,59 @@ mod tests {
39413974
39423975 #[ test]
39433976 fn test_detect_trusted_host_gateway_rejects_loopback ( ) {
3944- // Loopback is always-blocked and not link-local — must not be trusted .
3977+ // Loopback is not link-local — must not receive the SSRF exemption .
39453978 let ip = IpAddr :: V4 ( Ipv4Addr :: LOCALHOST ) ;
39463979 assert ! ( !is_cloud_metadata_ip( ip) ) ;
3947- assert ! ( is_always_blocked_ip( ip) ) ;
39483980 assert ! ( !is_link_local_ip( ip) ) ;
3949- // The guard: always-blocked && !link-local → reject.
3950- assert ! ( is_always_blocked_ip ( ip ) && !is_link_local_ip( ip) ) ;
3981+ // The guard: !link-local → reject.
3982+ assert ! ( !is_link_local_ip( ip) ) ;
39513983 }
39523984
39533985 #[ test]
39543986 fn test_detect_trusted_host_gateway_rejects_unspecified ( ) {
3955- // Unspecified (0.0.0.0) must not be trusted.
3987+ // Unspecified (0.0.0.0) is not link-local — must not be trusted.
39563988 let ip = IpAddr :: V4 ( Ipv4Addr :: UNSPECIFIED ) ;
39573989 assert ! ( !is_cloud_metadata_ip( ip) ) ;
3958- assert ! ( is_always_blocked_ip( ip) ) ;
39593990 assert ! ( !is_link_local_ip( ip) ) ;
3960- assert ! ( is_always_blocked_ip ( ip ) && !is_link_local_ip( ip) ) ;
3991+ assert ! ( !is_link_local_ip( ip) ) ;
39613992 }
39623993
39633994 #[ test]
39643995 fn test_detect_trusted_host_gateway_rejects_loopback_v6 ( ) {
39653996 let ip = IpAddr :: V6 ( Ipv6Addr :: LOCALHOST ) ;
39663997 assert ! ( !is_cloud_metadata_ip( ip) ) ;
3967- assert ! ( is_always_blocked_ip( ip) ) ;
39683998 assert ! ( !is_link_local_ip( ip) ) ;
3969- assert ! ( is_always_blocked_ip( ip) && !is_link_local_ip( ip) ) ;
3999+ }
4000+
4001+ #[ test]
4002+ fn test_detect_trusted_host_gateway_rejects_private_ip ( ) {
4003+ // Docker bridge (172.17.0.1) and K8s host gateway (192.168.x.x) are
4004+ // RFC 1918 private addresses — not link-local. Before this fix they
4005+ // slipped through the old always-blocked guard and received the SSRF
4006+ // exemption. The new guard (!is_link_local_ip) rejects them, so
4007+ // connections to these hosts fall through to resolve_and_reject_internal().
4008+ for ip in [
4009+ IpAddr :: V4 ( Ipv4Addr :: new ( 172 , 17 , 0 , 1 ) ) ,
4010+ IpAddr :: V4 ( Ipv4Addr :: new ( 192 , 168 , 1 , 1 ) ) ,
4011+ IpAddr :: V4 ( Ipv4Addr :: new ( 10 , 0 , 0 , 1 ) ) ,
4012+ ] {
4013+ assert ! ( !is_cloud_metadata_ip( ip) , "{ip} should not be metadata" ) ;
4014+ assert ! ( !is_link_local_ip( ip) , "{ip} should not be link-local" ) ;
4015+ // Guard fires — exemption disabled.
4016+ assert ! ( !is_link_local_ip( ip) , "{ip}: guard must reject" ) ;
4017+ }
39704018 }
39714019
39724020 #[ test]
39734021 fn test_detect_trusted_host_gateway_allows_link_local_non_metadata ( ) {
3974- // 169.254.1.2 is always-blocked (link-local) but IS link-local,
3975- // so the guard should pass and allow the exemption.
4022+ // 169.254.1.2 (rootless Podman pasta gateway) IS link-local and is
4023+ // not a cloud metadata IP — it is the only address class the exemption
4024+ // is designed for.
39764025 let ip = IpAddr :: V4 ( Ipv4Addr :: new ( 169 , 254 , 1 , 2 ) ) ;
39774026 assert ! ( !is_cloud_metadata_ip( ip) ) ;
3978- assert ! ( is_always_blocked_ip( ip) ) ;
39794027 assert ! ( is_link_local_ip( ip) ) ;
3980- // The guard does NOT fire — this IP is eligible for the exemption.
3981- assert ! ( ! ( is_always_blocked_ip ( ip ) && ! is_link_local_ip( ip) ) ) ;
4028+ // Guard does NOT fire — this IP is eligible for the exemption.
4029+ assert ! ( is_link_local_ip( ip) ) ;
39824030 }
39834031
39844032 // -- parse_hosts_file_for_host: multi-entry / duplicate scenarios --
@@ -4189,8 +4237,8 @@ mod tests {
41894237 assert ! ( result. is_err( ) , "loopback must be rejected" ) ;
41904238 let err = result. unwrap_err ( ) ;
41914239 assert ! (
4192- err. contains( "always-blocked non-link-local" ) ,
4193- "expected always-blocked rejection, got: {err}"
4240+ err. contains( "non-link-local" ) ,
4241+ "expected non-link-local rejection, got: {err}"
41944242 ) ;
41954243 }
41964244
@@ -4203,8 +4251,8 @@ mod tests {
42034251 assert ! ( result. is_err( ) , "unspecified must be rejected" ) ;
42044252 let err = result. unwrap_err ( ) ;
42054253 assert ! (
4206- err. contains( "always-blocked non-link-local" ) ,
4207- "expected always-blocked rejection, got: {err}"
4254+ err. contains( "non-link-local" ) ,
4255+ "expected non-link-local rejection, got: {err}"
42084256 ) ;
42094257 }
42104258
@@ -4238,6 +4286,22 @@ mod tests {
42384286 ) ;
42394287 }
42404288
4289+ #[ tokio:: test]
4290+ async fn test_trusted_gateway_rejects_private_ip_as_trusted_gw ( ) {
4291+ // Defense-in-depth: a private RFC 1918 IP (e.g. Docker bridge 172.17.0.1)
4292+ // must be rejected even if it somehow matched trusted_gw.
4293+ // detect_trusted_host_gateway() already blocks these via !is_link_local_ip(),
4294+ // but resolve_and_check_trusted_gateway() must enforce the same invariant.
4295+ let docker_bridge = IpAddr :: V4 ( Ipv4Addr :: new ( 172 , 17 , 0 , 1 ) ) ;
4296+ let result = resolve_and_check_trusted_gateway ( "172.17.0.1" , 8080 , docker_bridge, 0 ) . await ;
4297+ assert ! ( result. is_err( ) , "private RFC 1918 IP must be rejected" ) ;
4298+ let err = result. unwrap_err ( ) ;
4299+ assert ! (
4300+ err. contains( "non-link-local" ) ,
4301+ "expected non-link-local rejection for private IP, got: {err}"
4302+ ) ;
4303+ }
4304+
42414305 #[ tokio:: test]
42424306 async fn test_rejects_localhost_resolution ( ) {
42434307 let result = resolve_and_reject_internal ( "localhost" , 80 , 0 ) . await ;
0 commit comments