diff --git a/tests/tools/test_url_safety.py b/tests/tools/test_url_safety.py index 5a0cceb2880..8513a848be0 100644 --- a/tests/tools/test_url_safety.py +++ b/tests/tools/test_url_safety.py @@ -482,3 +482,70 @@ class TestIsAlwaysBlockedUrl: """security.allow_private_urls can NOT unblock cloud metadata.""" monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "true") assert is_always_blocked_url("http://169.254.169.254/") is True + + +class TestIPv4MappedIPv6SSRF: + """Regression tests for SSRF bypass via IPv4-mapped IPv6 addresses. + + DNS resolvers may return ``::ffff:x.x.x.x`` for IPv4-only hosts. + Python's ipaddress module treats these as distinct from the plain + IPv4 address, so ``ip in frozenset({IPv4Address(...)})`` and + ``ip in IPv4Network(...)`` both return False. Without explicit + handling, an attacker could use IPv4-mapped addresses to bypass + all SSRF protections. + """ + + # ── _is_blocked_ip direct tests ── + + @pytest.mark.parametrize("ip_str", [ + "::ffff:100.64.0.1", # CGNAT start + "::ffff:100.100.100.200", # Alibaba Cloud metadata (in CGNAT range) + "::ffff:100.127.255.254", # CGNAT end + "::ffff:169.254.42.99", # Link-local (non-metadata) + "::ffff:0.0.0.0", # Unspecified + "::ffff:224.0.0.1", # Multicast + ]) + def test_ipv4_mapped_blocked_ips(self, ip_str): + """IPv4-mapped IPv6 addresses that should be blocked.""" + ip = ipaddress.ip_address(ip_str) + assert _is_blocked_ip(ip) is True, f"{ip_str} should be blocked" + + @pytest.mark.parametrize("ip_str", [ + "::ffff:8.8.8.8", # Public DNS + "::ffff:93.184.216.34", # example.com + "::ffff:100.0.0.1", # Not in CGNAT range + ]) + def test_ipv4_mapped_allowed_ips(self, ip_str): + """IPv4-mapped IPv6 addresses that should be allowed.""" + ip = ipaddress.ip_address(ip_str) + assert _is_blocked_ip(ip) is False, f"{ip_str} should be allowed" + + # ── is_safe_url integration tests: always-blocked metadata IPs ── + + def test_ipv4_mapped_aws_metadata_blocked(self): + """::ffff:169.254.169.254 (AWS metadata) must always be blocked.""" + with patch("socket.getaddrinfo", return_value=[ + (10, 1, 6, "", ("::ffff:169.254.169.254", 0, 0, 0)), + ]): + assert is_safe_url("http://aws-metadata.internal/") is False + + def test_ipv4_mapped_ecs_metadata_blocked(self): + """::ffff:169.254.170.2 (AWS ECS task metadata) must always be blocked.""" + with patch("socket.getaddrinfo", return_value=[ + (10, 1, 6, "", ("::ffff:169.254.170.2", 0, 0, 0)), + ]): + assert is_safe_url("http://ecs-metadata.internal/") is False + + def test_ipv4_mapped_azure_wire_server_blocked(self): + """::ffff:169.254.169.253 (Azure IMDS wire server) must always be blocked.""" + with patch("socket.getaddrinfo", return_value=[ + (10, 1, 6, "", ("::ffff:169.254.169.253", 0, 0, 0)), + ]): + assert is_safe_url("http://azure-metadata.internal/") is False + + def test_ipv4_mapped_alibaba_metadata_blocked(self): + """::ffff:100.100.100.200 (Alibaba Cloud metadata) must always be blocked.""" + with patch("socket.getaddrinfo", return_value=[ + (10, 1, 6, "", ("::ffff:100.100.100.200", 0, 0, 0)), + ]): + assert is_safe_url("http://aliyun-metadata.internal/") is False diff --git a/tools/url_safety.py b/tools/url_safety.py index 0f3dd597e49..a0ce297a923 100644 --- a/tools/url_safety.py +++ b/tools/url_safety.py @@ -45,15 +45,26 @@ _BLOCKED_HOSTNAMES = frozenset({ # allow_private_urls toggle. These are cloud metadata / credential # endpoints — the #1 SSRF target — and the link-local range where # they all live. +# +# IPv4-mapped IPv6 variants are included because DNS resolvers may +# return ``::ffff:x.x.x.x`` for IPv4-only hosts, and Python's +# ipaddress module treats these as distinct from the plain IPv4 +# address (they won't match ``ip in frozenset`` or ``ip in network``). _ALWAYS_BLOCKED_IPS = frozenset({ ipaddress.ip_address("169.254.169.254"), # AWS/GCP/Azure/DO/Oracle metadata ipaddress.ip_address("169.254.170.2"), # AWS ECS task metadata (task IAM creds) ipaddress.ip_address("169.254.169.253"), # Azure IMDS wire server ipaddress.ip_address("fd00:ec2::254"), # AWS metadata (IPv6) ipaddress.ip_address("100.100.100.200"), # Alibaba Cloud metadata + # IPv4-mapped IPv6 variants — same endpoints reachable via ::ffff:x.x.x.x + ipaddress.ip_address("::ffff:169.254.169.254"), + ipaddress.ip_address("::ffff:169.254.170.2"), + ipaddress.ip_address("::ffff:169.254.169.253"), + ipaddress.ip_address("::ffff:100.100.100.200"), }) _ALWAYS_BLOCKED_NETWORKS = ( ipaddress.ip_network("169.254.0.0/16"), # Entire link-local range (no legit agent target) + ipaddress.ip_network("::ffff:169.254.0.0/112"), # IPv4-mapped link-local range ) # Exact HTTPS hostnames allowed to resolve to private/benchmark-space IPs. @@ -137,6 +148,16 @@ def _reset_allow_private_cache() -> None: def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: """Return True if the IP should be blocked for SSRF protection.""" + # IPv4-mapped IPv6 addresses (``::ffff:x.x.x.x``) should be checked + # by their embedded IPv4 address, not as IPv6 + if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None: + embedded_ip = ip.ipv4_mapped + return (embedded_ip.is_private or embedded_ip.is_loopback or + embedded_ip.is_link_local or embedded_ip.is_reserved or + embedded_ip.is_multicast or embedded_ip.is_unspecified or + embedded_ip in _CGNAT_NETWORK) + + # Standard IPv4/IPv6 address checking if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: return True if ip.is_multicast or ip.is_unspecified: