ip-address: Address6.isLinkLocal() recognizes fe80::/64 rather than fe80::/10, allowing SSRF and trust-boundary bypass to on-link hosts
What fixes it
- 10.5.1ip-address, npmGitHub
npm install ip-address@10.5.1
A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.
Description
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:
// 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 IN6_IS_ADDR_LINKLOCAL macro in netinet6/in6.h, and Linux's ipv6_addr_type() 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:
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:
const LINK_LOCAL = new Address6('fe80::/10');
const linkLocal = new Address6(host).isHostInSubnet(LINK_LOCAL);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.
Text from GitHub Advisory Database
Affected versions 3
- ip-address, npm<= 10.5.0GitHub
- ip-addressaffected: < 10.5.1CVE List
- ip-address, npmfrom first release, fixed in 10.5.1OSV
References 5
- github.com/beaugunderson/ip-address/commit/d03e960c7cc3179ef25c8a44b4f94dd499625546Fixvia CVE List
- github.com/beaugunderson/ip-address/releases/tag/v10.5.1Fixvia CVE List
- github.com/advisories/GHSA-rpw4-54j3-4h4qAdvisoryvia GitHub
- github.com/beaugunderson/ip-address/security/advisories/GHSA-rpw4-54j3-4h4qExploitvia CVE List
- nvd.nist.gov/vuln/detail/CVE-2026-101913Referencevia GitHub