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:
Alexandre Teixeira
2026-09-23 15:32:04 +01:00
parent 0784cd2fed
commit 11ad4e2739
4 changed files with 342 additions and 0 deletions
+143
View File
@@ -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