{"id":"GHSA-rpw4-54j3-4h4q","summary":"ip-address: Address6.isLinkLocal() recognizes fe80::/64 rather than fe80::/10, allowing SSRF and trust-boundary bypass to on-link hosts","details":"### Summary\n\n`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`.\n\nThe 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`.\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't check isHostInSubnet with 'fe80::/10'\nif (\n  this.getBitsBase2(0, 64) ===\n  '1111111010000000000000000000000000000000000000000000000000000000'\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⁶⁴ 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.\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'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.\n\nA 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.\n\n### Proof of concept\n\n`npm i ip-address@10.5.0`, then:\n\n```js\nconst { Address6 } = require('ip-address');\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 ['fe80::1', 'fe81::1', 'febf::1', 'fe80:0:0:1::1']) {\n  const a = new Address6(h);\n  console.log(isBlocked(h) ? 'BLOCK' : 'ALLOW', h, '-\u003e getType()', 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 `'Teredo'` 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('fe80::/10');\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.","aliases":["CVE-2026-101913"],"modified":"2026-09-28T21:00:05.359993143Z","published":"2026-09-28T20:43:55Z","database_specific":{"cwe_ids":["CWE-697","CWE-918"],"severity":"MODERATE","github_reviewed":true,"github_reviewed_at":"2026-09-28T20:43:55Z","nvd_published_at":"2026-09-28T18:17:21Z"},"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"}],"affected":[{"package":{"name":"ip-address","ecosystem":"npm","purl":"pkg:npm/ip-address"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"10.5.1"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-rpw4-54j3-4h4q/GHSA-rpw4-54j3-4h4q.json","last_known_affected_version_range":"\u003c= 10.5.0"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V4","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"}]}