mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 23:12:22 +02:00
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.
261 lines
10 KiB
Python
261 lines
10 KiB
Python
"""Tests for outbound URL safety / SSRF hardening (src/url_safety.py).
|
|
|
|
A stub resolver is injected so the tests never touch real DNS.
|
|
"""
|
|
|
|
from src.url_safety import check_outbound_url
|
|
|
|
|
|
def _resolver(mapping):
|
|
def resolve(host):
|
|
if host in mapping:
|
|
return mapping[host]
|
|
raise OSError(f"unresolvable: {host}")
|
|
return resolve
|
|
|
|
|
|
PUBLIC = _resolver({"example.com": ["93.184.216.34"]})
|
|
LOOPBACK = _resolver({"localhost": ["127.0.0.1"]})
|
|
LAN = _resolver({"nas.local": ["192.168.1.50"]})
|
|
METADATA = _resolver({"evil.example": ["169.254.169.254"]})
|
|
MAPPED_METADATA = _resolver({"evil6.example": ["::ffff:169.254.169.254"]})
|
|
|
|
|
|
def test_non_http_scheme_blocked():
|
|
for url in ("file:///etc/passwd", "ftp://x/y", "gopher://h", "redis://h:6379"):
|
|
ok, reason = check_outbound_url(url, resolver=PUBLIC)
|
|
assert ok is False, url
|
|
assert "scheme" in reason
|
|
|
|
|
|
def test_missing_host_or_empty_blocked():
|
|
assert check_outbound_url("", resolver=PUBLIC)[0] is False
|
|
assert check_outbound_url("http://", resolver=PUBLIC)[0] is False
|
|
|
|
|
|
def test_public_url_allowed():
|
|
ok, reason = check_outbound_url("https://example.com/v1/embeddings", resolver=PUBLIC)
|
|
assert ok is True, reason
|
|
|
|
|
|
def test_cloud_metadata_blocked_even_when_private_allowed():
|
|
# The headline SSRF vector must be blocked regardless of block_private.
|
|
ok, reason = check_outbound_url("http://evil.example/latest/meta-data/", resolver=METADATA)
|
|
assert ok is False
|
|
assert "link-local" in reason
|
|
|
|
|
|
def test_ipv4_mapped_metadata_blocked():
|
|
ok, reason = check_outbound_url("http://evil6.example/", resolver=MAPPED_METADATA)
|
|
assert ok is False
|
|
assert "link-local" in reason
|
|
|
|
|
|
def test_loopback_and_lan_allowed_by_default_local_first():
|
|
# Local-first: a localhost / LAN embedding server is a legitimate target.
|
|
assert check_outbound_url("http://localhost:8080/v1", resolver=LOOPBACK)[0] is True
|
|
assert check_outbound_url("http://nas.local:1234/v1", resolver=LAN)[0] is True
|
|
|
|
|
|
def test_strict_mode_blocks_private_and_loopback():
|
|
ok, reason = check_outbound_url("http://localhost:8080", block_private=True, resolver=LOOPBACK)
|
|
assert ok is False and "private" in reason
|
|
ok, reason = check_outbound_url("http://nas.local", block_private=True, resolver=LAN)
|
|
assert ok is False and "private" in reason
|
|
|
|
|
|
def test_strict_mode_blocks_cgnat_shared_space():
|
|
# RFC 6598 shared/CGNAT space (100.64.0.0/10) is not globally routable.
|
|
# A public redirect into it must be rejected under full SSRF lockdown,
|
|
# even though ipaddress reports is_private=False for this range.
|
|
CGNAT = _resolver({"svc.example": ["100.64.0.1"]})
|
|
ok, reason = check_outbound_url("http://svc.example:8080", block_private=True, resolver=CGNAT)
|
|
assert ok is False
|
|
assert "blocked" in reason
|
|
|
|
|
|
def test_strict_mode_blocks_non_global_ranges():
|
|
# Strict mode is a full SSRF lockdown: only globally-routable public
|
|
# addresses may be reached. Benchmarking (198.18.0.0/15) and TEST-NET
|
|
# documentation space (192.0.2.0/24) are not globally routable.
|
|
for ip in ("198.18.0.1", "192.0.2.10"):
|
|
res = _resolver({"svc.example": [ip]})
|
|
ok, reason = check_outbound_url("http://svc.example", block_private=True, resolver=res)
|
|
assert ok is False, ip
|
|
assert "blocked" in reason
|
|
|
|
|
|
def test_strict_mode_still_allows_public_ip():
|
|
# The lockdown must not reject a legitimate globally-routable target.
|
|
ok, reason = check_outbound_url("https://example.com/v1", block_private=True, resolver=PUBLIC)
|
|
assert ok is True, reason
|
|
|
|
|
|
def test_unresolvable_host_blocked():
|
|
ok, reason = check_outbound_url("http://does-not-resolve.invalid", resolver=PUBLIC)
|
|
assert ok is False
|
|
assert "resolve" in reason
|
|
|
|
|
|
def test_resolver_values_must_include_a_parseable_ip():
|
|
ok, reason = check_outbound_url(
|
|
"https://example.test",
|
|
resolver=lambda _host: [None, 123, "not-an-ip"],
|
|
)
|
|
|
|
assert ok is False
|
|
assert "does not resolve to an IP" in reason
|
|
|
|
|
|
def test_resolver_skips_invalid_values_but_accepts_public_ip():
|
|
ok, reason = check_outbound_url(
|
|
"https://example.test",
|
|
resolver=lambda _host: [None, "not-an-ip", "93.184.216.34"],
|
|
)
|
|
|
|
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
|