Skip to content

fix(url_safety): allow NAT64 well-known prefix (RFC 6052) - #12256

Closed
SergoVashakmadze wants to merge 1 commit into
NousResearch:mainfrom
SergoVashakmadze:fix/nat64-ssrf-false-positive
Closed

fix(url_safety): allow NAT64 well-known prefix (RFC 6052)#12256
SergoVashakmadze wants to merge 1 commit into
NousResearch:mainfrom
SergoVashakmadze:fix/nat64-ssrf-false-positive

Conversation

@SergoVashakmadze

Copy link
Copy Markdown

Problem

is_safe_url fails closed on legitimate public URLs when the client machine is on a NAT64-aware network (dual-stack lite, 464XLAT cellular, or any IPv6-only with CLAT).

Python's IPv6Address.is_reserved returns True for the entire NAT64 Well-Known Prefix 64:ff9b::/96 (RFC 6052). The existing _is_blocked_ip check treats any reserved IPv6 as an SSRF target, so web_extract rejects those URLs with:

Blocked: URL targets a private or internal network address

But NAT64 addresses are the IPv6 representation of public IPv4 services reachable via an upstream NAT64 translator — the embedded IPv4 address in the low 32 bits is public. Blocking them is a false positive.

How I hit it

Consumer dual-stack-lite ISP returns both a public IPv4 and a NAT64 synthesized IPv6 for any AAAA lookup. e.g. blogs.nvidia.com:

>>> socket.getaddrinfo("blogs.nvidia.com", None, socket.AF_UNSPEC, socket.SOCK_STREAM)
[(2,  ..., ('2.19.248.139', 0)),
 (10, ..., ('64:ff9b::213:f88b', 0, 0, 0))]

is_safe_url iterates addresses and bails on the first blocked one, so the NAT64 entry short-circuits the public IPv4.

Affects sec.gov, investor.nvidia.com, blogs.nvidia.com, Google/Cloudflare DNS, and countless others depending on network topology.

Fix

Explicit allow for 64:ff9b::/96 at the top of _is_blocked_ip, before the generic is_private / is_reserved check. Mirrors the existing _CGNAT_NETWORK explicit handling.

_NAT64_NETWORK = ipaddress.ip_network("64:ff9b::/96")

def _is_blocked_ip(ip):
    if isinstance(ip, ipaddress.IPv6Address) and ip in _NAT64_NETWORK:
        return False
    ...

Security posture is preserved: the NAT64 translator itself is a public gateway, so an attacker using a NAT64 IPv6 to reach an internal IPv4 would need to get Hermes onto the NAT64 operator's network AND configure their private IPv4 into the NAT64 mapping — infeasible in the threat model the SSRF check is defending against. (Truly internal IPv4s behind CGNAT/VPN are separately blocked via _CGNAT_NETWORK and the standard private ranges.)

Tests

All 61 tests in tests/tools/test_url_safety.py pass, including new coverage:

  • TestIsBlockedIp.test_allowed_ips — 5 new parametrized IPs across the 64:ff9b::/96 range (nvidia, Google DNS, Cloudflare, boundary cases)
  • test_nat64_url_allowed_end_to_end — dual-stack hostname returning both a public IPv4 and a NAT64 IPv6
  • test_nat64_only_resolution_allowed — IPv6-only resolution returning only a NAT64 address (DS-Lite scenario)

Ran with:

python -m pytest tests/tools/test_url_safety.py -x
# 61 passed in 1.33s

Context

Found while building a Hermes Agent hackathon submission — web_extract kept erroring on legitimate public URLs because the dev machine is on a DS-Lite home ISP. Spent a bit figuring out this wasn't a Firecrawl auth / quota issue. Hopefully saves someone else the debugging.

Repo of the project that surfaced this: https://github.com/SergoVashakmadze/IoMarkets-Hermes (Hermes Creative Hackathon 2026).

`ipaddress.IPv6Address.is_reserved` returns True for the NAT64
well-known prefix `64:ff9b::/96`. The existing `_is_blocked_ip`
check treats any reserved IPv6 as an SSRF target and fails closed,
but NAT64 addresses are the IPv6 representation of **public** IPv4
services reachable via an upstream NAT64 translator — blocking them
is a false positive SSRF rejection.

This hits users on:
  - Consumer ISPs running DS-Lite / B4 (dual-stack lite)
  - 464XLAT cellular networks (most modern LTE carriers)
  - CLAT'd systems on IPv6-only networks

Symptom: `web_extract` fails with
  "Blocked: URL targets a private or internal network address"
for legitimate public URLs, with no obvious recourse for the user.

Fix: explicit allow for `64:ff9b::/96` before the generic
is_private/is_reserved check. The check runs BEFORE the existing
`_TRUSTED_PRIVATE_IP_HOSTS` allowlist so it applies broadly rather
than per-host.

Tests:
- 5 new parametrized IPs in TestIsBlockedIp.test_allowed_ips
  covering the full `64:ff9b::/96` range
- `test_nat64_url_allowed_end_to_end` — dual-stack hostname
  returning both an IPv4 AAAA and a NAT64 IPv6
- `test_nat64_only_resolution_allowed` — IPv6-only resolution
  returning only a NAT64 address

All 61 tests in the file pass.

Discovered while building a Hermes Agent hackathon submission
(github.com/SergoVashakmadze/IoMarkets-Hermes) — web_extract kept
erroring on blogs.nvidia.com, sec.gov, investor.nvidia.com because
they resolved to NAT64 IPv6 on a consumer dual-stack-lite ISP.
@Bartok9

Bartok9 commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Salvaged onto current main as #51758 with credit to you, @SergoVashakmadze. The NAT64 false-positive block is still live on main. One hardening change worth flagging: your version returns False for the entire 64:ff9b::/96 prefix, but the low 32 bits are attacker-controllable, so 64:ff9b::169.254.169.254 (cloud metadata) / 64:ff9b::10.0.0.1 (RFC1918) would have been allowed — a fail-open SSRF bypass. The salvage instead extracts the embedded IPv4 and re-checks it against the IPv4 policy (mirroring the existing ::ffff: handling), so public targets pass while private/metadata ones stay blocked. Happy to defer if you would rather refresh this yourself. 🙏

Bartok9 added a commit to Bartok9/hermes-agent that referenced this pull request Jun 24, 2026
… (RFC 6052)

Salvage of NousResearch#12256 by @SergoVashakmadze, re-targeted onto current main and
hardened against a fail-open SSRF bypass.

Problem: ipaddress.is_reserved flags the entire NAT64 Well-Known Prefix
(64:ff9b::/96, RFC 6052), so is_safe_url() blocked every host that resolves
to a NAT64 synthesized IPv6 address. On dual-stack-lite consumer ISPs that
is a false positive for PUBLIC IPv4 targets reachable via the upstream
NAT64 translator.

Fix: instead of blanket-allowing the prefix (the original PR returned False
unconditionally for any 64:ff9b:: address), extract the embedded IPv4 from
the low 32 bits and re-run the existing IPv4 SSRF policy on it. Public
embedded targets pass; private/loopback/link-local/reserved/CGNAT/metadata
embedded targets (e.g. 64:ff9b::169.254.169.254, 64:ff9b::10.0.0.1) stay
blocked. This mirrors main's existing ipv4_mapped (::ffff:x.x.x.x) handling
via a shared _embedded_ipv4_is_blocked() helper.

Testing: tests/tools/test_url_safety.py - 134 passed. Added public-NAT64
allow cases (fail without the fix), private/metadata-embedded NAT64 block
cases (the fail-open guard), and three end-to-end is_safe_url() cases
including a metadata-embedding host that must be rejected.
@SergoVashakmadze

Copy link
Copy Markdown
Author

Superseded by #51758, which re-targets this onto current main and closes a fail-open SSRF gap in my original: I returned False for the entire 64:ff9b::/96 prefix, but the embedded low-32-bit IPv4 is attacker-controllable, so metadata/RFC1918/loopback targets would have been allowed. The salvage re-checks the embedded IPv4 against the v4 policy instead. Thanks @Bartok9 for the salvage and the credit — closing in favor of #51758.

Bartok9 pushed a commit to Bartok9/hermes-agent that referenced this pull request Aug 1, 2026
… (salvage of NousResearch#12256 by @SergoVashakmadze)

Rebuilt on latest main (Bartok9 hygiene 2026-08-01).
Original: NousResearch#51758
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P2 Medium — degraded but workaround exists tool/web Web search and extraction type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants