The October 21, 2026 Node.js security releases fix CVE-2026-111497, an out-of-bounds write in the bundled Brotli decoder that the project says can lead to remote code execution. The fixed versions are 22.23.4, 24.22.1 and 26.12.1. Upgrading is the fix, and there's nothing to debate about that. The harder question, if you can't roll every service today, is which of them actually decompress Brotli from someone you don't trust. The answer includes code that never mentions Brotli: global fetch() decodes a br response even when it didn't ask for one, and Express 5 inflates Brotli request bodies by default.
What was fixed, and on which lines
The Node.js advisory fixes ten CVEs across the three supported lines. The Brotli bug is rated High by the project and affects 22.x, 24.x and 26.x. In the advisory's words, an invalid block switch "was reported as a temporary need for more input instead of a permanent format error", so the streaming decoder kept appending bytes to an eight-byte internal buffer. Anyone who can supply Brotli data that your application decompresses with node:zlib can corrupt process memory. The 24.22.1 release notes show the fix as a cherry-picked upstream Brotli commit, not a version bump.
| Line | Status (per the Release WG schedule) | Fixed in |
|---|---|---|
| 26.x | Active LTS | 26.12.1 |
| 24.x | Maintenance LTS | 24.22.1 |
| 22.x | Maintenance LTS | 22.23.4 |
| 20.x, 23.x, 25.x | End-of-life | No fix |
The Release working group schedule puts Node 20 at end-of-life on April 30, 2026, so a service still on 20 has no patched release to move to. The same goes for the odd-numbered lines.
How Brotli reaches node:zlib without you asking
The obvious entry point is your own code calling zlib.createBrotliDecompress(), brotliDecompress() or brotliDecompressSync() on data from a request, an upload or a queue. Those are easy to grep for. Two others are easier to miss.
The first is fetch(). Node's global fetch is undici, and undici's fetch implementation sends accept-encoding: br, gzip, deflate, zstd for https URLs and gzip, deflate for plain http. That looks like Brotli is only in play over https. It isn't: what matters is what the server sends back. This script serves a Brotli body to fetch() and to the node:http client:
// fetch-br.mjs: which Node clients decompress a Brotli response without being asked?
import http from 'node:http';
import { brotliCompressSync } from 'node:zlib';
const body = brotliCompressSync(Buffer.from('hello from a brotli server\n'.repeat(3)));
const server = http.createServer((req, res) => {
console.log(`${req.headers['user-agent'] ?? '-'} sent accept-encoding: ${req.headers['accept-encoding'] ?? '(none)'}`);
res.writeHead(200, { 'content-type': 'text/plain', 'content-encoding': 'br' });
res.end(body);
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const url = `http://127.0.0.1:${server.address().port}/`;
// 1. Global fetch (undici)
const res = await fetch(url);
const text = await res.text();
console.log(`fetch: content-encoding ${res.headers.get('content-encoding')}, body is ${text.length} chars of plain text`);
// 2. node:http client
await new Promise((done) => {
http.get(url, { headers: { 'user-agent': 'node:http' } }, (r) => {
const chunks = [];
r.on('data', (c) => chunks.push(c));
r.on('end', () => {
console.log(`http.get: content-encoding ${r.headers['content-encoding']}, body is ${Buffer.concat(chunks).length} raw bytes, still compressed`);
done();
});
});
});
server.close();
Running it on Node 23.9.0 (bundled undici 6.21.1):
node sent accept-encoding: gzip, deflate
fetch: content-encoding br, body is 81 chars of plain text
node:http sent accept-encoding: (none)
http.get: content-encoding br, body is 39 raw bytes, still compressed
Fetch advertised only gzip and deflate, got Brotli anyway, and decoded it. So any service that calls fetch() on a URL it doesn't control is decompressing attacker-chosen Brotli: webhook targets, link previews, feed and image fetchers, anything that follows a user-supplied URL. The plain node:http client hands you the raw bytes, which matches the zlib docs, whose HTTP client example switches on content-encoding and pipes through createBrotliDecompress() itself. Code built on node:http is only exposed where it does that decoding.
The second is request bodies. The body-parser README says all its parsers inflate gzip, br, deflate and zstd, and its inflate option defaults to true. Brotli support arrived in body-parser 2.0.0, according to its changelog. Express 5.3.0 depends on body-parser ^2.3.0, and the Express 5 API docs say express.json() is based on body-parser. Put together: a stock Express 5 app with app.use(express.json()) will decompress a Brotli body from any client that sets Content-Encoding: br. Express 4.21.2 pins body-parser 1.20.3, and no 1.x changelog entry mentions Brotli. We didn't install Express or body-parser for this article, so this part rests on the docs and registry metadata rather than a test run.
Find the entry points in a code base
This script reports whether the running Node is patched, then lists source lines that call the zlib Brotli APIs, call fetch(), or import undici directly. It skips node_modules, so it tells you about your code, not your dependencies.
// check-node-oct-2026.mjs: is this Node patched for the 2026-10-21 security release,
// and where does this code base decompress Brotli?
// Usage: node check-node-oct-2026.mjs [project-dir]
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
const FIXED = { 22: '22.23.4', 24: '24.22.1', 26: '26.12.1' };
const parse = (v) => v.replace(/^v/, '').split('.').map(Number);
const lt = (a, b) => {
const [x, y] = [parse(a), parse(b)];
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i];
return false;
};
const v = process.version.slice(1);
const major = parse(v)[0];
const fixed = FIXED[major];
let status;
if (!fixed) status = major > 26 ? 'newer line: check its own release notes' : 'END-OF-LIFE line: no fix, move to 22, 24 or 26';
else status = lt(v, fixed) ? `VULNERABLE: upgrade to ${fixed}` : 'patched';
console.log(`node ${v} (undici ${process.versions.undici}): ${status}`);
// Places where untrusted Brotli can reach node:zlib.
const patterns = [
['zlib brotli', /\b(createBrotliDecompress|brotliDecompress(Sync)?)\b/],
['fetch()', /\bfetch\s*\(/],
['undici', /from\s+['"]undici['"]|require\(\s*['"]undici['"]\s*\)/],
];
function* files(d) {
for (const e of readdirSync(d, { withFileTypes: true })) {
if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
const p = join(d, e.name);
if (e.isDirectory()) yield* files(p);
else if (/\.(c|m)?(j|t)sx?$/.test(e.name)) yield p;
}
}
const dir = process.argv[2] ?? '.';
for (const f of files(dir)) {
readFileSync(f, 'utf8').split('\n').forEach((line, i) => {
for (const [label, re] of patterns) {
if (re.test(line)) console.log(`${label.padEnd(11)} ${f}:${i + 1} ${line.trim().slice(0, 70)}`);
}
});
}
Against a small sample project with one upload route that decodes br bodies and one module that fetches a URL from config:
node 23.9.0 (undici 6.21.1): END-OF-LIFE line: no fix, move to 22, 24 or 26
fetch() sample/src/rates.ts:2 const res = await fetch(process.env.RATES_URL);
zlib brotli sample/src/routes/upload.js:1 import { createBrotliDecompress } from 'node:zlib';
zlib brotli sample/src/routes/upload.js:6 await pipeline(req, createBrotliDecompress(), saveTo('/tmp/upload'));
Run it with the same node binary your service uses, inside the production image, since the version line is about the runtime that executes it. A hit isn't proof of exposure, and a clean result isn't proof of safety: it won't see Express's body parsing, and we didn't check which third-party HTTP clients decode Brotli on their own. Treat it as a list of places to look first.
If a service can't be upgraded today, cut off what you can. For Express 5 and body-parser, inflate: false makes the parser reject compressed bodies (with a 415, per the README) instead of decoding them. Most APIs don't need clients to compress request bodies at all. For outbound fetches, limit them to origins you trust until the new runtime is out. Neither is a substitute for the upgrade.
The TLS hostname bugs fixed alongside it
Three of the other CVEs are about tls.checkServerIdentity() choosing the wrong reading of an IP address. They matter if your code opens TLS connections to hosts that are IP literals, especially ones that come from user input or config. The root of it is that the same string means different things to different parsers. Running this on macOS with Node 23.9.0:
// ip-spellings.mjs: one host string, two readings
import net from 'node:net';
import dns from 'node:dns/promises';
for (const host of ['127.0.0.1', '0127.0.0.1', '127.0.0.1']) {
const url = new URL(`https://${host}/`).hostname;
let os;
try {
os = (await dns.lookup(host)).address;
} catch (e) {
os = e.code;
}
console.log(`${host.padEnd(12)} net.isIP=${net.isIP(host)} WHATWG URL=${url.padEnd(10)} OS resolver=${os}`);
}
127.0.0.1 net.isIP=4 WHATWG URL=127.0.0.1 OS resolver=127.0.0.1
0127.0.0.1 net.isIP=0 WHATWG URL=87.0.0.1 OS resolver=127.0.0.1
127.0.0.1 net.isIP=0 WHATWG URL=127.0.0.1 OS resolver=127.0.0.1
The WHATWG URL parser reads the leading-zero octet as octal and gets 87.0.0.1, while the macOS resolver connects to 127.0.0.1. That's CVE-2026-106069 (High, all three lines): the certificate is checked against one address while the socket goes to another. The fullwidth-digit spelling is CVE-2026-105483 (High, 24.x and 26.x only), a regression where such a host isn't recognised as an IP and gets matched against the certificate's DNS names, so a cert with DNS:127.0.0.1 could pass. CVE-2026-106072 (Medium) covers spellings like 0x7f.0.0.1 and 127.1 on resolvers such as glibc that accept them. The fix, per the release notes, is that TLS now rejects non-canonical IP addresses. If you validate hosts yourself before connecting, net.isIP() returning 0 for these spellings is a reasonable thing to reject on.
One more fix is worth knowing if you allow-list domains: CVE-2026-105482 makes dns.resolve*() and dns.reverse() reject names containing a NUL byte. Before the fix, resolving attacker.example\0.trusted.example quietly queried attacker.example, so a check that the name ends in your trusted domain could pass while the records came from somewhere else.
After you deploy, confirm with node -p process.version inside the running container, not on the build machine. The base image decides the runtime, and an image pinned to an exact Node version won't move on its own.
No comment for this article.