fix(url_safety): evaluate embedded IPv4 for NAT64 64:ff9b::/96 addresses - #38058
fix(url_safety): evaluate embedded IPv4 for NAT64 64:ff9b::/96 addresses#38058liuhao1024 wants to merge 1 commit into
Conversation
DNS64/NAT64-synthesized AAAA records in the 64:ff9b::/96 well-known prefix (RFC 6052) embed a public IPv4 address in the low 32 bits. Python marks the entire prefix as `is_reserved`, which caused `_is_blocked_ip()` to false-positive block normal public sites when the resolver returns synthesized AAAA records alongside A records. Add explicit handling analogous to the existing IPv4-mapped IPv6 path: decode the embedded IPv4 and run the standard IPv4 safety checks on it. This preserves SSRF protection (e.g. 64:ff9b::a9fe:a9fe → 169.254.169.254 is still blocked) while allowing public targets (e.g. 64:ff9b::6812:27e4 → 104.18.39.228 passes). Fixes NousResearch#38048
teknium1
left a comment
There was a problem hiding this comment.
Thanks for preserving the embedded-IPv4 checks rather than blanket-allowing the NAT64 prefix; that matches the related issue’s hardening requirement.
Problems
- The new branch only runs in
_is_blocked_ip(). On current main,is_safe_url()skips that helper whensecurity.allow_private_urlsis enabled (tools/url_safety.py:443). Its preceding always-blocked check only compares the raw NAT64 wrapper to_ALWAYS_BLOCKED_IPS/_ALWAYS_BLOCKED_NETWORKS(tools/url_safety.py:435-441), so a NAT64 wrapper around IMDS is not covered under that toggle. is_always_blocked_url()has the same raw-address-only sentinel checks attools/url_safety.py:329-370and is not changed here.
Suggested changes
- Reuse a NAT64 embedded-IPv4 helper for both ordinary and always-blocked policy checks.
- Add toggle-enabled and
is_always_blocked_url()regressions for NAT64-wrapped metadata/link-local targets; the existing suite establishes that metadata must remain blocked despite the toggle (tests/tools/test_url_safety.py:445-503).
Automated hermes-sweeper review.
| # itself is ``is_reserved`` in Python, but the embedded IPv4 target | ||
| # may be a perfectly public host. Evaluate the embedded IPv4 | ||
| # instead of blocking the NAT64 wrapper wholesale. | ||
| if isinstance(ip, ipaddress.IPv6Address) and ip in _NAT64_WKP: |
There was a problem hiding this comment.
This protects the ordinary _is_blocked_ip() path, but is_safe_url() skips that helper when security.allow_private_urls is enabled. Please also decode NAT64 before the always-blocked metadata checks so an embedded IMDS address remains blocked under the toggle.
|
The floor half is still open here. Suggest extracting the unwrap into a helper covering both |
IPv6-only containers resolve through DNS64, which answers A-only hostnames with a synthesized AAAA in the RFC 6052 well-known prefix, carrying the real IPv4 in the low 32 bits (64:ff9b::d4d9:2e0a = public 212.217.46.10). ipv4_mapped is None for these, and Python's ipaddress reports all of 64:ff9b::/96 as is_reserved, so _is_blocked_ip fell through to the generic IPv6 branch and blocked every DNS64-resolved host — an agent in such a container had no outbound web access at all. Add a shared _embedded_ipv4() helper covering both IPv6 wrappers a resolver can return (::ffff:x.x.x.x and 64:ff9b::x.x.x.x) and judge the embedded IPv4 by the full IPv4 ruleset, so 64:ff9b::7f00:1 (loopback) and 64:ff9b::c0a8:1 (private) stay blocked. Only the /96 well-known prefix is unwrapped; custom NAT64 prefixes place the IPv4 at a prefix-dependent offset and are left blocked rather than guessed at. Also route the always-blocked metadata floor through the same unwrap. The floor compared raw resolved IPs against sets holding only plain-IPv4 and ::ffff: forms, so 64:ff9b::a9fe:a9fe (169.254.169.254) matched nothing and was blocked only incidentally by is_reserved — which allow_private_urls skips. Unwrapping in _is_blocked_ip alone would turn that incidental block into a real gap. Upstream: reported as NousResearch#38048, partially fixed by PR NousResearch#38058 (in-flight, covers _is_blocked_ip only). Drop this carry once a version landing both halves is merged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
suggesting changes Security evidence:
Please normalize NAT64 (alongside the existing IPv4-mapped form) before every always-blocked decision and add opt-out-enabled preflight, floor, and connect-time regressions while retaining the public-NAT64 positive case. Not checked:
Signed: GPT-5.6-sol-xhigh in Codex |
What does this PR do?
Fixes a false-positive SSRF block in
tools.url_safety._is_blocked_ip()that prevents normal public websites from being fetched when the DNS resolver returns DNS64/NAT64-synthesized AAAA records in the64:ff9b::/96well-known prefix.Related Issue
Fixes #38048
Type of Change
Changes Made
tools/url_safety.py: Add_NAT64_WKPnetwork constant and NAT64 handling in_is_blocked_ip()— decode the embedded IPv4 from the low 32 bits and run standard IPv4 safety checks instead of blocking the entire prefix asis_reserved.tests/tools/test_url_safety.py: Add parametrized tests for NAT64 blocked IPs (private/metadata embedded targets) and allowed IPs (public embedded targets), plus integration tests foris_safe_urlwith NAT64 addresses.How to Test
pytest tests/tools/test_url_safety.py -v— all 126 tests should pass including the 12 new NAT64 tests64:ff9b::6812:27e4(embeds 104.18.39.228, a public Cloudflare IP) is allowed by_is_blocked_ip()64:ff9b::a9fe:a9fe(embeds 169.254.169.254, AWS metadata) is blocked by_is_blocked_ip()64:ff9b::0a00:0001(embeds 10.0.0.1, private) is blocked by_is_blocked_ip()Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/tools/test_url_safety.py -qand all 126 tests passDocumentation & Housekeeping
docs/, docstrings) — or N/Acli-config.yaml.exampleif I added/changed config keys — or N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — or N/ACode Intelligence
tools/url_safety.py:_is_blocked_ip(called byis_safe_url,is_always_blocked_url— SSRF protection entry points)198.18.0.0/15benchmark range handled by existingis_reservedcheck in PR fix(url_safety): exclude 198.18.0.0/15 benchmark range from SSRF private-IP blocking #35436