Your SSRF Allowlist Blocked 127.0.0.1, and Node Connected to 0177.0.0.1 Anyway
Node's October 21, 2026 security release fixed a Brotli out-of-bounds write that CISA added to its exploited list six days later. But four quieter CVEs in the same batch are the ones worth reading: they all come down to your code and Node's code parsing the same hostname differently. Here's what broke, why string allowlists lose, and what to upgrade to now that 26.13.0 is LTS.

The Brotli bug got the attention, and fair enough. CVE-2026-111497 is an out-of-bounds write in the decoder Node bundles: an invalid block switch reported itself as "need more input" instead of a format error, so the streaming decoder kept appending into an eight-byte internal buffer and walked off the end. CISA added it to the exploited list on October 27. If anything in your stack runs untrusted bytes through node:zlib, stop reading and go patch.
The four CVEs I actually keep thinking about shipped in the same October 21 release, got lower billing, and all say the same thing in different words: your code and Node's code do not agree on what a hostname is.
One string, two parsers
Here is the shape of basically every SSRF guard I have ever reviewed, including ones I wrote.
const BLOCKED = new Set(['127.0.0.1', 'localhost', '169.254.169.254']);
export async function proxyUserUrl(raw) {
const url = new URL(raw);
if (BLOCKED.has(url.hostname)) throw new Error('blocked host');
return fetch(url); // off it goes
}url.hostname for http://0177.0.0.1/ is the string 0177.0.0.1. Not in the set. Passes. Then the OS resolver gets hold of it, reads 0177 as octal, and dials 127.0.0.1. Same story for 127.1, which expands to 127.0.0.1, and 0x7f.0.0.1, which is hex for the same place. Your guard compared strings. The thing that opened the socket parsed numbers.
Node 22, 24 and 26 had their own version of this disagreement inside the TLS stack. CVE-2026-106069 is hostname verification accepting a leading-zero IPv4 spelling against a certificate's IP SAN. CVE-2026-105483 is the same bypass using fullwidth digits, so 127.0.0.1 with the wide Unicode characters matched. CVE-2026-106072 is DNS SANs authenticating IP endpoints for the non-canonical spellings above. Three separate reporters, three separate patches, one root cause.
The NUL byte one is worse
CVE-2026-105482 is rated High and it is the one I would audit first, because it breaks validation that looks correct.
dns.resolve*() and dns.reverse() hand the name to c-ares as a C string. A NUL byte in the middle ends the string there. So this:
await dns.promises.resolve4('attacker.example\0.trusted.example');queries attacker.example, returns its A records, and throws nothing. No error, no warning, records for a name you never approved.
Picture the usual flow. You take a webhook URL, check the hostname ends in .trusted.example, pass the validated string to dns.resolve4() to pin an address, and connect. Your suffix check sees .trusted.example because the full string really does end that way. c-ares sees attacker.example and answers for that. Two parsers, one string, and this time the gap sits between your validation and the resolution you did specifically to be careful.
What makes it sting: this is an incomplete fix of CVE-2026-48930, which covered dns.lookup() and the net/tls host options back when it landed. The lower-level resolver functions were missed. If you read that advisory, patched, and moved on, you were still exposed.
Fix the shape, not the strings
Upgrading closes the TLS matcher holes and the NUL truncation. It does nothing for your own allowlist, which is still comparing text.
The rule I have settled on: never validate the string a user gave you. Validate its canonical form, then use the canonical form for everything downstream.
import net from 'node:net';
import dns from 'node:dns/promises';
function canonicalHost(raw) {
if (raw.includes('\0')) throw new Error('NUL in hostname');
const ascii = new URL(`http://${raw}`).hostname; // punycode + lowercase
const v4 = net.isIPv4(ascii);
if (!v4 && /^[\dx.]+$/i.test(ascii)) throw new Error('numeric-ish host');
return ascii;
}
export async function safeTarget(raw) {
const host = canonicalHost(raw);
if (!host.endsWith('.trusted.example')) throw new Error('not allowed');
const [ip] = await dns.resolve4(host);
if (isPrivate(ip)) throw new Error('private range');
return { ip, host }; // dial ip, send host
}new URL() does the IDNA work, so fullwidth digits and punycode tricks collapse before the comparison. The numeric check is blunt on purpose: a hostname that is almost but not quite an IPv4 address is nearly always someone probing. And you resolve exactly once, then dial the IP you checked while keeping the real Host header, so there is no second lookup to swap the answer under you.
Two sleepers in the same batch
CVE-2026-106074: crypto.randomUUID() and randomInt() keep an internal cache, and the cache got baked into startup snapshots and single-executable applications. Every process from that snapshot returns the same values. If you ship an SEA and generate request IDs or tokens that way, go check.
CVE-2026-105481: a keep-alive socket accepted a response sent before its request. That is response queue desync, which is how one user ends up reading another user's body.
What to run
Node 26.13.0 shipped on October 28 as LTS, so 26 is now the line to be on. 24.22.2 landed the same day. 22.23.4 from October 21 carries the fixes for that line. The odd-numbered lines got nothing and will not: if you are on 25, you are running every unpatched CVE above. Node 27.0.0-alpha.0.0.0 appeared on October 29, which is worth a CI job and nothing else yet.
The grep I ran across my own repos was dns.resolve, then zlib.brotliDecompress, then every new Set([ holding hostnames. The third one found more than the first two.