mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-15 19:32:21 +02:00
Rebuild PR C to ship the vendor/photocraft harness as a coherent unit (vendor implementation + vendor tests) instead of a per-file ahead/behind mix that produced broken imports and 6+ test failures. - Flip 36 harness impl/route/test files back to vendor (the per-file line-set policy misclassified user-rewritten files as 'behind'). - Flip core/models.py + src/tool_approval_scopes.py to vendor: vendor's harness (authority.py, turn_contract.py, clean_agent_preview.py) depends on vendor's older approval-scope API; upstream added CHAT_SESSION_APPROVAL_SIGNATURE_FIELD that vendor's core/models.py lacks. - Add src/shutdown.py (new vendor infra) required by routes/chat_routes.py. - Keep src/agent_tools/__init__.py at upstream (editor_tools registration lives in PR B). Upstream-only security divergence preserved in C_upstream_delta.patch and flagged for integration-time reconciliation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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
|