From 11ad4e273958f8c3eb0168cf6665b6e6a60f730d Mon Sep 17 00:00:00 2001
From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
Date: Wed, 23 Sep 2026 15:00:24 +0100
Subject: [PATCH] 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.
---
src/url_safety.py | 48 +++++++
tests/test_review_20260923_fixes.py | 122 ++++++++++++++++
tests/test_service_search_provider_guards.py | 29 ++++
tests/test_url_safety.py | 143 +++++++++++++++++++
4 files changed, 342 insertions(+)
diff --git a/src/url_safety.py b/src/url_safety.py
index c8bf86327..e0f66cfeb 100644
--- a/src/url_safety.py
+++ b/src/url_safety.py
@@ -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:
diff --git a/tests/test_review_20260923_fixes.py b/tests/test_review_20260923_fixes.py
index 9c1256c25..6d6cb1200 100644
--- a/tests/test_review_20260923_fixes.py
+++ b/tests/test_review_20260923_fixes.py
@@ -187,6 +187,79 @@ def test_redirect_limit_is_bounded(monkeypatch):
assert len(called_urls) == search_core.MAX_SCHOLARLY_REDIRECTS + 1
+def test_redirect_does_not_replay_query_parameters_to_the_new_destination(monkeypatch):
+ """The original query must not be re-sent to a redirect target.
+
+ The params belong to the endpoint that was asked for. Replaying them across
+ a redirect would hand the search terms to whatever host the redirect names.
+ """
+ calls = []
+
+ def fake_get(url, **kwargs):
+ params = kwargs.get("params")
+ calls.append((url, params))
+ # Mirror real httpx: response.url carries the query that was sent.
+ req = httpx.Request("GET", httpx.URL(url, params=params or {}))
+ if len(calls) == 1:
+ return httpx.Response(
+ 302, headers={"Location": "https://redirect.openalex.test/v2"}, request=req
+ )
+ return httpx.Response(200, json={"results": []}, request=req)
+
+ monkeypatch.setattr(httpx, "get", fake_get)
+ monkeypatch.setattr("src.url_safety.check_outbound_url", lambda url, **kw: (True, ""))
+
+ result = search_core._scholarly_api_get(
+ OPENALEX_API_URL, {"search": "confidential-title"}
+ )
+
+ assert result is not None
+ assert len(calls) == 2
+ second_url, second_params = calls[1]
+ assert second_url == "https://redirect.openalex.test/v2"
+ assert second_params is None
+ assert "confidential-title" not in second_url
+
+
+def test_relative_redirect_resolves_against_the_responding_url(monkeypatch):
+ """A relative Location resolves against the URL that answered, not the origin."""
+ calls = []
+
+ def fake_get(url, **kwargs):
+ calls.append(url)
+ req = httpx.Request("GET", httpx.URL(url, params=kwargs.get("params") or {}))
+ if len(calls) == 1:
+ return httpx.Response(
+ 302, headers={"Location": "https://mirror.arxiv.test/api/v1/query"}, request=req
+ )
+ if len(calls) == 2:
+ # Relative hop: must resolve against mirror.arxiv.test, not arxiv.
+ return httpx.Response(302, headers={"Location": "../v2/query"}, request=req)
+ return httpx.Response(200, text="", request=req)
+
+ monkeypatch.setattr(httpx, "get", fake_get)
+ monkeypatch.setattr("src.url_safety.check_outbound_url", lambda url, **kw: (True, ""))
+
+ result = search_core._scholarly_api_get(ARXIV_API_URL, {"search_query": "x"})
+
+ assert result is not None
+ assert calls[2] == "https://mirror.arxiv.test/api/v2/query"
+
+
+def test_http_error_status_degrades_to_no_results(monkeypatch):
+ """A 500 from a scholarly API must degrade to no results, not raise."""
+
+ def fake_get(url, **kwargs):
+ req = httpx.Request("GET", url)
+ return httpx.Response(500, text="upstream exploded", request=req)
+
+ monkeypatch.setattr(httpx, "get", fake_get)
+ monkeypatch.setattr("src.url_safety.check_outbound_url", lambda url, **kw: (True, ""))
+
+ assert search_core._arxiv_title_results("Attention Is All You Need") == []
+ assert search_core._openalex_title_results("Attention Is All You Need") == []
+
+
def test_exhausted_budget_skips_the_request(monkeypatch):
"""Once the chain's budget is spent, later hops are skipped, not retried."""
calls = []
@@ -398,3 +471,52 @@ def test_ordinary_draft_is_unaffected():
from routes.editor_draft_routes import _dump_payload
assert _dump_payload({"layers": []}) == '{"layers":[]}'
+
+
+def test_route_class_guard_applies_only_to_body_bearing_methods():
+ """The route class must not change GET/DELETE semantics.
+
+ ``EditorDraftRoute`` is attached to the whole editor-draft router, so the
+ bodyless routes run through it too. They must reach their handler
+ untouched — the early ceiling belongs to the methods that carry a draft.
+ """
+ from fastapi import APIRouter
+
+ from routes.editor_draft_routes import EditorDraftRoute
+
+ router = APIRouter(route_class=EditorDraftRoute)
+
+ @router.get("/probe")
+ async def _get_probe():
+ return {"reached": "get"}
+
+ @router.delete("/probe")
+ async def _delete_probe():
+ return {"reached": "delete"}
+
+ @router.post("/probe")
+ async def _post_probe():
+ return {"reached": "post"}
+
+ app = FastAPI()
+ app.include_router(router)
+ client = TestClient(app)
+
+ oversized = {
+ "content-type": "application/json",
+ "content-length": str(EDITOR_DRAFT_MAX_BYTES + 1),
+ }
+
+ # Bodyless methods are unaffected even when a bogus huge length is declared.
+ for method, expected in (("GET", "get"), ("DELETE", "delete")):
+ response = client.request(method, "/probe", content=b"{}", headers=oversized)
+ assert response.status_code == 200, (method, response.text)
+ assert response.json() == {"reached": expected}
+
+ # The same declaration on the body-bearing method is refused.
+ assert client.post("/probe", content=b"{}", headers=oversized).status_code == 413
+
+ # ...and an ordinary POST still reaches the handler.
+ ordinary = client.post("/probe", json={"layers": []})
+ assert ordinary.status_code == 200
+ assert ordinary.json() == {"reached": "post"}
diff --git a/tests/test_service_search_provider_guards.py b/tests/test_service_search_provider_guards.py
index 29b1b36c4..d7735ec32 100644
--- a/tests/test_service_search_provider_guards.py
+++ b/tests/test_service_search_provider_guards.py
@@ -4,6 +4,7 @@ The old src.search provider path aliases this module; these tests pin the
behavior at the single implementation point.
"""
+import ipaddress
import sys
import pytest
@@ -11,6 +12,34 @@ from services.search import core
from services.search import providers
+@pytest.fixture(autouse=True)
+def _deterministic_outbound_dns(monkeypatch):
+ """Keep the outbound-URL policy running, but take it off live DNS.
+
+ ``_scholarly_api_get`` validates each destination through
+ ``check_outbound_url`` before it reaches the mocked ``httpx`` transport, and
+ that check resolves the hostname. Without this stub these tests depend on
+ real DNS: on a DNS64/NAT64 network an IPv4-only host such as
+ export.arxiv.org resolves to ``64:ff9b::``, so the lookup is judged on a
+ synthesised address that has nothing to do with the title-resolution,
+ fallback, request-parameter and ordering behaviour asserted here.
+
+ Only name resolution is replaced. The policy itself still runs in full, and
+ a literal-IP host still resolves to itself, so a test that points at a
+ prohibited address is still genuinely rejected. This is deliberately not a
+ stand-in for SSRF/NAT64 coverage, which lives in tests/test_url_safety.py
+ and tests/test_review_20260923_fixes.py.
+ """
+
+ def _resolve(host: str):
+ try:
+ return [str(ipaddress.ip_address(host))]
+ except ValueError:
+ return ["93.184.216.34"] # public, policy-clean
+
+ monkeypatch.setattr("src.url_safety._default_resolver", _resolve)
+
+
def test_html_transport_fallback_preserves_query_constraints(monkeypatch):
seen = []
class Response:
diff --git a/tests/test_url_safety.py b/tests/test_url_safety.py
index d82d38878..efe0c7bb8 100644
--- a/tests/test_url_safety.py
+++ b/tests/test_url_safety.py
@@ -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::. 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