h3llh0und

CVE-2026-101913: the address the library couldn't agree with itself about

A classifier in the ip-address library answered isLinkLocal() for fe80::/64 when link-local is fe80::/10 — it recognised one address in every 2^54 of the range it named. I didn't need a second parser to show it was wrong, because the same object answered the same question correctly three other ways. The fix commit says so in as many words. Fixed in 10.5.1; the identifier landed a month later.

Disclaimer. This is personal security research, carried out on my own equipment against a public open-source project. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. Nothing in it touched a network: every address here is a string I handed to a parser on my own machine and then asked to classify. The issue was reported privately through the project’s own security advisory process, and is written up here after the advisory and the fixed release were public. The CVE identifier was assigned and its record published a month after the fix, and this was written once both were live.

ip-address is a widely used JavaScript library for parsing and classifying IPv4 and IPv6 addresses. Among its methods is a family of classifiers — isLoopback(), isMulticast(), isLinkLocal(), and so on — that answer yes-or-no questions about an address you have parsed. Applications lean on them, and the library’s own SECURITY.md lists them by name: a classifier returning the wrong answer for well-formed input is in scope.

isLinkLocal() returns the wrong answer. Link-local unicast is fe80::/10 — RFC 4291 §2.4 and the IANA registry both assign the whole of that /10 — and the method recognises fe80::/64. That is 2⁶⁴ of the 2¹¹⁸ addresses in the range: one in every 2⁵⁴. new Address6('fe81::1').isLinkLocal() is false, and so is it for febf::1, fe90::1, and every other address in the ninety-nine-point-nine-something percent of the range that does not begin fe80:0:0:0.

Four answers, and one that dissents

The thing that makes this a defect rather than an argument about the RFC is that I did not have to reach for a second implementation to contradict it. The library contradicts itself, on the same object, in the same breath. Parse fe81::1 and ask it four questions:

import { Address6 } from 'ip-address'   // 10.5.0

const o = new Address6('fe81::1')
o.getType()                                  // 'Link-local unicast'
o.getScope()                                 // 'Link local'
o.isHostInSubnet(new Address6('fe80::/10'))  // true
o.isLinkLocal()                              // false   <-- the odd one out

Three members agree the address is link-local. The fourth denies it. Run it across the range and the disagreement is systematic — every row past the first /64 is a contradiction:

address             isLinkLocal  getType()             getScope()   isHostInSubnet(fe80::/10)
fe80::1             true         Link-local unicast    Link local   true
fe80::dead:beef     true         Link-local unicast    Link local   true
fe81::1             false        Link-local unicast    Link local   true   <-- CONTRADICTION
fe8f::1             false        Link-local unicast    Link local   true   <-- CONTRADICTION
fe90::1             false        Link-local unicast    Link local   true   <-- CONTRADICTION
fea0::1             false        Link-local unicast    Link local   true   <-- CONTRADICTION
febf::1             false        Link-local unicast    Link local   true   <-- CONTRADICTION
fe80:0:0:1::1       false        Link-local unicast    Link local   true   <-- CONTRADICTION

A report that rests on “your library disagrees with Python” invites a conversation about whose reading of the standard is right. A report that rests on “your library disagrees with your library” does not. The correct answer is already inside the package, computed three different ways, and shipped on every install.

The comparison, and a comment that argues against the rest of the file

Here is the method, at src/ipv6.ts:

isLinkLocal(): boolean {
  const embedded = this.embeddedIPv4();
  if (embedded) {
    return embedded.isLinkLocal();
  }

  // Zeroes are required, i.e. we can't check isHostInSubnet with 'fe80::/10'
  if (
    this.getBitsBase2(0, 64) ===
    '1111111010000000000000000000000000000000000000000000000000000000'
  ) {
    return true;
  }

  return false;
}

It compares the first sixty-four bits against fe80:0000:0000:0000 exactly, so it accepts fe80::/64 and refuses everything else in the /10. And the comment — “we can’t check isHostInSubnet with fe80::/10” — states a premise the rest of the file disproves. getType() checks the range with exactly that subnet match, against an entry the library itself ships:

// src/v6/constants.ts
'fe80::/10': 'Link-local unicast',

So isHostInSubnet handles a /10 perfectly well; getType() is standing proof that it does. The comment is not describing a limitation of the library. It is describing a belief that was wrong when it was written, and the hand-rolled bit-string comparison is what that belief produced.

The IPv4 sibling was written the right way

The clinching detail is one method over. Address4#isLinkLocal() is a single line:

// src/ipv4.ts
isLinkLocal(): boolean {
  return this.isHostInSubnet(LINK_LOCAL_V4);
}

It delegates to the subnet test, matches 169.254.0.0/16, and is correct. Only the IPv6 side was hand-written, and only the hand-written side is wrong. This is not a subtle interaction between two components — it is one method that reimplemented, badly, what its own sibling and its own getType() do correctly.

A prior fix had the function open and walked past it

The comparison has had this shape since the classification API was introduced, so it is not a regression. But it is not that nobody has touched the function since, either. Commit 488fe9b, the fix for an earlier ip-address advisory, edited this exact method — and edited only the comment, updating a stale reference from isInSubnet to isHostInSubnet. It corrected the sentence that names isHostInSubnet while leaving intact the comparison that exists because someone thought isHostInSubnet could not be used. The fix had its hands on the wrong line and changed the words around it.

Where a wrong answer lands

The impact is bounded and I said so in the report rather than leaving it to be found. This is not a claim that these classifiers are an SSRF defence — the library’s policy is explicit that they are one layer and not a guard, and the finding does not depend on an application having used them as its only one. The defect stands on its own terms: a well-formed literal is parsed successfully and then classified wrongly.

Where it bites is a deny-list assembled from these classifiers, which is the use SECURITY.md itself names. Run the guard shape from the library’s own advisory PoC against fe81::1 and every classifier says no:

isLoopback=false  isLinkLocal=false  isMulticast=false  isUnspecified=false
=> special-use? false

isPrivate() does not catch it either — that is ULA, fc00::/7 — so nothing in the library flags the address, and a request built on that verdict reaches a link-local host on the server’s own segment: a neighbouring machine, or the on-link router. That needs on-link adjacency and generally a zone index, so the reach is the local link and not the internet — which is why I filed it Low and why the vendor’s rescore kept it there.

What shipped

The maintainer accepted the report and fixed it in about fifteen hours, in 10.5.1, commit d03e960c. The method is now the one line the rest of the library already used:

isLinkLocal(): boolean {
  const embedded = this.embeddedIPv4();
  if (embedded) {
    return embedded.isLinkLocal();
  }

  return this.isHostInSubnet(LINK_LOCAL_SUBNET);
}

The commit message is the report’s own argument, given back in the maintainer’s words: “getType() and getScope() already matched fe80::/10 via the TYPES table, so the library disagreed with itself on the same address. Use the same subnet test the rest of the library uses.” It also took the one secondary note I had left at the bottom of the report — that getType() had no Teredo entry, so isTeredo() and getType() disagreed the same way for 2001::/32 — and added that row to the table in the same commit. Both halves of the divergence closed together.

The identifier

The shape of the credit is worth a paragraph, because it is not the shape the last few of mine have had. The advisory GHSA-rpw4-54j3-4h4q went public on 29 August, the day after I filed, with the fix already shipped and my reporter credit accepted. The identifier did not arrive with it. CVE-2026-101913 was assigned by GitHub and its record published on 28 September — thirty days later, and long after the fix was in people’s hands.

When it landed, the record carried no credits field at all. GitHub, as the CNA here, publishes the record without the reporter block the advisory holds, so the name lives on the advisory and not on the CVE — the same gap I have written about before, and the cleanest example of it yet: the identifiers I hold that were assigned by VulnCheck and the Erlang Ecosystem Foundation each carry the name in the record itself, and this one, from the same reporter for the same kind of finding, does not. It is a difference in the assigning CNA, not in the work. The advisory also credits a second researcher, spokodev, as a finder — an independent arrival at the same method, folded into the one record rather than split into two.

My filing was CVSS 3.1, AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N, 5.3. The published record is 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, 6.3 — the migration put the pre-existing-adjacency condition into AT:P and the on-link reach into SC:L, which is a more faithful spelling of the same low, local impact I described. The number went up a tenth of a band and the story it tells did not change.


Advisory GHSA-rpw4-54j3-4h4q · CVE CVE-2026-101913 · Fix d03e960c · Affected <= 10.5.0, fixed in 10.5.1

Reported 28 August · accepted, fixed and advisory published 29 August · CVE assigned and record published 28 September, thirty days later, crediting me as reporter on the advisory.

Eurico Nicacio — @h3llh0und

← all posts Eurico Nicacio · h3llh0und