fix(core): classify IPv6 literals by parsed bytes in the private-range SSRF gate - #3502
fix(core): classify IPv6 literals by parsed bytes in the private-range SSRF gate#3502GabrielDrapor wants to merge 3 commits into
Conversation
…e SSRF gate isPrivateRangeHost matched hostname spellings, and WHATWG URL parsing canonicalizes them: '[::ffff:127.0.0.1]' reaches the gate as '[::ffff:7f00:1]', which matched nothing — so every IPv4-mapped spelling of a loopback or private destination sailed through the remote-provenance https check (fail open). The 'fe8' prefix check also covered only a quarter of fe80::/10, missing fe9x/feax/febx link-local spellings. The literal now parses to its 16 bytes (minimal RFC 4291 text parser) and classifies structurally: IPv4-mapped (::ffff/96) and NAT64 (64:ff9b::/96) embeddings classify by their embedded IPv4 — including 127/8, which lands on the PRIVATE side of the gate on purpose. isLoopbackHost deliberately does NOT learn the mapped spellings: its unknown-spelling default must stay 'not loopback' so cleartext is refused (fail closed), while this gate's must stay 'private' so the request is blocked (also fail closed) — the two defaults point in opposite directions, per review guidance on apache#2653. An unparseable bracketed literal is treated as private. Regressions: core unit coverage across spellings, the full fe80::/10 range, NAT64, global-IPv6 reachability, and garbage-in-brackets; plus transport-security end-to-end cases asserting mapped private/loopback https is refused under remote provenance while cleartext to the mapped loopback stays refused by the strict loopback predicate. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TMwYxgNEbz2RFmuK6AXGcj
Astro-Han
left a comment
There was a problem hiding this comment.
Thanks — moving this from spelling to parsed bytes is the right call, and the 16-byte normalization handles the cases that string matching kept getting wrong: compressed zeros, ::ffff: mapped, NAT64, the full fe80::/10 rather than just the fe8 prefix, and garbage input. We confirmed the old string path is gone rather than left as a fallback.
Reviewed at exact head 109cbfca25883b6b079b1eea7d8a0158eb592e4b against base 56d03b4eab1e5b1980ca93775a9ae2e926cb61c9. One P2, inline. No checks have run on this head yet.
The gap is one prefix: classifyIpv6 special-cases ::ffff:/96 and 64:ff9b::/96 but not IPv4-compatible ::/96, so an embedded IPv4 written that way falls through to global. Details and a reproduction are inline.
Two things we checked and are not reporting: the parser strips %zone before classification so fe80::1%eth0 still lands on private, and WHATWG URL currently rejects URI zone-ids anyway; and a spelled-out loopback like ::0:1 normalizes to [::1] and is caught by the existing loopback check.
This review was AI-assisted. Findings were verified against the exact head listed above; any mistakes are ours to correct — please push back where we got it wrong.
| return bytes; | ||
| } | ||
|
|
||
| function classifyIpv6(bytes: Uint8Array): 'loopback' | 'private' | 'global' { |
There was a problem hiding this comment.
[P2] IPv4-compatible ::/96 is not classified by its embedded IPv4.
classifyIpv6 recognizes two ways of embedding an IPv4 address — ::ffff:/96 and 64:ff9b::/96 — and hands both to isPrivateIpv4. It does not recognize the third, ::/96, so those addresses reach the final return 'global'.
Reproduction, using the URL parser this gate sits behind:
new URL('https://[::192.168.1.1]/').hostname // '[::c0a8:101]'
new URL('https://[::127.0.0.1]/').hostname // '[::7f00:1]'Trace [::7f00:1] through the function: bytes 0–11 are zero so mapped is false (byte 10 is not 0xff) and nat64 is false (byte 1 is not 0x64); the loopback check requires bytes 0–14 to be zero and byte 12 is 0x7f; fc00::/7 and fe80::/10 both test byte 0, which is zero. Result: global. isPrivateRangeHost('[::7f00:1]') returns false, so the remote-HTTPS branch in transport-security.ts:59-75 does not reject it, and a remote server's metadata or a redirect can still hand back a target spelled this way.
On severity. We tried to connect to ::127.0.0.1 on macOS and it times out rather than reaching the loopback listener, so on a current stack this is unlikely to be an exploitable path to a live service — IPv4-compatible addresses are deprecated by RFC 4291 and most stacks no longer translate them. That is why this is a P2 and not a P1. But it should still be closed, for a reason that does not depend on reachability: this PR's whole premise is that classification happens on parsed bytes rather than on how an address is written, and ::/96 is the one byte-level IPv4 embedding the classifier does not know about. Leaving it means the gate's correctness rests on an assumption about the other end's network stack, which is exactly the kind of assumption this change set out to remove.
Smallest fix: treat ::/96 alongside the other two — bytes 0–11 all zero, excluding :: and ::1 which the surrounding checks already own — and reuse the same isPrivateIpv4 / 127. logic. Worth a regression case that comes in through the remote-provenance path rather than only calling the classifier directly, since that is where a real one would arrive.
Review follow-up on apache#3502: classifyIpv6 knew two byte-level IPv4 embeddings (::ffff/96, 64:ff9b::/96) but not the deprecated IPv4-compatible ::/96 (RFC 4291 §2.5.5.1) — '[::192.168.1.1]' canonicalizes to '[::c0a8:101]' and fell through to 'global'. The classifier's premise is bytes over spellings, and whether the other end's stack still translates the deprecated form must not be what the gate's correctness rests on. ::/96 now classifies by its embedded IPv4 like the other two. '::1' is returned by the loopback check first; the all-zero '::' (embedded 0.0.0.0) is treated as private — connecting to the unspecified address reaches the LOCAL machine on common stacks, so it fails closed. Public v4-compatible embeddings (e.g. ::8.8.8.8) stay global. Regressions: core unit cases across the URL round-trip and raw canonical forms, plus transport-security cases arriving through the remote-provenance path. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TMwYxgNEbz2RFmuK6AXGcj
|
Fixed at head One deliberate extension beyond the smallest fix, flagged for push-back: the all-zero Regressions added in both places you suggested: core unit cases across the URL round-trip ( |
…SSRF gate
Independent adversarial review (Codex) against the previous head found
three gaps, all fixed here:
- The mapped unspecified address ([::ffff:0:0]) classified as global;
connecting to ::ffff:0.0.0.0 verifiably reaches a 127.0.0.1-bound
listener. The embedded-0.0.0.0 → private rule now applies under EVERY
embedding, not only IPv4-compatible.
- RFC 8215's local-use NAT64 space (64:ff9b:1::/48) and RFC 2765 SIIT
(::ffff:0:0/96) were unclassified. The /48 is reserved for in-network
translation and deployments carve arbitrary RFC 6052 prefix lengths out
of it, so the whole prefix fails closed; SIIT classifies by its
embedded IPv4 like the other exact /96 prefixes.
- The parser accepted dotted IPv4 outside the low-order 32 bits
('[192.168.1.1::]' parsed shifted instead of failing) and an empty
zone id — both violated RFC 4291 and the documented fail-closed
contract. WHATWG URL parsing rejects these today, so the transport path
was covered, but the exported classifier now honors its own contract.
Regressions in both suites, including transport-security cases through
the remote-provenance path.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TMwYxgNEbz2RFmuK6AXGcj
|
Follow-up at head
Regressions for all three in both suites, including transport-security cases through the remote-provenance path ( |
Follow-up to @Astro-Han's review note on #2653 about the
isLoopbackHost/isPrivateRangeHostIPv6 blind spot, now that the code lives on main via #2920's squash.The gap
isPrivateRangeHostmatched hostname spellings — and WHATWG URL parsing canonicalizes them before the gate ever sees one:[::ffff:127.0.0.1]arrives as[::ffff:7f00:1], which matched nothing, so every IPv4-mapped spelling of a loopback or private destination passed the remote-provenance https check (fail open, exactly the direction the note called out). The'fe8'prefix check also covered only a quarter of fe80::/10 —fe9x/feax/febxlink-local spellings slipped through.The fix — with the opposite-defaults treatment from the review
The literal now parses to its 16 bytes (a minimal RFC 4291 text parser, no resolver) and classifies structurally:
::ffff/96) and NAT64 (64:ff9b::/96) embeddings classify by their embedded IPv4 — including 127/8, which lands on the PRIVATE side of the gate on purpose;isLoopbackHostdeliberately does not learn the mapped spellings: its unknown-spelling default stays "not loopback" so cleartext stays refused (fail closed), while this gate's default stays "private" so the request is blocked (also fail closed) — the two defaults point in opposite directions, as the note prescribed;[2606:4700::1], mapped8.8.8.8) — real OAuth endpoints on IPv6 keep working.(The #2920 loopback listener is unaffected, as the note observed — it pins
Hostto the exact127.0.0.1:<port>literal.)Regressions
packages/core: unit coverage across spellings of one address (raw, uppercase, uncompressed, canonical), the full fe80::/10 range, NAT64,[::1]/loopback asymmetry, global reachability, and garbage-in-brackets failing closed.packages/mcptransport-security: end-to-end — mapped private/loopback https refused under remote provenance; public IPv6 allowed; cleartext to the mapped loopback still refused by the strict loopback predicate.Suites: core 608, mcp 171 — green; biome + ASF header audit clean.
Co-Authored-By: Claude noreply@anthropic.com
https://claude.ai/code/session_01TMwYxgNEbz2RFmuK6AXGcj