diff --git a/raven/security/network.py b/raven/security/network.py index 42bf28a9..a4e7406d 100644 --- a/raven/security/network.py +++ b/raven/security/network.py @@ -23,8 +23,32 @@ ipaddress.ip_network("fe80::/10"), ] +_NAT64_WELL_KNOWN = ipaddress.ip_network("64:ff9b::/96") + + +def _embedded_ipv4(addr: ipaddress.IPv6Address) -> ipaddress.IPv4Address | None: + """The embedded IPv4 destination of an IPv6-to-IPv4 transition address. + + IPv4-mapped IPv6 (::ffff:a.b.c.d), 6to4 (2002::/16), and NAT64 + (64:ff9b::/96) all route to an IPv4 destination, so the guard must + judge that destination rather than the IPv6 literal spelling. + """ + mapped = getattr(addr, "ipv4_mapped", None) + if mapped is not None: + return mapped + sixtofour = getattr(addr, "sixtofour", None) + if sixtofour is not None: + return sixtofour + if addr in _NAT64_WELL_KNOWN: + return ipaddress.IPv4Address(int(addr) & 0xFFFFFFFF) + return None + def _is_private(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: + if isinstance(addr, ipaddress.IPv6Address): + embedded = _embedded_ipv4(addr) + if embedded is not None: + addr = embedded return any(addr in net for net in _BLOCKED_NETWORKS) diff --git a/tests/test_security_network.py b/tests/test_security_network.py index 02f35eec..f3d7cc35 100644 --- a/tests/test_security_network.py +++ b/tests/test_security_network.py @@ -55,6 +55,60 @@ def test_blocks_unique_local_v6() -> None: assert "private/internal" in err +def test_blocks_v4_mapped_loopback() -> None: + ok, err = net.validate_url_target("http://[::ffff:127.0.0.1]/x") + assert not ok + assert "private/internal" in err + + +@pytest.mark.parametrize( + "url", + [ + "http://[::ffff:10.0.0.1]/x", + "http://[::ffff:7f00:1]/x", # hex form of ::ffff:127.0.0.1 + "http://[::ffff:169.254.169.254]/latest/meta-data/", + ], +) +def test_blocks_v4_mapped_private(url: str) -> None: + ok, err = net.validate_url_target(url) + assert not ok, f"should block {url}" + assert "private/internal" in err + + +def test_allows_v4_mapped_public() -> None: + ok, err = net.validate_url_target("http://[::ffff:93.184.216.34]/x") + assert ok, f"unexpectedly blocked: {err}" + + +@pytest.mark.parametrize( + "url", + [ + "http://[2002:7f00:1::]/x", # 6to4 embedding 127.0.0.1 + "http://[64:ff9b::7f00:1]/x", # NAT64 well-known prefix -> 127.0.0.1 + "http://[2002:a00:1::]/x", # 6to4 embedding 10.0.0.1 + "http://[64:ff9b::a9fe:a9fe]/x", # NAT64 -> 169.254.169.254 (metadata) + ], +) +def test_blocks_transition_prefixes_with_private_embedded(url: str) -> None: + ok, err = net.validate_url_target(url) + assert not ok, f"should block {url}" + assert "private/internal" in err + + +@pytest.mark.parametrize( + "url", + [ + "http://[2002:5db8:d822::]/x", # 6to4 embedding public 93.184.216.34 + "http://[64:ff9b::5db8:d822]/x", # NAT64 -> public 93.184.216.34 + ], +) +def test_allows_transition_prefixes_with_public_embedded(url: str) -> None: + """DNS64 networks synthesize NAT64/6to4 addresses for legitimate public + IPv4 services; those must not be blocked wholesale.""" + ok, err = net.validate_url_target(url) + assert ok, f"unexpectedly blocked: {err}" + + @pytest.mark.parametrize( "url", [ @@ -115,6 +169,12 @@ def test_resolved_blocks_private_ip_literal() -> None: assert "private" in err +def test_resolved_blocks_v4_mapped_private() -> None: + ok, err = net.validate_resolved_url("http://[::ffff:10.0.0.1]/x") + assert not ok + assert "private" in err + + def test_resolved_blocks_private_via_dns(monkeypatch: pytest.MonkeyPatch) -> None: """Redirect target is a hostname that resolves to a private IP → block.""" _mock_resolve(monkeypatch, "10.0.0.1")