Answer Summary
CVE-2026-69192 is a high-severity vulnerability in the Node.js ip-address package (versions before 10.3.1) where IP address strings are parsed inconsistently with the platform's real resolver, allowing SSRF and trust-boundary bypass (CWE-1286 / CWE-918). Code that used new Address4(host) or new Address6(host) to decide whether a URL pointed at a public host could be tricked into approving a request that Node then actually sent to 127.0.0.1 or a link-local metadata endpoint. The fix is to upgrade ip-address to 10.3.1 — here done with a "ip-address": "10.3.1" entry in package.json overrides plus the matching package-lock.json bump so transitive consumers (such as socks/socks-proxy-agent) also get the patched parser.
Introduction
This vulnerability could have allowed an attacker to make your server talk to itself.
Not through a bug in your route handlers, and not through a misconfigured proxy — through a parser disagreement. Deep in this project's dependency tree, at package-lock.json line 630, sat this entry:
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
}
ip-address is the de facto standard IPv4/IPv6 parsing library for Node. It exports the Address4 and Address6 classes and it is pulled in transitively by an enormous slice of the ecosystem — socks, socks-proxy-agent, pac-resolver, various proxy and networking agents, and hand-rolled SSRF guards in application code. It has one job: turn a string into a structured, comparable IP address.
Trivy flagged version 10.1.0 under CVE-2026-69192: "Inconsistent IP address parsing leads to Server-Side Request Forgery (SSRF) and trust-boundary bypass."
The assessment on this repository was honest — "Present in dependency tree, not confirmed reachable." That is still worth fixing, and this post explains exactly why a parsing library becomes a security boundary the moment anything in your stack uses it to answer the question "is this host safe to connect to?"
The Vulnerability Explained
The shape of the bug
SSRF defenses in Node almost always follow the same three steps:
- Take an untrusted URL or host from a request.
- Parse the host as an IP and check whether it falls in a forbidden range (loopback, RFC1918, link-local, ULA).
- If it looks public, make the outbound request.
Here's the pattern, written with ip-address the way thousands of projects write it:
// Representative SSRF guard built on ip-address 10.1.0
import { Address4, Address6 } from 'ip-address';
const BLOCKED_V4 = [
new Address4('127.0.0.0/8'),
new Address4('10.0.0.0/8'),
new Address4('172.16.0.0/12'),
new Address4('192.168.0.0/16'),
new Address4('169.254.0.0/16'), // cloud metadata
];
function isSafeHost(host) {
try {
const v4 = new Address4(host); // <-- the trust decision
return !BLOCKED_V4.some((net) => v4.isInSubnet(net));
} catch {
try {
const v6 = new Address6(host);
return !v6.isLoopback() && !v6.isLinkLocal();
} catch {
return true; // "not an IP, must be a hostname" — already shaky
}
}
}
// Step 3: the request is made with the ORIGINAL STRING, not the parsed object
if (isSafeHost(new URL(userUrl).hostname)) {
await fetch(userUrl);
}
Look carefully at the last block. The validator consumes host through ip-address's parser. The connector (fetch → undici → net.connect → getaddrinfo) consumes the same string through a completely different parser written in C.
That gap is the entire vulnerability. CVE-2026-69192 exists because ip-address 10.1.0's parser and the platform's parser did not agree on the meaning of every input string. Whenever ip-address says "this is 0.177.0.1, clearly public" and the OS says "this is 127.0.0.1, that's your own loopback interface", the check is bypassed.
Why parsers disagree
IP notation is far messier than the four-dotted-decimal-octets model most developers carry in their heads. Historical inet_aton() semantics — which glibc, musl, and Windows all inherit in one form or another — accept forms that a strict, spec-modern parser rejects or normalizes differently:
| Input string | Strict/library interpretation | Platform resolver interpretation |
|---|---|---|
0177.0.0.1 |
often rejected, or read as decimal 177 |
octal → 127.0.0.1 |
0x7f.0.0.1 |
rejected as non-numeric | hex → 127.0.0.1 |
2130706433 |
not a valid dotted quad | 32-bit integer → 127.0.0.1 |
127.1 |
invalid (only two parts) | short form → 127.0.0.1 |
::ffff:127.0.0.1 |
an IPv6 address; IPv4 loopback checks never run | dual-stack → connects to IPv4 loopback |
::ffff:7f00:1 |
hex IPv4-mapped form; easy to miss in a loopback check | same → 127.0.0.1 |
127.0.0.1 / 127.0.0.1. |
trailing/leading characters may be trimmed silently | trimmed too — but only sometimes, and differently |
Any single one of these being handled differently by ip-address than by getaddrinfo() is enough to defeat the guard above. This class of divergence is well-documented (it's the same family of bug as the 2021 netmask and Node ip package advisories), and CVE-2026-69192 is the ip-address 10.x instance of it.
Concrete attack scenario
Assume this application exposes any feature that fetches a user-supplied URL — a webhook tester, a link preview generator, an avatar-by-URL importer, an OpenAPI schema importer, a SOCKS-proxied outbound call. That's the reachable surface.
- The attacker POSTs
{"url": "http://0177.0.0.1:8080/admin/keys"}. new URL(...).hostnameyields0177.0.0.1.new Address4('0177.0.0.1')in 10.1.0 does not normalize this to loopback.isInSubnet(127.0.0.0/8)returnsfalse.isSafeHost()returnstrue.fetch('http://0177.0.0.1:8080/admin/keys')is executed. Node hands the string to the OS, which reads0177as octal and connects to127.0.0.1:8080— the internal admin service that trusts anything arriving from localhost.- The response body is returned to the attacker.
Swap step 1 for http://[::ffff:169.254.169.254]/latest/meta-data/iam/security-credentials/ and, on a cloud instance, the same bypass yields temporary IAM credentials. That is the "trust-boundary bypass" half of the advisory title: services that grant privileges based on "the request came from inside the network" are now reachable by outsiders.
The socks / socks-proxy-agent angle matters too. Those packages use ip-address to decide whether a destination is already a literal IP (send as an address atom) or a hostname (send as a domain atom for remote resolution). A parse disagreement there changes which machine performs resolution, which can route traffic past egress controls entirely — no application code required.
The Fix
The fix is a dependency upgrade, applied in two coordinated places.
Change 1 — package-lock.json: the actual installed bytes
"node_modules/ip-address": {
- "version": "10.1.0",
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
- "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
+ "version": "10.3.1",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz",
+ "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
}
Three fields changed, and all three matter:
version—10.3.1contains the hardened parser, which rejects (rather than silently mis-normalizes) the ambiguous forms above.resolved— points npm at the new tarball URL.integrity— a new Subresource Integrity hash. Editingversionwithout updatingintegrityproduces anEINTEGRITYfailure onnpm ci. This hash is what guarantees the bytes installed in CI are the reviewed 10.3.1 bytes and not a substituted tarball.
Note the engines block is unchanged (node >= 12), so there is no runtime floor being raised — this upgrade will not break anyone's Node version.
Change 2 — package.json: enforcing it across the whole tree
"overrides": {
"fast-uri": "4.1.2",
- "hono": "4.12.34"
+ "hono": "4.12.34",
+ "ip-address": "10.3.1"
}
This is the important half, and it's easy to under-appreciate.
ip-address almost certainly is not a direct dependency of this project. It arrives transitively — most likely via a SOCKS or proxy agent. That means a plain npm update or a lockfile edit can be undone the next time an unrelated dependency is bumped and npm re-resolves the tree: if socks@2.x declares "ip-address": "^10.0.0", npm is free to install 10.1.0 again.
The overrides field is npm's mechanism (npm 8.3+) for saying "no matter who asks for ip-address, and no matter what range they ask for, they get exactly 10.3.1." It makes the fix durable rather than incidental. The project already uses this pattern for fast-uri and hono, so adding ip-address is consistent with the existing remediation strategy in this repo.
Together the two files give you:
| File | Role |
|---|---|
package.json overrides |
Policy — the version constraint that survives future re-resolution |
package-lock.json |
Fact — the exact version + integrity hash installed by npm ci today |
Verifying the fix
# Confirm only 10.3.1 is present, including transitively
npm ls ip-address --all
# Expect: no output / clean
npm audit --production
# Re-run the scanner that found it
trivy fs --scanners vuln --severity HIGH,CRITICAL .
A quick runtime sanity check that the new parser is stricter:
import { Address4 } from 'ip-address';
for (const s of ['0177.0.0.1', '0x7f.0.0.1', '2130706433', '127.1']) {
try {
console.log(s, '=>', new Address4(s).correctForm());
} catch (e) {
console.log(s, '=> REJECTED:', e.message);
}
}
On 10.3.1 you want ambiguous forms to throw, not to quietly produce a "public-looking" address. A thrown error is a safe outcome, because it forces your code down the catch branch where you can fail closed.
Behavior preservation
The upgrade is a patch/minor bump within the same major (10.x), the engines constraint is untouched, and the public API (Address4, Address6, isInSubnet, correctForm, etc.) is unchanged. Well-formed addresses — 192.168.1.10, 2001:db8::1, 10.0.0.0/8 — parse identically before and after. Only ambiguous, encoding-abusing inputs change behavior, which is precisely the intent.
Prevention & Best Practices
Upgrading closes this CVE. Architecting differently closes the whole class.
1. Validate the resolved address, not the string
The root design flaw in the vulnerable pattern is validating one representation and connecting with another. Break that gap:
import dns from 'node:dns/promises';
import net from 'node:net';
const blocked = new net.BlockList();
blocked.addSubnet('127.0.0.0', 8); // loopback
blocked.addSubnet('10.0.0.0', 8); // RFC1918
blocked.addSubnet('172.16.0.0', 12);
blocked.addSubnet('192.168.0.0', 16);
blocked.addSubnet('169.254.0.0', 16); // link-local / metadata
blocked.addSubnet('100.64.0.0', 10); // CGNAT
blocked.addAddress('::1', 'ipv6');
blocked.addSubnet('fc00::', 7, 'ipv6'); // ULA
blocked.addSubnet('fe80::', 10, 'ipv6'); // link-local v6
async function resolveSafely(hostname) {
const records = await dns.lookup(hostname, { all: true, verbatim: true });
for (const { address, family } of records) {
const type = family === 6 ? 'ipv6' : 'ipv4';
if (blocked.check(address, type)) {
throw new Error(`Blocked destination: ${address}`);
}
}
return records[0].address; // connect to THIS, not to the original string
}
net.BlockList and net.isIP() ship with Node and are backed by the same address handling as Node's networking layer — so validator and connector cannot disagree. This is the single highest-value change you can make.
2. Fail closed on parse errors
The vulnerable snippet earlier ends with return true; // must be a hostname. Invert it. If you cannot confidently parse and classify a destination, reject it. A hardened parser that throws on 0177.0.0.1 only helps you if your catch block denies rather than allows.
3. Defend against DNS rebinding and redirects
Even perfect parsing loses to time-of-check/time-of-use: a hostname that resolves public during validation can resolve to 127.0.0.1 milliseconds later when the socket opens. Mitigate by connecting to the pinned resolved IP with an explicit Host header, and by re-validating every hop of a redirect chain (maxRedirects: 0 and handle 3xx yourself is the