Summary
new Address6() and Address6.isValid() strip a zone identifier with the pattern /%.$/ before they apply the 45-character length limit. When the string holds many % characters followed by a line terminator, the pattern is retried from every % and each attempt scans to the line terminator before failing, so rejecting the string takes time proportional to the square of its length. 10,000 % characters and a newline take 41 ms, 40,000 take 632 ms and 100,000 take 3.9 s of synchronous work. The same strings without the newline are rejected in under a millisecond.
An application that validates an attacker-supplied string with these methods can be stalled by a single long request. The input is rejected with the correct AddressError; the cost of reaching that rejection is the defect.
Details
The constructor in src/ipv6.ts removes the CIDR suffix, then the zone, then checks the length of what is left:
ts const zone = constants6.REZONESTRING.exec(address);
if (zone) { this.zone = zone[0];
address = address.replace(constants6.REZONESTRING, ''); }
const longest = this.groups 5 + 5;
if (address.length > longest) { throw new AddressError(IPv6 addresses are at most ${longest} characters.); }
REZONESTRING is /%.$/. It has no anchor at its start, . does not match a line terminator (\n, \r, U+2028 or U+2029), and without the m flag $ matches only at the end of the input. For % repeated N times and then \n, the engine starts a match at the first %, . consumes the remaining % characters up to the newline, $ fails there, and backtracking through every shorter . fails too. The engine then starts again at the second %, and so on: N starts, each proportional to N. The length limit that follows rejects the string in constant time, after the scan has already run.
The line terminator need not be last. Any string in which many % characters precede a line terminator behaves the same way.
Every entry point that passes a caller's string to the Address6 constructor is affected: new Address6(), isValid(), both arguments of fromAddressAndMask() and fromAddressAndWildcardMask(), and the prefix argument of fromAddress4Nat64() and toAddress4Nat64(). fromWildcard() rejects a % before it constructs anything, fromURL() admits only hex digits, colons and dots in a host, and fromArpa() checks the nibble grammar first, so those three are unaffected. Address4 has no zone and is unaffected.
Affected versions
<= 10.7.3. The zone pattern has been in the constructor since 1.0.0; 5.9.4, 9.0.5 and 10.7.0 show the same timings as 10.7.3.
Impact
Address6.isValid() on N % characters and a newline, measured on ip-address 10.7.3 and node 24.21.0:
| Input | Wall time | Same length of ! | |---|---|---| | 5,461 characters | 13 ms | 0.1 ms | | 16 KiB | 108 ms | 0.1 ms | | 100 KB | 3.9 s | 0.1 ms | | 256 KiB | 27 s | 0.2 ms |
The parse is synchronous, so the event loop is blocked for the whole of the wall time and nothing else on that process is served. No memory is retained and the process resumes when the call returns.
Reachability
The input needs a line terminator after the % characters, which decides where it can arrive. An HTTP header value cannot carry a carriage return or a line feed, so a host or address taken from a header does not reach this. A URL can carry both characters percent-encoded at three bytes each, and Node's default 16 KB limit on the request line and headers admits about 5,461 of them: 13 ms. A request body is where the larger sizes come from. Common body parsers default to between 100 KB and 1 MB, and at 100 KB one field passed to the parser unchecked blocks the process for 3.9 s.
Proof of concept
npm i ip-address@10.7.3, then:
js const { Address6 } = require('ip-address');
function time(input) { const start = process.hrtime.bigint();
Address6.isValid(input);
return ${(Number(process.hrtime.bigint() - start) / 1e6).toFixed(1)} ms; }
for (const n of [10000, 20000, 40000, 100000]) { console.log(${n}: ${time(${'%'.repeat(n)}\n)}, without the newline ${time('%'.repeat(n))}); }
On affected versions (node 24.21.0):
10000: 41.0 ms, without the newline 0.0 ms 20000: 161.1 ms, without the newline 0.0 ms 40000: 632.4 ms, without the newline 0.1 ms 100000: 3899.8 ms, without the newline 0.1 ms
Each doubling of the input multiplies the time by four.
Remediation
Upgrade to the patched release. In the fix, the constructor finds the zone by the index of the first % and accepts the remainder as a zone only when it holds no line terminator, which reads the input once. 1 MiB of the input above is rejected in about a millisecond. Every address that parsed before parses to the same result, and a string with a line terminator after a % is rejected as it was.
REZONESTRING stays exported from v6/constants for code that imports it directly and is marked @deprecated; the library itself does not call it.
If you cannot upgrade immediately, reject a candidate longer than an address with a zone identifier can be before you parse it:
js if (host.length > 64) throw new Error('not an IP address');
Summary
Address6.isLinkLocal() recognizes fe80::/64 rather than fe80::/10. Link-local unicast is the whole /10 under RFC 4291 §2.4 and the IANA IPv6 Special-Purpose Address Registry, so the method returns false for every link-local address outside the one /64 that stateless address autoconfiguration happens to use. new Address6('fe81::1').isLinkLocal() is false.
The library contradicts itself on the same object: for fe81::1, getType() returns 'Link-local unicast', getScope() returns 'Link local', and isHostInSubnet(new Address6('fe80::/10')) returns true, while isLinkLocal() returns false.
An application that builds a network trust-boundary decision on these checks (for example a filter intended to block Server-Side Request Forgery, or SSRF) will classify a link-local target as unremarkable and allow the request. SSRF is an attack in which a user-supplied address coaxes the server into making a request to an internal destination the user could not otherwise reach.
Details
isLinkLocal() in src/ipv6.ts compares the first 64 bits of the address against a literal string:
ts // Zeroes are required, i.e. we can't check isHostInSubnet with 'fe80::/10' if ( this.getBitsBase2(0, 64) === '1111111010000000000000000000000000000000000000000000000000000000' ) { return true; }
The comparison requires the first 64 bits to be exactly fe80:0000:0000:0000, so it accepts 2⁶⁴ of the 2¹¹⁸ addresses in fe80::/10. The comment states a premise the library disproves: getType() classifies the same range with isHostInSubnet against the 'fe80::/10': 'Link-local unicast' entry in src/v6/constants.ts, and Address4.isLinkLocal() is a plain isHostInSubnet test against 169.254.0.0/16. RFC 4291 §2.5.6 constrains the format of an autoconfigured link-local address; it does not define the range, and reading it as the definition is the likeliest origin of the /64 comparison.
The IPv4-mapped and NAT64 well-known paths of isLinkLocal() are unaffected: ::ffff:169.254.169.254 and 64:ff9b::a9fe:a9fe are classified by their embedded IPv4 address and report true.
Affected versions
<= 10.5.0. The comparison has had this shape since Address6.isLinkLocal() was introduced, so every release exposing the method is affected.
Impact
Every well-formed address in fe80::/10 outside fe80::/64 is parsed successfully, isValid() is true, and the classifier reports something untrue about it. No other classifier catches these addresses: isPrivate() covers ULA (fc00::/7), not link-local.
| Address | isLinkLocal() | getType() | getScope() | |---|---|---|---| | fe80::1 | true | Link-local unicast | Link local | | fe81::1 | false | Link-local unicast | Link local | | fe8f::1 | false | Link-local unicast | Link local | | febf::1 | false | Link-local unicast | Link local | | fe80:0:0:1::1 | false | Link-local unicast | Link local | | fe80::1:0:0:0:1 | false | Link-local unicast | Link local |
Python's ipaddress module, the IN6ISADDRLINKLOCAL macro in netinet6/in6.h, and Linux's ipv6addrtype() all apply a ten-bit prefix test and classify every row above as link-local.
A request admitted through a guard built on isLinkLocal() reaches a link-local host on the server's own segment: a neighboring machine or the on-link router. An IPv6 link-local destination generally needs a zone index and a neighbor on the same link, so the reach is the server's own segment rather than the internet or a universal metadata endpoint, and the severity reflects that.
Proof of concept
npm i ip-address@10.5.0, then:
js const { Address6 } = require('ip-address');
// A guard of the shape the library documents. function isBlocked(host) { const a = new Address6(host); return a.isLoopback() || a.isLinkLocal() || a.isPrivate() || a.isMulticast() || a.isUnspecified(); }
for (const h of ['fe80::1', 'fe81::1', 'febf::1', 'fe80:0:0:1::1']) { const a = new Address6(h); console.log(isBlocked(h) ? 'BLOCK' : 'ALLOW', h, '-> getType()', a.getType()); }
On affected versions:
BLOCK fe80::1 -> getType() Link-local unicast ALLOW fe81::1 -> getType() Link-local unicast ALLOW febf::1 -> getType() Link-local unicast ALLOW fe80:0:0:1::1 -> getType() Link-local unicast
Remediation
Upgrade to the patched release. In the fix, isLinkLocal() tests the address against fe80::/10 with the same isHostInSubnet predicate getType() and Address4.isLinkLocal() use. The same release adds 2001::/32 to the type table so getType() reports 'Teredo' for the addresses isTeredo() returns true for; that is a consistency correction with no security effect.
If you cannot upgrade immediately, test the range directly:
js const LINKLOCAL = new Address6('fe80::/10'); const linkLocal = new Address6(host).isHostInSubnet(LINKLOCAL);
A note on SSRF defense
These methods are address classifiers, not a complete SSRF defense. Regardless of this fix, a robust SSRF guard must resolve the hostname and validate the resolved IP against the socket it connects to, and account for DNS rebinding and redirects. Treat these checks as one layer, not the only one.
Summary
isInSubnet() and isHostInSubnet() accept an address of either family and compare masked binary strings without checking that both operands are the same family. Address4 pads to 32 bits and Address6 to 128, so whenever the leading bits agree the strings are equal: new Address6('a00::1').isInSubnet(new Address4('10.0.0.0/8')) is true, and new Address4('32.0.0.1').isInSubnet(new Address6('2000::/3')) is true. No IPv4 address is inside an IPv6 network, so both answers are untrue.
An application that parses untrusted input as whichever family accepts it and then tests the result against a fixed-family allowlist can admit an address outside the list.
Details
Both methods are in src/common.ts:
ts export function isInSubnet(this: Address4 | Address6, address: Address4 | Address6) { if (this.subnetMask < address.subnetMask) { return false; }
return isHostInSubnet.call(this, address); }
export function isHostInSubnet(this: Address4 | Address6, address: Address4 | Address6) { return this.mask(address.subnetMask) === address.mask(); }
mask(n) returns the first n bits of the address as a string of 0 and 1, taken from a representation padded to the family's width. new Address6('a00::1').mask(8) is '00001010', and so is new Address4('10.0.0.0/8').mask(), so string equality reports containment. The signature admits either family on either side, so TypeScript raises nothing, and the v4 property on Address6 marks IPv4 notation (::ffff:10.0.0.1) rather than family, so it does not discriminate either.
Which cross-family pairs coincide depends on the network's prefix length. mask(n) on an Address4 returns at most 32 bits, so an IPv6 network longer than /32 never matches an IPv4 address, and an IPv6 network of /32 or shorter matches exactly the IPv4 addresses whose leading bits equal its prefix. An IPv4 network is at most 32 bits and matches every IPv6 address whose leading bits equal its prefix.
Affected versions
<= 10.7.0. The comparison has had this shape since the methods were written, so every release is affected.
Impact
| Expression | Result | Reason | |---|---|---| | new Address6('a00::1').isInSubnet(new Address4('10.0.0.0/8')) | true | both mask to 00001010 | | new Address4('10.0.0.1').isInSubnet(new Address6('a00::/8')) | true | the same bits, reversed | | new Address4('32.0.0.1').isInSubnet(new Address6('2000::/3')) | true | both begin 001 | | new Address4('32.1.13.184').isInSubnet(new Address6('2001:db8::/32')) | true | the /32 spells an IPv4 address | | new Address6('cb00:7100::1').isInSubnet(new Address4('203.0.113.0/24')) | true | the /24 spells an IPv6 prefix | | new Address4('10.0.0.1').isInSubnet(new Address6('::ffff:10.0.0.0/104')) | false | /104 is longer than 32 bits | | new Address4('8.8.8.8').isInSubnet(new Address4('10.0.0.0/8')) | false | same-family control |
In the allowlist direction the check admits an address outside the list; in the denylist direction it blocks an address outside the list. What the coincidence can admit is narrow. An IPv6 allowlist of /32 or shorter admits the IPv4 addresses its prefix spells: 2001:db8::/32 admits exactly 32.1.13.184, and 2000::/3 admits 32.0.0.0/3. Those are public IPv4 addresses; the private, loopback, and link-local ranges begin with bit patterns no allocated IPv6 prefix shares. An IPv4 allowlist admits the IPv6 addresses its prefix spells, and those all sit in blocks IANA has not allocated. So a request admitted through this defect reaches an address outside the intended list, not an internal host, and the severity reflects that.
Proof of concept
npm i ip-address@10.7.0, then:
js const { Address4, Address6 } = require('ip-address');
// An allowlist of the application's own IPv6 range, tested against whichever // family the input parses as. const allowed = new Address6('2001:db8::/32');
function parse(host) { return Address4.isValid(host) ? new Address4(host) : new Address6(host); }
for (const h of ['2001:db8::1', '2001:db9::1', '32.1.13.184', '32.1.13.185']) { console.log(parse(h).isInSubnet(allowed) ? 'ALLOW' : 'BLOCK', h); }
On affected versions:
ALLOW 2001:db8::1 BLOCK 2001:db9::1 ALLOW 32.1.13.184 BLOCK 32.1.13.185
The IPv6 rows are right. The IPv4 address whose 32 bits equal the allowlist's prefix is admitted; the one next to it is not.
Remediation
Upgrade to the patched release. In the fix, isHostInSubnet() returns false when the two addresses are of different families, and isInSubnet() inherits the answer. The methods keep accepting either family so existing call sites compile. A caller that means to compare across families converts first, with Address6.fromAddress4(), to4(), or toAddress4Nat64(): new Address6('::ffff:10.0.0.1').to4().isInSubnet(new Address4('10.0.0.0/8')) is true, as before.
If you cannot upgrade immediately, compare the classes before you compare the addresses:
js const contained = host.constructor === network.constructor && host.isInSubnet(network);
A note on SSRF defense
These methods are address classifiers, not a complete SSRF defense. Regardless of this fix, a robust SSRF guard must resolve the hostname and validate the resolved IP against the socket it connects to, and account for DNS rebinding and redirects. Treat these checks as one layer, not the only one.
Summary
new Address6() and Address6.isValid() place no bound on the length of the string they parse. When the string contains a character that cannot appear in an IPv6 address, the parser builds a diagnostic that wraps every such character in a 34-byte <span class="parse-error">, so the work and the memory scale with the input rather than with an address. A 1 MiB string of ! costs about 110 MB and 70 ms of synchronous work, 8 MiB costs about 800 MB and half a second, 16 MiB throws a RangeError in place of the documented AddressError, and 32 MiB aborts the Node process. isValid() builds the diagnostic and discards it, so a caller that only asks whether a string is valid pays the full price.
An application that validates an attacker-supplied string with these methods can be stalled or crashed by a single oversized request. This is a crash on input that should have been rejected cleanly, which SECURITY.md lists as in scope.
Details
parse() in src/ipv6.ts checks for characters outside [0-9a-f:/%] and, on finding any, throws an AddressError whose parseMessage is the whole input with each offending character wrapped:
ts const badCharacters = address.match(constants6.REBADCHARACTERS);
if (badCharacters) { throw new AddressError( Bad character${badCharacters.length > 1 ? 's' : ''} detected in address: ${badCharacters.join('')}, address.replace(constants6.REBADCHARACTERS, '<span class="parse-error">$1</span>'), ); }
REBADCHARACTERS is /([^0-9a-f:/%])/gi. For an input made entirely of punctuation the match allocates one string per character, the join copies them all into the message, and the replace produces a string 34 times the input. Nothing before this point looks at the length: the constructor strips a CIDR suffix and a zone identifier and hands the rest to parse(). The REBADADDRESS branch below it builds its diagnostic the same way at about a third of the ratio, for input in the hex-and-colon alphabet such as fffff: repeated.
parse() is reached only through the constructor, so every entry point that constructs an Address6 from a string it has not already bounded is affected: new Address6(), isValid(), both arguments of fromAddressAndMask() and fromAddressAndWildcardMask(), fromWildcard(), and the prefix argument of fromAddress4Nat64() and toAddress4Nat64(). fromURL() admits only hex, colons, and dots in a host, so it reaches the REBADADDRESS branch and not the other. fromArpa() caps its input at 32 nibbles before constructing anything, and fromBigInt(), fromByteArray(), and fromAddress4() build the string themselves, so those are unaffected.
The replace is where size becomes fatal. V8 caps a string at 2^29 - 24 characters, so past 16 MiB of input the replace throws RangeError: Invalid string length rather than the AddressError callers catch; isValid() swallows it, but a constructor call guarded by instanceof AddressError does not. Past 32 MiB the replacement builder's internal array exceeds its maximum size and V8 aborts the process with Fatal JavaScript invalid size error, which no try/catch intercepts.
Affected versions
<= 10.7.0. The diagnostic has had this shape since the parser was written, so every release is affected.
Impact
Address6.isValid() on N bytes of !, measured on node 24.19.0:
| Input | Wall time | Transient heap | Outcome | |---|---|---|---| | 16 KiB | 1 ms | 1 MB | false | | 1 MiB | 73 ms | 112 MB | false | | 8 MiB | 464 ms | 784 MB | false | | 16 MiB | | | RangeError: Invalid string length from the constructor; isValid() returns false | | 32 MiB | | | the process aborts |
The parse is synchronous, so the event loop is blocked for the whole of the wall time and nothing else on that process is served. The heap is transient and is reclaimed after the call returns, so memory does not accumulate across requests; the abort at 32 MiB is a single request. Address4 has no diagnostic of this shape and parses the same 8 MiB in a few milliseconds.
Reachability
Reaching the sizes above requires that the application hand the parser a string it has not already bounded. A host taken from a URL or an HTTP header is bounded by the server's header limit (Node's default is 16 KB), and at that size the cost is a millisecond. The megabyte sizes need a request body the application accepts at that scale and passes through unchecked. Common body parsers default to between 100 KB and 1 MB, which caps the effect at a stall of under 100 ms per request, and the abort needs 32 MiB in a single field, which no default admits. The severity is scored for the stall, not the abort: an application that accepts 32 MiB bodies into an address parser is the exception, and the reader running one should treat this as a crash.
Proof of concept
npm i ip-address@10.7.0, then:
js const { Address6 } = require('ip-address');
for (const mib of [1, 8]) { const input = '!'.repeat(mib 1024 1024); const before = process.memoryUsage().heapUsed; const start = process.hrtime.bigint();
Address6.isValid(input);
const ms = Number(process.hrtime.bigint() - start) / 1e6; const mb = (process.memoryUsage().heapUsed - before) / 1048576;
console.log(${mib} MiB: ${ms.toFixed(0)} ms, ${mb.toFixed(0)} MB); }
try { new Address6('!'.repeat(16 1024 1024)); } catch (e) { console.log(16 MiB: ${e.name}: ${e.message}); }
new Address6('!'.repeat(32 1024 1024));
On affected versions (node 24.19.0):
1 MiB: 73 ms, 112 MB 8 MiB: 464 ms, 784 MB 16 MiB: RangeError: Invalid string length
Fatal error in , line 0 Fatal JavaScript invalid size error 142606336
The last line is V8 terminating the process; the script does not reach its end.
Remediation
Upgrade to the patched release. In the fix, the constructor rejects an address longer than the family allows before parse() runs: 45 characters for IPv6 once the CIDR suffix and zone identifier are stripped (six four-digit groups, six colons, and a dotted quad, the same line CPython's ipaddress module draws), and 15 characters for IPv4. The rejection is an AddressError with no parseMessage, so isValid() returns false for the cost of a length comparison and the 32 MiB input above is rejected in the same time as a 46-character one. A zone identifier is not counted, since it never reaches the diagnostic.
This rejects nothing a previous release accepted: every string longer than the limit already failed to parse.
If you cannot upgrade immediately, reject a candidate longer than an address with a zone identifier can be before you parse it:
js if (host.length > 64) throw new Error('not an IP address');
Summary
No classifier on Address6 recognizes the NAT64 local-use range 64:ff9b:1::/48 (RFC 8215). isPrivate(), isLoopback(), isLinkLocal() and their siblings all return false for every address in it, so an internal IPv4 destination written through a local-use NAT64 prefix (64:ff9b:1:7f00:0:100:: for 127.0.0.1, 64:ff9b:1:a9fe:a9:fe00:: for 169.254.169.254) reads as an ordinary global address. getType() names the range 'NAT64 (local-use)', so the library knows what the address is and classifies it as nothing.
An application that builds a network trust-boundary decision on these checks (for example a filter intended to block Server-Side Request Forgery, or SSRF) will classify an internal target as external and allow the request. SSRF is an attack in which a user-supplied address coaxes the server into making a request to an internal destination the user could not otherwise reach, such as a loopback service or a cloud metadata endpoint.
Details
The fix for GHSA-22jq-vg5j-6vgg (10.2.1) classifies IPv4-mapped (::ffff:0:0/96) and NAT64 well-known (64:ff9b::/96) addresses by their embedded IPv4 address: embeddedIPv4() in src/ipv6.ts decodes the trailing 32 bits and every special-use classifier delegates to the resulting Address4. Those are the only two ranges embeddedIPv4() handles.
The local-use range differs in kind from the well-known prefix, which is why extending the decoder does not fix it. RFC 8215 reserves 64:ff9b:1::/48 so an operator can carve their own NAT64 prefix out of it, of any length RFC 6052 allows (/48, /56, /64, or /96). Where the IPv4 address sits depends on that choice: 127.0.0.1 is 64:ff9b:1:7f00:0:100:: under a /48 prefix and 64:ff9b:1::7f00:1 under a /96 prefix, and each of those decodes to a different address under the other prefix length. There is no single embedded IPv4 address for the library to classify by.
What is well-defined is the range as a whole. The IANA IPv6 Special-Purpose Address Registry lists 64:ff9b:1::/48 as not globally reachable, and Python's ipaddress module reports isprivate as True and isglobal as False for every address in it. isPrivate() covered ULA (fc00::/7) plus the decoded mapped and well-known cases, and nothing in the local-use range.
Affected versions
>= 10.2.0, <= 10.5.0. The is classification API was extended to Address6 in 10.2.0; releases before it do not expose the method and are not affected through this vector.
Impact
Every address in 64:ff9b:1::/48 parses successfully, isValid() is true, and no classifier catches it. Which internal target is reached depends on the NAT64 prefix deployed on the server's network; the well-known prefix column is the patched control from GHSA-22jq-vg5j-6vgg.
| Internal target | Well-known form | Classified | Local-use form (/48 prefix) | Classified | |---|---|---|---|---| | 127.0.0.1 | 64:ff9b::7f00:1 | loopback | 64:ff9b:1:7f00:0:100:: | nothing | | 10.0.0.1 | 64:ff9b::a00:1 | private | 64:ff9b:1:a00:0:100:: | nothing | | 169.254.169.254 | 64:ff9b::a9fe:a9fe | link-local | 64:ff9b:1:a9fe:a9:fe00:: | nothing | | 192.168.1.1 | 64:ff9b::c0a8:101 | private | 64:ff9b:1:c0a8:1:100:: | nothing |
Reachability
Reaching an internal host through one of these addresses requires that the server's network run a NAT64 translator on a prefix inside 64:ff9b:1::/48 and that the attacker guess or know the prefix length. That is the same precondition the well-known prefix carries (a translator on 64:ff9b::/96), with the additional constraint that the prefix is operator-chosen rather than fixed. The severity reflects that precondition.
Proof of concept
npm i ip-address@10.5.0, then:
js const { Address6 } = require('ip-address');
// A guard of the shape the library documents. function isBlocked(host) { const a = new Address6(host); return a.isLoopback() || a.isLinkLocal() || a.isPrivate() || a.isMulticast() || a.isUnspecified(); }
for (const h of ['64:ff9b::7f00:1', '64:ff9b:1:7f00:0:100::', '64:ff9b:1::7f00:1']) { console.log(isBlocked(h) ? 'BLOCK' : 'ALLOW', h, '-> getType()', new Address6(h).getType()); }
On affected versions:
BLOCK 64:ff9b::7f00:1 -> getType() NAT64 (well-known) ALLOW 64:ff9b:1:7f00:0:100:: -> getType() NAT64 (local-use) ALLOW 64:ff9b:1::7f00:1 -> getType() NAT64 (local-use)
Remediation
Upgrade to the patched release. In the fix, isPrivate() returns true for every address in 64:ff9b:1::/48, alongside ULA and the decoded mapped and well-known cases. The range is reported private as a whole rather than by a decoded IPv4 address, for the reason given above; toAddress4Nat64(prefix) remains the way to decode an address under a known deployment prefix. isLoopback() and isLinkLocal() are unchanged for this range, since without the prefix length the library cannot know which IPv4 address is embedded.
If you cannot upgrade immediately, test the range directly:
js const NAT64LOCALUSE = new Address6('64:ff9b:1::/48'); const localUse = new Address6(host).isHostInSubnet(NAT64LOCALUSE);
A note on SSRF defense
These methods are address classifiers, not a complete SSRF defense. Regardless of this fix, a robust SSRF guard must resolve the hostname and validate the resolved IP against the socket it connects to, and account for DNS rebinding and redirects. Treat these checks as one layer, not the only one.
ip-address is a library for parsing and manipulating IPv4 and IPv6 addresses in JavaScript. Prior to 10.3.1, Address4 accepts an octet written with a leading zero and decodes it as decimal, while the WHATWG URL host parser, inetaton, and getaddrinfo all decode a leading zero as octal. The library and the network stack therefore disagree about which host a string names. new Address4('012.0.0.1') reports correctForm() of 12.0.0.1 and isPrivate() of false, but fetch('http://012.0.0.1/') connects to 10.0.0.1. An application that builds a network trust-boundary decision on these checks, for example a filter intended to block Server-Side Request Forgery, or SSRF, will classify an internal target as external and allow the request. The defect is in the parse gate rather than in any one classifier, so every consumer of Address4 inherits it: isPrivate(), isLoopback(), isLinkLocal(), isCGNAT(), isInSubnet(), isHostInSubnet(), and correctForm() are all computed from the mis-decoded octets. This issue is fixed in version 10.3.1.
ip-address is a library for parsing and manipulating IPv4 and IPv6 addresses in JavaScript. Versions 10.1.1 through 10.2.0 are vulnerable to SSRF through misclassification of IPv4-mapped/NAT64 IPv6 addresses. Address6.getType() classifies an address by matching it against a table of known IPv6 special-use prefixes, returning Global unicast when nothing matches. That table had no entry for the IPv4-mapped range (::ffff:0:0/96), so every mapped address fell through to Global unicast; NAT64 addresses matched their own NAT64 … labels. The boolean checks isLoopback, isUnspecified, and isMulticast compared getType() against a fixed label and so returned false, while isLinkLocal and isULA checked only the native IPv6 ranges. The library already exposed isMapped4() and to4(), but did not apply them inside these checks, so a mapped or NAT64 address was never normalized to its embedded IPv4 address before classification. For IPv4-mapped addresses the host OS routes to the IPv4 stack, so the misclassification is reachable on any dual-stack host. For NAT64, the classification bypass is unconditional but end-to-end reachability additionally requires a NAT64/DNS64 gateway in the deployment network.This issue has been fixed in version 10.2.1.
Summary
Address6's special-property checks misclassify IPv4-mapped (::ffff:0:0/96) and NAT64 well-known (64:ff9b::/96) IPv6 addresses. These checks classify an address by its IPv6 wrapper rather than by the IPv4 address it embeds, so isLoopback(), isLinkLocal(), isMulticast(), and isUnspecified() all return false for literals such as ::ffff:127.0.0.1 or ::ffff:169.254.169.254 that actually route to loopback, RFC 1918, or link-local (cloud-metadata) destinations. Address6 also had no isPrivate() method, so a mapped RFC 1918 address could not be detected at all.
An application that builds a network trust-boundary decision on these checks (for example, a filter intended to block Server-Side Request Forgery, or SSRF) may therefore treat an internal target as external and allow the request. SSRF is an attack in which a user-supplied address coaxes the server into making a request to an internal destination the user could not otherwise reach, such as a loopback service or a cloud metadata endpoint.
Details
Address6.getType() classifies an address by matching it against a table of known IPv6 special-use prefixes, returning Global unicast when nothing matches. That table had no entry for the IPv4-mapped range (::ffff:0:0/96), so every mapped address fell through to Global unicast; NAT64 addresses matched their own NAT64 … labels. The boolean checks isLoopback, isUnspecified, and isMulticast compared getType() against a fixed label and so returned false, while isLinkLocal and isULA checked only the native IPv6 ranges.
The library already exposed isMapped4() and to4(), but did not apply them inside these checks, so a mapped or NAT64 address was never normalized to its embedded IPv4 address before classification. The underlying CIDR matching is correct; the defect is that the special-use table omitted the IPv4-mapped range and the checks performed no embedded-IPv4 normalization.
Affected versions
>= 10.1.1, <= 10.2.0. The is classification API was introduced for Address4 in 10.1.1 and extended to Address6 in 10.2.0. Releases before 10.1.1 do not expose this API and are not affected through this vector.
Impact
The misclassification covers the entire ::ffff:0:0/96 range, in both dotted and hex notation and case-insensitively, plus the 64:ff9b::/96 NAT64 well-known prefix:
| Address | Reported as | Actually points at | |---|---|---| | ::ffff:127.0.0.1 / ::ffff:7f00:1 | Global unicast | loopback (127.0.0.0/8) | | ::ffff:10.0.0.1 | Global unicast | RFC 1918 10/8 | | ::ffff:172.16.5.5 | Global unicast | RFC 1918 172.16/12 | | ::ffff:192.168.1.1 | Global unicast | RFC 1918 192.168/16 | | ::ffff:169.254.169.254 / ::ffff:a9fe:a9fe | Global unicast | link-local / cloud metadata (IMDS) | | ::ffff:100.64.0.1 | Global unicast | CGNAT 100.64/10 | | ::ffff:0.0.0.0 / ::ffff:255.255.255.255 | Global unicast | unspecified / broadcast | | 64:ff9b::7f00:1 / 64:ff9b::a9fe:a9fe | NAT64 (well-known) | loopback / IMDS via NAT64 |
For IPv4-mapped addresses the host OS routes to the IPv4 stack, so the misclassification is reachable on any dual-stack host. For NAT64, the classification bypass is unconditional but end-to-end reachability additionally requires a NAT64/DNS64 gateway in the deployment network.
Proof of concept
A guard assembled from these checks lets internal hosts through:
js const { Address4, Address6 } = require('ip-address');
// true => block as internal, false => allow outbound function isBlocked(host) { try { const a = new Address4(host); return a.isPrivate() || a.isLoopback() || a.isLinkLocal() || a.isCGNAT() || a.isMulticast() || a.isUnspecified() || a.isBroadcast(); } catch {} try { const a = new Address6(host); return a.isLoopback() || a.isLinkLocal() || a.isULA() || a.isMulticast() || a.isUnspecified(); } catch {} return false; }
for (const h of ['127.0.0.1', '::1', '10.0.0.1', '8.8.8.8', '::ffff:127.0.0.1', '::ffff:10.0.0.1', '::ffff:169.254.169.254', '64:ff9b::7f00:1']) { console.log(isBlocked(h) ? 'BLOCK ' : 'ALLOW ', h); }
On affected versions this prints (note that every ::ffff:… and 64:ff9b::… internal target is allowed):
BLOCK 127.0.0.1 BLOCK ::1 BLOCK 10.0.0.1 ALLOW 8.8.8.8 ALLOW ::ffff:127.0.0.1 ALLOW ::ffff:10.0.0.1 ALLOW ::ffff:169.254.169.254 ALLOW 64:ff9b::7f00:1
The first three lines (native loopback, native IPv6 loopback, and a literal RFC 1918 address) are blocked as expected; the IPv4-mapped and NAT64 forms of the same internal destinations are allowed through.
Remediation
Upgrade to the patched release. In the fix, Address6 normalizes IPv4-mapped and NAT64 well-known addresses to their embedded IPv4 address before classifying, via a new embeddedIPv4() helper that isLoopback, isLinkLocal, isMulticast, and isUnspecified consult first. Address6 also gains isPrivate(), isCGNAT(), and isBroadcast() for parity with Address4, and getType() now labels the ::ffff:0:0/96 range as IPv4-mapped. After upgrading, new Address6('::ffff:127.0.0.1').isLoopback() returns true and new Address6('::ffff:10.0.0.1').isPrivate() returns true.
If you cannot upgrade immediately, normalize embedded IPv4 addresses yourself before classifying: call to4() on any address where isMapped4() (or membership in 64:ff9b::/96) is true, and run your IPv4 checks against the result.
A note on SSRF defense
These methods are address classifiers, not a complete SSRF defense. Regardless of this fix, a robust SSRF guard must resolve the hostname and validate the resolved IP against the socket it connects to, and account for DNS rebinding and redirects. Treat these checks as one layer, not the only one.
Credit
Reported by @OV-0-VO.
ip-address is a library for parsing and manipulating IPv4 and IPv6 addresses in JavaScript. Prior to 10.1.1, Address6.group() and Address6.link() do not HTML-escape attacker-controlled content before embedding it in the HTML strings they return, and AddressError.parseMessage (emitted by the Address6 constructor for invalid input) can contain unescaped attacker-controlled content in one branch. An application that (1) passes untrusted input to Address6 and (2) renders the output of these methods, or the thrown error's parseMessage, as HTML (e.g. via innerHTML) is vulnerable to cross-site scripting. This vulnerability is fixed in 10.1.1.