mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-13 18:32:20 +02:00
fix(url-safety): resolve NAT64 well-known prefix to IPv4 target
The R09 scholarly hardening routes every hop through check_outbound_url with block_private=True. On a DNS64/NAT64 network, an IPv4-only host can resolve through the RFC 6052 Well-Known Prefix. For example, export.arxiv.org resolved to 64:ff9b::924b:5b2a in the reproduced environment. CPython classifies that outer IPv6 prefix as reserved, so the URL guard rejected the request before examining the effective IPv4 destination. Decode addresses in exactly 64:ff9b::/96 to their embedded IPv4 destination and evaluate that destination under the strict outbound policy. The translated target is always checked with private-address blocking enabled. This prevents the NAT64 prefix from becoming a path to loopback, private, shared/CGNAT, link-local, multicast, unspecified, or other non-global IPv4 space. Network-specific translation prefixes are not decoded. In particular, 64:ff9b:1::/48 remains subject to the existing IPv6 policy. Coverage includes: - public IPv4 destinations embedded through the RFC 6052 prefix - loopback, link-local, private, CGNAT, multicast, unspecified, benchmark, and TEST-NET rejection - network-specific NAT64 prefixes remaining undecoded - deterministic scholarly provider tests without live DNS dependence - redirect query parameter isolation - relative redirect resolution - HTTP error fallback behavior - editor draft GET and DELETE behavior remaining unaffected R09, R11, and R01 were independently audited and otherwise left unchanged.
This commit is contained in:
@@ -115,3 +115,146 @@ def test_resolver_skips_invalid_values_but_accepts_public_ip():
|
||||
|
||||
assert ok is True
|
||||
assert reason == "ok"
|
||||
|
||||
|
||||
# --- RFC 6052 Well-Known Prefix (DNS64 / NAT64) ---------------------------
|
||||
#
|
||||
# On a DNS64/NAT64 network an IPv4-only host resolves to 64:ff9b::<v4>. The
|
||||
# effective destination is the embedded IPv4 address, so that is what the
|
||||
# policy must judge. RFC 6052 §3.1 permits the Well-Known Prefix to represent
|
||||
# only globally-routable IPv4, so the embedded target is always held to the
|
||||
# strict policy — the prefix must never tunnel past the SSRF guard.
|
||||
|
||||
|
||||
def _nat64(v4: str) -> str:
|
||||
"""Render the RFC 6052 Well-Known-Prefix form of an IPv4 address."""
|
||||
import ipaddress
|
||||
|
||||
packed = int(ipaddress.IPv4Address(v4))
|
||||
return str(ipaddress.IPv6Address(int(ipaddress.IPv6Address("64:ff9b::")) | packed))
|
||||
|
||||
|
||||
def test_nat64_well_known_prefix_allows_public_ipv4_destination():
|
||||
# The reproduced failure: export.arxiv.org resolves to 64:ff9b::924b:5b2a
|
||||
# under DNS64. The embedded 146.75.91.42 is public, so the URL is allowed.
|
||||
res = _resolver({"export.arxiv.org": [_nat64("146.75.91.42")]})
|
||||
ok, reason = check_outbound_url("https://export.arxiv.org/api/query", resolver=res)
|
||||
assert ok is True, reason
|
||||
# ...including under full lockdown, where scholarly lookups actually run.
|
||||
ok, reason = check_outbound_url(
|
||||
"https://export.arxiv.org/api/query", block_private=True, resolver=res
|
||||
)
|
||||
assert ok is True, reason
|
||||
|
||||
|
||||
def test_nat64_well_known_prefix_blocks_embedded_loopback():
|
||||
res = _resolver({"evil.example": [_nat64("127.0.0.1")]})
|
||||
for strict in (False, True):
|
||||
ok, reason = check_outbound_url(
|
||||
"http://evil.example/", block_private=strict, resolver=res
|
||||
)
|
||||
assert ok is False, strict
|
||||
assert "NAT64" in reason and "127.0.0.1" in reason
|
||||
|
||||
|
||||
def test_nat64_well_known_prefix_blocks_embedded_metadata_address():
|
||||
# The headline SSRF vector must not become reachable through DNS64.
|
||||
res = _resolver({"evil.example": [_nat64("169.254.169.254")]})
|
||||
for strict in (False, True):
|
||||
ok, reason = check_outbound_url(
|
||||
"http://evil.example/latest/meta-data/", block_private=strict, resolver=res
|
||||
)
|
||||
assert ok is False, strict
|
||||
assert "link-local" in reason and "169.254.169.254" in reason
|
||||
|
||||
|
||||
def test_nat64_well_known_prefix_blocks_embedded_private_ipv4_under_strict():
|
||||
res = _resolver({"evil.example": [_nat64("192.168.1.50")]})
|
||||
ok, reason = check_outbound_url(
|
||||
"http://evil.example/", block_private=True, resolver=res
|
||||
)
|
||||
assert ok is False
|
||||
assert "NAT64" in reason and "192.168.1.50" in reason
|
||||
|
||||
|
||||
def test_nat64_well_known_prefix_blocks_embedded_shared_space_under_strict():
|
||||
# RFC 6598 shared/CGNAT space is not globally routable, so the Well-Known
|
||||
# Prefix may not represent it.
|
||||
res = _resolver({"evil.example": [_nat64("100.64.0.1")]})
|
||||
ok, reason = check_outbound_url(
|
||||
"http://evil.example/", block_private=True, resolver=res
|
||||
)
|
||||
assert ok is False
|
||||
assert "NAT64" in reason and "100.64.0.1" in reason
|
||||
|
||||
|
||||
def test_nat64_well_known_prefix_blocks_embedded_non_global_ipv4():
|
||||
# Multicast, unspecified and other non-global space must not be reachable
|
||||
# through the Well-Known Prefix, whatever block_private says.
|
||||
for v4 in ("224.0.0.1", "0.0.0.0", "198.18.0.1", "192.0.2.10", "240.0.0.1"):
|
||||
res = _resolver({"evil.example": [_nat64(v4)]})
|
||||
for strict in (False, True):
|
||||
ok, reason = check_outbound_url(
|
||||
"http://evil.example/", block_private=strict, resolver=res
|
||||
)
|
||||
assert ok is False, (v4, strict)
|
||||
assert v4 in reason, (v4, reason)
|
||||
|
||||
|
||||
def test_network_specific_nat64_prefixes_are_not_decoded():
|
||||
# RFC 8215 64:ff9b:1::/48 and arbitrary network-specific prefixes carry
|
||||
# locally assigned semantics, so no embedded IPv4 may be inferred from them.
|
||||
# 64:ff9b:1::8ef:5b2a would decode to the *public* 8.239.91.42; it stays
|
||||
# rejected on the outer IPv6 address, which proves no decoding happened.
|
||||
for addr in ("64:ff9b:1::8ef:5b2a", "64:ff9b:1::7f00:1"):
|
||||
res = _resolver({"svc.example": [addr]})
|
||||
ok, reason = check_outbound_url("http://svc.example/", resolver=res)
|
||||
assert ok is False, addr
|
||||
assert "NAT64" not in reason, addr
|
||||
assert addr in reason, addr
|
||||
|
||||
# A documentation-range prefix is judged as an ordinary IPv6 address under
|
||||
# the existing policy (local-first by default, blocked under lockdown) and
|
||||
# is never reinterpreted as carrying an IPv4 destination.
|
||||
res = _resolver({"svc.example": ["2001:db8:1::8ef:5b2a"]})
|
||||
ok, reason = check_outbound_url("http://svc.example/", resolver=res)
|
||||
assert ok is True, reason
|
||||
ok, reason = check_outbound_url(
|
||||
"http://svc.example/", block_private=True, resolver=res
|
||||
)
|
||||
assert ok is False
|
||||
assert "NAT64" not in reason and "8.239.91.42" not in reason
|
||||
|
||||
|
||||
def test_ordinary_ipv6_behaviour_is_unchanged():
|
||||
# A global unicast IPv6 host is allowed in both modes.
|
||||
GLOBAL6 = _resolver({"v6.example": ["2606:2800:220:1:248:1893:25c8:1946"]})
|
||||
assert check_outbound_url("https://v6.example/", resolver=GLOBAL6)[0] is True
|
||||
assert (
|
||||
check_outbound_url("https://v6.example/", block_private=True, resolver=GLOBAL6)[0]
|
||||
is True
|
||||
)
|
||||
|
||||
# ULA is local-first allowed by default, blocked under lockdown.
|
||||
ULA = _resolver({"ula.example": ["fd00::1"]})
|
||||
assert check_outbound_url("http://ula.example/", resolver=ULA)[0] is True
|
||||
ok, reason = check_outbound_url(
|
||||
"http://ula.example/", block_private=True, resolver=ULA
|
||||
)
|
||||
assert ok is False and "private" in reason
|
||||
|
||||
# fe80::/10 is always blocked.
|
||||
LL6 = _resolver({"ll.example": ["fe80::1"]})
|
||||
ok, reason = check_outbound_url("http://ll.example/", resolver=LL6)
|
||||
assert ok is False and "link-local" in reason
|
||||
|
||||
# Pre-existing behaviour, unchanged here: CPython reports ::1 as
|
||||
# is_reserved, so IPv6 loopback is rejected in both modes (unlike 127.0.0.1,
|
||||
# which the local-first default allows).
|
||||
LOOP6 = _resolver({"loop6.example": ["::1"]})
|
||||
for strict in (False, True):
|
||||
ok, reason = check_outbound_url(
|
||||
"http://loop6.example/", block_private=strict, resolver=LOOP6
|
||||
)
|
||||
assert ok is False, strict
|
||||
assert "NAT64" not in reason
|
||||
|
||||
Reference in New Issue
Block a user