diff --git a/tests/tools/test_url_safety.py b/tests/tools/test_url_safety.py index 8513a848be01..a20036c7da62 100644 --- a/tests/tools/test_url_safety.py +++ b/tests/tools/test_url_safety.py @@ -217,6 +217,38 @@ def test_allowed_ips(self, ip_str): ip = ipaddress.ip_address(ip_str) assert _is_blocked_ip(ip) is False, f"{ip_str} should be allowed" + # ── NAT64/DNS64 well-known prefix (64:ff9b::/96) ── + + @pytest.mark.parametrize("ip_str,embedded", [ + ("64:ff9b::a9fe:a9fe", "169.254.169.254"), # AWS metadata via NAT64 + ("64:ff9b::0a00:0001", "10.0.0.1"), # Private 10.x via NAT64 + ("64:ff9b::7f00:0001", "127.0.0.1"), # Loopback via NAT64 + ("64:ff9b::c0a8:0001", "192.168.0.1"), # Private 192.168.x via NAT64 + ("64:ff9b::0000:0000", "0.0.0.0"), # Unspecified via NAT64 + ("64:ff9b::e000:0001", "224.0.0.1"), # Multicast via NAT64 + ("64:ff9b::6440:0001", "100.64.0.1"), # CGNAT via NAT64 + ]) + def test_nat64_blocked(self, ip_str, embedded): + """NAT64 addresses wrapping private/metadata IPv4 targets must be blocked.""" + ip = ipaddress.ip_address(ip_str) + assert _is_blocked_ip(ip) is True, ( + f"{ip_str} (embeds {embedded}) should be blocked" + ) + + @pytest.mark.parametrize("ip_str,embedded", [ + ("64:ff9b::6812:27e4", "104.18.39.228"), # Cloudflare public IP + ("64:ff9b::ac40:941c", "172.64.148.28"), # Another Cloudflare IP + ("64:ff9b::0808:0808", "8.8.8.8"), # Google DNS + ("64:ff9b::0101:0101", "1.1.1.1"), # Cloudflare DNS + ("64:ff9b::5d68:d822", "93.184.216.34"), # example.com + ]) + def test_nat64_allowed(self, ip_str, embedded): + """NAT64 addresses wrapping public IPv4 targets must be allowed.""" + ip = ipaddress.ip_address(ip_str) + assert _is_blocked_ip(ip) is False, ( + f"{ip_str} (embeds {embedded}) should be allowed" + ) + class TestGlobalAllowPrivateUrls: """Tests for the security.allow_private_urls config toggle.""" @@ -529,6 +561,20 @@ def test_ipv4_mapped_aws_metadata_blocked(self): ]): assert is_safe_url("http://aws-metadata.internal/") is False + def test_nat64_public_site_allowed(self): + """64:ff9b:: wrapping a public IPv4 should pass is_safe_url.""" + with patch("socket.getaddrinfo", return_value=[ + (10, 1, 6, "", ("64:ff9b::6812:27e4", 0, 0, 0)), + ]): + assert is_safe_url("http://www.example.com/") is True + + def test_nat64_aws_metadata_blocked(self): + """64:ff9b:: wrapping 169.254.169.254 must always be blocked.""" + with patch("socket.getaddrinfo", return_value=[ + (10, 1, 6, "", ("64:ff9b::a9fe:a9fe", 0, 0, 0)), + ]): + assert is_safe_url("http://fake-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=[ diff --git a/tools/url_safety.py b/tools/url_safety.py index a0ce297a923b..39a2d458c4d0 100644 --- a/tools/url_safety.py +++ b/tools/url_safety.py @@ -80,6 +80,13 @@ # VPNs, and some cloud internal networks. _CGNAT_NETWORK = ipaddress.ip_network("100.64.0.0/10") +# DNS64/NAT64 well-known prefix (RFC 6052). Addresses in 64:ff9b::/96 +# embed a public IPv4 address in the low 32 bits. Python marks the +# entire prefix as ``is_reserved``, which causes false-positive SSRF +# blocks on normal public sites when the resolver synthesises AAAA +# records. We must decode the embedded IPv4 and check *that* instead. +_NAT64_WKP = ipaddress.ip_network("64:ff9b::/96") + # --------------------------------------------------------------------------- # Global toggle: allow private/internal IP resolution # --------------------------------------------------------------------------- @@ -157,6 +164,17 @@ def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: embedded_ip.is_multicast or embedded_ip.is_unspecified or embedded_ip in _CGNAT_NETWORK) + # DNS64/NAT64 well-known prefix (64:ff9b::/96) — the IPv6 address + # itself is ``is_reserved`` in Python, but the embedded IPv4 target + # may be a perfectly public host. Evaluate the embedded IPv4 + # instead of blocking the NAT64 wrapper wholesale. + if isinstance(ip, ipaddress.IPv6Address) and ip in _NAT64_WKP: + embedded_ip = ipaddress.IPv4Address(ip.packed[-4:]) + 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