GHSA-RPW4-54J3-4H4Q
Vulnerability from github – Published: 2026-09-28 20:43 – Updated: 2026-09-28 20:43Summary
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.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 10.5.0"
},
"package": {
"ecosystem": "npm",
"name": "ip-address"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "10.5.1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-101913"
],
"database_specific": {
"cwe_ids": [
"CWE-697",
"CWE-918"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-28T20:43:55Z",
"nvd_published_at": "2026-09-28T18:17:21Z",
"severity": "MODERATE"
},
"details": "### Summary\n\n`Address6.isLinkLocal()` recognizes `fe80::/64` rather than `fe80::/10`. Link-local unicast is the whole `/10` under RFC 4291 \u00a72.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(\u0027fe81::1\u0027).isLinkLocal()` is `false`.\n\nThe library contradicts itself on the same object: for `fe81::1`, `getType()` returns `\u0027Link-local unicast\u0027`, `getScope()` returns `\u0027Link local\u0027`, and `isHostInSubnet(new Address6(\u0027fe80::/10\u0027))` returns `true`, while `isLinkLocal()` returns `false`.\n\nAn 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.\n\n### Details\n\n`isLinkLocal()` in `src/ipv6.ts` compares the first 64 bits of the address against a literal string:\n\n```ts\n// Zeroes are required, i.e. we can\u0027t check isHostInSubnet with \u0027fe80::/10\u0027\nif (\n this.getBitsBase2(0, 64) ===\n \u00271111111010000000000000000000000000000000000000000000000000000000\u0027\n) {\n return true;\n}\n```\n\nThe comparison requires the first 64 bits to be exactly `fe80:0000:0000:0000`, so it accepts 2\u2076\u2074 of the 2\u00b9\u00b9\u2078 addresses in `fe80::/10`. The comment states a premise the library disproves: `getType()` classifies the same range with `isHostInSubnet` against the `\u0027fe80::/10\u0027: \u0027Link-local unicast\u0027` entry in `src/v6/constants.ts`, and `Address4.isLinkLocal()` is a plain `isHostInSubnet` test against `169.254.0.0/16`. RFC 4291 \u00a72.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.\n\nThe 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`.\n\n### Affected versions\n\n`\u003c= 10.5.0`. The comparison has had this shape since `Address6.isLinkLocal()` was introduced, so every release exposing the method is affected.\n\n### Impact\n\nEvery 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.\n\n| Address | `isLinkLocal()` | `getType()` | `getScope()` |\n|---|---|---|---|\n| `fe80::1` | `true` | Link-local unicast | Link local |\n| `fe81::1` | `false` | Link-local unicast | Link local |\n| `fe8f::1` | `false` | Link-local unicast | Link local |\n| `febf::1` | `false` | Link-local unicast | Link local |\n| `fe80:0:0:1::1` | `false` | Link-local unicast | Link local |\n| `fe80::1:0:0:0:1` | `false` | Link-local unicast | Link local |\n\nPython\u0027s `ipaddress` module, the `IN6_IS_ADDR_LINKLOCAL` macro in `netinet6/in6.h`, and Linux\u0027s `ipv6_addr_type()` all apply a ten-bit prefix test and classify every row above as link-local.\n\nA request admitted through a guard built on `isLinkLocal()` reaches a link-local host on the server\u0027s 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\u0027s own segment rather than the internet or a universal metadata endpoint, and the severity reflects that.\n\n### Proof of concept\n\n`npm i ip-address@10.5.0`, then:\n\n```js\nconst { Address6 } = require(\u0027ip-address\u0027);\n\n// A guard of the shape the library documents.\nfunction isBlocked(host) {\n const a = new Address6(host);\n return a.isLoopback() || a.isLinkLocal() || a.isPrivate() || a.isMulticast() || a.isUnspecified();\n}\n\nfor (const h of [\u0027fe80::1\u0027, \u0027fe81::1\u0027, \u0027febf::1\u0027, \u0027fe80:0:0:1::1\u0027]) {\n const a = new Address6(h);\n console.log(isBlocked(h) ? \u0027BLOCK\u0027 : \u0027ALLOW\u0027, h, \u0027-\u003e getType()\u0027, a.getType());\n}\n```\n\nOn affected versions:\n\n```\nBLOCK fe80::1 -\u003e getType() Link-local unicast\nALLOW fe81::1 -\u003e getType() Link-local unicast\nALLOW febf::1 -\u003e getType() Link-local unicast\nALLOW fe80:0:0:1::1 -\u003e getType() Link-local unicast\n```\n\n### Remediation\n\nUpgrade 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 `\u0027Teredo\u0027` for the addresses `isTeredo()` returns `true` for; that is a consistency correction with no security effect.\n\nIf you cannot upgrade immediately, test the range directly:\n\n```js\nconst LINK_LOCAL = new Address6(\u0027fe80::/10\u0027);\nconst linkLocal = new Address6(host).isHostInSubnet(LINK_LOCAL);\n```\n\n### A note on SSRF defense\n\nThese 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.",
"id": "GHSA-rpw4-54j3-4h4q",
"modified": "2026-09-28T20:43:55Z",
"published": "2026-09-28T20:43:55Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/beaugunderson/ip-address/security/advisories/GHSA-rpw4-54j3-4h4q"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-101913"
},
{
"type": "WEB",
"url": "https://github.com/beaugunderson/ip-address/commit/d03e960c7cc3179ef25c8a44b4f94dd499625546"
},
{
"type": "PACKAGE",
"url": "https://github.com/beaugunderson/ip-address"
},
{
"type": "WEB",
"url": "https://github.com/beaugunderson/ip-address/releases/tag/v10.5.1"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:N/VA:N/SC:L/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "ip-address: Address6.isLinkLocal() recognizes fe80::/64 rather than fe80::/10, allowing SSRF and trust-boundary bypass to on-link hosts"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.