mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 15:02: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:
@@ -16,6 +16,12 @@ break the primary use case. What it *always* rejects:
|
||||
|
||||
For exposed multi-tenant deployments, set ``EMBEDDING_BLOCK_PRIVATE_IPS=true`` to
|
||||
additionally reject all private and loopback targets (full SSRF lockdown).
|
||||
|
||||
On a DNS64/NAT64 network an IPv4-only host resolves to the RFC 6052 Well-Known
|
||||
Prefix ``64:ff9b::/96``. Such an address is decoded to the IPv4 destination the
|
||||
translator will actually contact, and that destination is then judged under the
|
||||
strict policy — so the prefix reaches public IPv4 but never tunnels to loopback,
|
||||
private, shared or link-local space.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
@@ -33,6 +39,39 @@ ALLOWED_SCHEMES = ("http", "https")
|
||||
# versions for other special ranges.
|
||||
_SHARED_ADDRESS_SPACE_V4 = ipaddress.ip_network("100.64.0.0/10")
|
||||
|
||||
# RFC 6052 §2.1 Well-Known Prefix for IPv4/IPv6 address translation (NAT64).
|
||||
# An address inside exactly this /96 is not a destination in its own right: the
|
||||
# low 32 bits carry the IPv4 address the translator will actually contact. On a
|
||||
# DNS64/NAT64 network every public IPv4-only host resolves this way, so judging
|
||||
# the outer IPv6 (which CPython reports as ``is_reserved``) would reject the
|
||||
# whole public internet while telling us nothing about the real target.
|
||||
#
|
||||
# RFC 6052 §3.1 allows the Well-Known Prefix to represent *only* globally
|
||||
# routable IPv4. The embedded destination is therefore always evaluated under
|
||||
# the strict policy, whatever ``block_private`` the caller passed: the prefix
|
||||
# must never become a path to loopback, private, shared, link-local, multicast,
|
||||
# unspecified or otherwise non-global space.
|
||||
#
|
||||
# Deliberately exact. Network-specific prefixes carry locally assigned meaning
|
||||
# and are NOT decoded here — notably 64:ff9b:1::/48 (RFC 8215), which this /96
|
||||
# membership test excludes and which stays rejected as reserved.
|
||||
_NAT64_WELL_KNOWN_PREFIX_V6 = ipaddress.ip_network("64:ff9b::/96")
|
||||
|
||||
|
||||
def _nat64_well_known_embedded_ipv4(
|
||||
ip: ipaddress._BaseAddress,
|
||||
) -> Optional[ipaddress.IPv4Address]:
|
||||
"""Return the IPv4 target embedded in an RFC 6052 Well-Known-Prefix address.
|
||||
|
||||
``None`` when ``ip`` is not inside ``64:ff9b::/96``, i.e. when no IPv4
|
||||
destination may be inferred from it.
|
||||
"""
|
||||
if not isinstance(ip, ipaddress.IPv6Address):
|
||||
return None
|
||||
if ip not in _NAT64_WELL_KNOWN_PREFIX_V6:
|
||||
return None
|
||||
return ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF)
|
||||
|
||||
|
||||
def _default_resolver(host: str) -> List[str]:
|
||||
"""Resolve a hostname to the list of IP strings it maps to (A + AAAA)."""
|
||||
@@ -44,6 +83,15 @@ def _classify(ip: ipaddress._BaseAddress, *, block_private: bool) -> Optional[st
|
||||
# IPv4-mapped IPv6 (e.g. ::ffff:169.254.169.254) — judge the embedded v4.
|
||||
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
||||
ip = ip.ipv4_mapped
|
||||
else:
|
||||
# RFC 6052 Well-Known Prefix — judge the IPv4 destination the NAT64
|
||||
# translator will contact, always under the strict policy.
|
||||
translated = _nat64_well_known_embedded_ipv4(ip)
|
||||
if translated is not None:
|
||||
reason = _classify(translated, block_private=True)
|
||||
if reason:
|
||||
return f"NAT64 translated destination blocked: {reason}"
|
||||
return None
|
||||
if ip.is_link_local:
|
||||
return f"link-local address blocked (SSRF metadata risk): {ip}"
|
||||
if ip.is_multicast or ip.is_reserved or ip.is_unspecified:
|
||||
|
||||
Reference in New Issue
Block a user