From ddccce7b836477a8d782290d059b816b6c1d41be Mon Sep 17 00:00:00 2001 From: Andrew MacPherson Date: Mon, 11 May 2026 17:50:34 -0400 Subject: [PATCH] =?UTF-8?q?Classify=20IPv4-Compatible=20IPv6=20(RFC4291=20?= =?UTF-8?q?=C2=A72.5.5.1)=20as=20ipv4Compat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The IPv4-Compatible IPv6 form (::/96, excluding :: and ::1) was being returned as `unicast` by IPv6.range(). This is a problem for the common pattern of using `range() !== 'unicast'` as an SSRF / address-policy safety check: hand-rolled blocklists upstream rely on the library to classify reserved ranges, and silently treating ::a.b.c.d as a public unicast address means a hostile actor can encode loopback (::7f00:1), RFC1918 (::a00:1), or cloud metadata (::a9fe:a9fe) as IPv4-Compatible IPv6 literals and slip past those checks. WHATWG URL parsers emit this form natively: `new URL('https://[::127.0.0.1]/').hostname` is `[::7f00:1]`, which currently parses to `range() === 'unicast'`. This change adds an `ipv4Compat` entry to IPv6.SpecialRanges. It is inserted after `unspecified` (::/128) and `loopback` (::1/128) so those more-specific labels are unaffected — subnetMatch returns the first hit in insertion order, and /128 entries always shadow the /96 entry for the two addresses they cover. The new range is also disjoint from `ipv4Mapped` (::ffff:0:0/96), `rfc6145` (::ffff:0:0:0/96), and `rfc6052` (64:ff9b::/96), so no existing classifications change. Note: IPv6.parse() rewrites dotted-quad literals like `::127.0.0.1` into the mapped form `::ffff:7f00:1` during parsing, so the new range is only reachable in practice via the pure-hex form (`::7f00:1`, `0:0:0:0:0:0:7f00:1`). That is also the form WHATWG URL parsers produce for IPv4-Compatible literals, which is where the security impact actually lives. Adds: - ipv4Compat entry in IPv6.SpecialRanges (lib/ipaddr.js) - ipv4Compat to the IPv6Range type union (lib/ipaddr.d.ts) - regression-guard assertions for ::, ::1, and several ::a.b.c.d variants (test/ipaddr.test.js) - Changes.md entry under Unreleased Co-authored-by: Cursor Committed-By-Agent: cursor --- Changes.md | 5 +++++ lib/ipaddr.d.ts | 2 +- lib/ipaddr.js | 7 +++++++ test/ipaddr.test.js | 17 +++++++++++++++++ 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/Changes.md b/Changes.md index e09ba5e..0b82ab3 100644 --- a/Changes.md +++ b/Changes.md @@ -1,3 +1,8 @@ +### Unreleased + +- classify IPv4-Compatible IPv6 addresses (RFC4291 §2.5.5.1, `::/96` excluding `::` and `::1`) as `ipv4Compat` instead of `unicast`, so consumers using `range() !== 'unicast'` as an SSRF safety check block `::7f00:1`, `::a9fe:a9fe`, etc. — the pure-hex form that WHATWG URL parsers (`new URL('https://[::127.0.0.1]/').hostname` → `[::7f00:1]`) emit for IPv4-Compatible literals. The more-specific `::/128` and `::1/128` ranges retain their existing `unspecified` and `loopback` classifications. + + ### 2.4.0 - 2026-05-03 - remove Bower support diff --git a/lib/ipaddr.d.ts b/lib/ipaddr.d.ts index ee02c02..d62aca8 100644 --- a/lib/ipaddr.d.ts +++ b/lib/ipaddr.d.ts @@ -1,7 +1,7 @@ declare module "ipaddr.js" { type IPvXRangeDefaults = 'unicast' | 'unspecified' | 'multicast' | 'linkLocal' | 'loopback' | 'reserved' | 'benchmarking' | 'amt'; type IPv4Range = IPvXRangeDefaults | 'broadcast' | 'carrierGradeNat' | 'private' | 'as112'; - type IPv6Range = IPvXRangeDefaults | 'uniqueLocal' | 'ipv4Mapped' | 'rfc6145' | 'rfc6052' | '6to4' | 'teredo' | 'as112v6' | 'orchid2' | 'droneRemoteIdProtocolEntityTags'; + type IPv6Range = IPvXRangeDefaults | 'uniqueLocal' | 'ipv4Mapped' | 'ipv4Compat' | 'rfc6145' | 'rfc6052' | '6to4' | 'teredo' | 'as112v6' | 'orchid2' | 'droneRemoteIdProtocolEntityTags'; interface RangeList { [name: string]: [T, number] | [T, number][]; diff --git a/lib/ipaddr.js b/lib/ipaddr.js index 3b1d449..c3a8e43 100644 --- a/lib/ipaddr.js +++ b/lib/ipaddr.js @@ -567,6 +567,13 @@ loopback: [new IPv6([0, 0, 0, 0, 0, 0, 0, 1]), 128], uniqueLocal: [new IPv6([0xfc00, 0, 0, 0, 0, 0, 0, 0]), 7], ipv4Mapped: [new IPv6([0, 0, 0, 0, 0, 0xffff, 0, 0]), 96], + // RFC4291 Section 2.5.5.1 ("IPv4-Compatible IPv6 Address"). Deprecated + // but still classified as a non-unicast range so consumers using + // range() !== 'unicast' as a safety check (e.g. SSRF filters) treat + // ::a.b.c.d the same way they treat ::ffff:a.b.c.d. Placed after + // unspecified (::/128) and loopback (::1/128) so those keep their + // more-specific classifications. + ipv4Compat: [new IPv6([0, 0, 0, 0, 0, 0, 0, 0]), 96], // RFC3879 deprecatedSiteLocal: [new IPv6([0xfec0, 0, 0, 0, 0, 0, 0, 0]), 10], // RFC6666 diff --git a/test/ipaddr.test.js b/test/ipaddr.test.js index c666522..15ca8bc 100644 --- a/test/ipaddr.test.js +++ b/test/ipaddr.test.js @@ -420,6 +420,23 @@ describe('ipaddr', () => { assert.equal(ipaddr.IPv6.parse('100::42').range(), 'discard'); assert.equal(ipaddr.IPv6.parse('fc00::').range(), 'uniqueLocal'); assert.equal(ipaddr.IPv6.parse('::ffff:192.168.1.10').range(), 'ipv4Mapped'); + // RFC4291 §2.5.5.1 IPv4-Compatible IPv6 addresses (::/96, excluding :: + // and ::1). Deprecated but must not be reported as plain unicast, since + // dual-stack hosts may route them to the embedded IPv4 destination. Note + // that IPv6.parse() rewrites dotted-quad forms ("::127.0.0.1") into the + // mapped form ("::ffff:7f00:1"); the realistic input shape for this + // range is therefore pure-hex, which is also what WHATWG URL parsers + // produce for the IPv4-compatible literal. + assert.equal(ipaddr.IPv6.parse('::7f00:1').range(), 'ipv4Compat'); + assert.equal(ipaddr.IPv6.parse('0:0:0:0:0:0:7f00:1').range(), 'ipv4Compat'); + assert.equal(ipaddr.IPv6.parse('::a00:1').range(), 'ipv4Compat'); + assert.equal(ipaddr.IPv6.parse('::a9fe:a9fe').range(), 'ipv4Compat'); + assert.equal(ipaddr.IPv6.parse('::808:808').range(), 'ipv4Compat'); + // The more-specific ::/128 and ::1/128 ranges keep their existing labels. + // (Asserted above; restated here as a regression guard against the new + // ::/96 entry shadowing them.) + assert.equal(ipaddr.IPv6.parse('::').range(), 'unspecified'); + assert.equal(ipaddr.IPv6.parse('::1').range(), 'loopback'); assert.equal(ipaddr.IPv6.parse('fec0::1234').range(), 'deprecatedSiteLocal'); assert.equal(ipaddr.IPv6.parse('::ffff:0:192.168.1.10').range(), 'rfc6145'); assert.equal(ipaddr.IPv6.parse('64:ff9b::1234').range(), 'rfc6052');