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
24 changes: 24 additions & 0 deletions raven/security/network.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
60 changes: 60 additions & 0 deletions tests/test_security_network.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
[
Expand Down Expand Up @@ -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")
Expand Down
Loading