Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions tests/tools/test_url_safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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=[
Expand Down
18 changes: 18 additions & 0 deletions tools/url_safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This protects the ordinary _is_blocked_ip() path, but is_safe_url() skips that helper when security.allow_private_urls is enabled. Please also decode NAT64 before the always-blocked metadata checks so an embedded IMDS address remains blocked under the toggle.

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
Expand Down
Loading