A Node.js Patch You Shouldn't Skip
On October 21, 2026, Node.js shipped security releases for all three supported lines. The headline fix is CVE-2026-111497, a High-severity out-of-bounds write in Node's Brotli decoder that, according to the advisory, permits remote code execution.
The natural first reaction is "we don't decompress anything, so this isn't ours." I had that reaction too. A search of my own site's application code found no zlib usage at all. Then I traced what Node's built-in fetch() does with a compressed response, and found the Brotli decoder sitting on a path my code uses every day.
This post walks through that investigation: the source code, a small reproduction you can run yourself, why some features deserve more urgency than others, and the checklist I'd run on any Node service right now.
The facts, up front:
- Component: the Brotli decoder in Node.js (
zlib) - CVE: CVE-2026-111497, rated High in the Node.js advisory
- Fixed in: v22.23.4, v24.22.1, v26.12.1
- What to do: upgrade to the patched release on your line, rebuild, redeploy, and verify the version production actually runs
It landed in a loud news cycle that also included two PHP remote code execution vulnerabilities, a Railway security disclosure about customer environment variables, and the compromise of eight eMudhra intermediate certificate keys. The Node.js release received less attention, but JavaScript teams should treat it as urgent runtime maintenance.
What Shipped
| Line | Fixed version |
|---|---|
| 26.x | v26.12.1 |
| 24.x | v24.22.1 |
| 22.x | v22.23.4 |
The release covers 10 CVEs: 4 rated High, 3 Medium and 3 Low. All the High and Medium issues affect 24.x and 26.x, and all but one affect 22.x as well. It also updates bundled dependencies, including Undici (the library behind Node's built-in fetch()), c-ares, OpenSSL on 22.x and 24.x, and the bundled CA certificates.
Node 20 and Node 25 are end-of-life. The advisory is explicit that end-of-life versions are always affected when a security release ships, and they get no fixes. If anything you run is still on one of them, that's the first thing to change.
The Brotli Bug, Without the Jargon
Brotli is a compression format. Servers use it to shrink HTTP responses, and Node ships a native Brotli decoder (inside zlib) to expand them again.
A decompressor works like a moving crew unpacking boxes into a room. The compressed data says, in effect, "this box holds this much, put it here." The crew trusts the instructions and keeps unpacking. An out-of-bounds write is what happens when a carefully crafted box convinces the crew to keep unpacking past the wall of the room, straight into the neighbour's flat. In a process, the neighbour's flat is other memory: data structures, pointers, things the program relies on. Overwrite the right bytes and you can steer what the program does next. That's the road from "decompression bug" to "remote code execution."
The advisory credits the OpenAI Codex Security team (Khai Tran and Sylvie Mayer) with reporting it, and Matteo Collina and Antoine du Hamel with the fix. (The first version of the advisory credited Matteo Collina as the reporter; the Node.js team updated the credits on October 22, so you may still see the older wording in cached copies.) The v24.22.1 release notes identify the fix as a cherry-pick from the upstream Brotli library, attributed to Evgenii Kliuchnikov. That identifies the patch as coming from the Brotli dependency Node bundles, rather than as a change to Node's own JavaScript. The advisory doesn't describe the exact input that triggers the bug, so I won't guess at exploit details. What matters for us is a simpler question: does untrusted Brotli data ever reach that decoder in my process?
Why "We Don't Use zlib" Isn't the Answer
Here's the search I ran across my site's source tree (excluding the course content folder, whose lessons mention zlib in prose):
grep -rnE "Brotli|zlib" src --exclude-dir=content
No results. My application code never imports zlib and never decompresses anything by hand. (That covers my code, not my dependencies, which is exactly the point of what follows.)
But the site calls fetch() constantly: the LinkedIn API, the YouTube API, the Instagram Graph API, Google's font CSS. And fetch() decompresses responses for you. That's the whole point of it: you call res.json() and get JSON, not compressed bytes.
So I checked how. Node v24.22.1 bundles Undici 7.30.1. In that version's fetch implementation, the response's Content-Encoding header picks the decoder, and for br it creates a native Brotli decoder:
} else if (coding === 'br') {
decoders.push(zlib.createBrotliDecompress({
flush: zlib.constants.BROTLI_OPERATION_FLUSH,
finishFlush: zlib.constants.BROTLI_OPERATION_FLUSH
}))
}
Reading source is one thing, so I also tested it. A tiny local server returns a Brotli-compressed body over plain HTTP, and Node's built-in fetch() reads it:
import http from 'node:http';
import zlib from 'node:zlib';
const body = zlib.brotliCompressSync(
Buffer.from('{"hello":"decoded by Node\'s Brotli decoder"}')
);
let seenAE;
const srv = http.createServer((req, res) => {
seenAE = req.headers['accept-encoding'];
res.writeHead(200, {
'content-type': 'application/json',
'content-encoding': 'br',
});
res.end(body);
}).listen(0, async () => {
const r = await fetch(`http://127.0.0.1:${srv.address().port}/`);
console.log('client sent accept-encoding:', seenAE);
console.log('json():', await r.json());
srv.close();
});
Output on Node v24.22.1 (bundled Undici 7.30.1):
client sent accept-encoding: gzip, deflate
json(): { hello: "decoded by Node's Brotli decoder" }
Look at the first line. Over plain HTTP, fetch() didn't advertise Brotli; it asked for gzip, deflate. The server sent Brotli anyway, and fetch() decoded it. In the Node.js fetch() implementation I tested, the response's Content-Encoding decides which decoder runs, not the request's Accept-Encoding. (Over HTTPS, this version does advertise br, so a normal server will happily use it.)
Two things this experiment does not show. It isn't an exploit: it proves the decoding path exists, not that the vulnerability can be triggered through it. And it ran on the patched v24.22.1, so it says nothing about the patched runtime being vulnerable. What it does establish is that, in the Node.js v24.22.1 fetch() implementation I tested, a Brotli-encoded response reaches the native decoder even when the request did not advertise Brotli support.
That changes the threat model. Don't treat Accept-Encoding as a security boundary. With the built-in fetch() I tested, whoever controls the response controls whether your process runs the Brotli decoder. If you use a different HTTP client or runtime, verify its behavior rather than assuming it matches.
Who Controls Your Responses?
This is the production question, and the answer decides how urgent this is for you.
Calls to big, well-known APIs. LinkedIn, Google, Stripe and the like are unlikely to send you malicious Brotli. The risk here is low, but not zero: a compromised upstream, a hijacked domain or a proxy in the middle all change who's really answering.
Calls to URLs your users give you. This is where it gets serious. Think of webhook URL testers, link preview and Open Graph extractors, document importers, URL-to-PDF services, image proxies and RSS readers. In any feature where a user types a URL and your server fetches it, an attacker may control the response that reaches the decompressor. On an affected runtime, that's a potentially dangerous path and the first place to patch. The exact impact depends on the vulnerable code path and your environment, so don't assume every URL-fetching feature is automatically exploitable, but don't assume it's safe either.
Redirects. A request to a host you trust can redirect to one you don't. fetch() follows redirects by default, and the response that finally gets decoded may come from a server you never meant to talk to.
Incoming request bodies. If any middleware in your stack decompresses request bodies based on the client's Content-Encoding header, then every client on the internet can choose to send you Brotli. Check what your body parser does with compressed uploads; don't assume it ignores them.
A useful analogy for all four: your code is the person who signed for the parcel, but fetch() is the assistant who opens it. You never touched the box, and it still got opened in your office.
One more point, because patching tends to feel like the end of the job: upgrading Node fixes this bug, not the risk of fetching user-supplied URLs. Those features still need normal server-side request forgery (SSRF) protection: restrict which destinations are allowed, validate the resolved IP address (not just the hostname), and re-check the destination on every redirect. The next section shows why the "validate the resolved address" part matters.
The Other High-Severity Fixes
Three more issues are rated High, and two of them are about exactly the kind of checks SSRF protection relies on:
- CVE-2026-105483: fullwidth-digit IP addresses. TLS certificate hostname checks can be bypassed when an IP address is written with fullwidth digits (Unicode characters that look like
1,2,3but aren't ASCII). - CVE-2026-106069: loosely resolved IP addresses. A separate bypass, where a host that loosely resolves to an IP address passes the certificate hostname check. Hostname verification is the part of TLS that proves you're talking to the server you meant to. Service-to-service calls are where teams trust TLS the most and look at it the least.
- CVE-2026-105482:
dns.resolvetruncates names containing a NUL character. When a name is truncated at a NUL byte, the string your code checked and the name that actually gets resolved can differ. Not every allowlist is vulnerable, but any check-then-resolve flow that handles untrusted hostnames is worth a look.
One Medium-rated fix matters to anyone who builds startup snapshots or single executable applications (SEAs): CVE-2026-106074. The entropy caches behind crypto.randomUUID() and crypto.randomInt() were being saved into the snapshot, so every process started from it repeats the same "random" values. The fix is in the new Node binary, but a snapshot you already built with an affected version still contains the old cached values. If you ship snapshots or SEAs, rebuild them with the patched Node. If you don't know what a startup snapshot is, you almost certainly don't use one.
The Certificate Scare Reached Node Too
The eMudhra incident from the same news cycle is easy to file under "browser problem." Eight eMudhra intermediate CA keys were so weak they could be recovered from public certificates, which in principle lets someone issue certificates that impersonate websites. Chrome, Firefox, Apple and Microsoft moved to block them within days.
Browsers have their own trust stores and update them quickly. Non-browser clients are a different story: API clients, build pipelines, containers and server runtimes each trust whatever certificate list they were configured with. By default, Node verifies HTTPS connections against a CA list bundled into the binary (you can opt into the operating system's store with --use-system-ca), and this release updates that bundled list in response to the eMudhra compromise.
That doesn't mean every outdated Node app is about to have its traffic intercepted. An attacker would still need a usable certificate and a position on the network. But it does mean the browser fix didn't fix your servers. The runtime's trust configuration is part of its security surface, and an old binary carries an old list.
Why This Can't Wait for Next Sprint
The PHP flaws disclosed the same day show how quickly a public security fix attracts attention. Cloudflare CEO Matthew Prince reported that Cloudflare's network saw thousands of exploit attempts in the 10 hours before the announcement (details had leaked early), tens of thousands in the 45 minutes after it, hundreds of thousands within two hours, and millions by the time he posted. A published patch also tells attackers where the bug was.
That's the case for treating runtime security releases as urgent maintenance rather than routine dependency housekeeping. To be clear, it isn't evidence that CVE-2026-111497 is being exploited; I haven't found a reliable report that it is. The broader threat environment justifies the urgency on its own.
The Checklist
1. Check what you run locally.
node -v
You want v22.23.4, v24.22.1, v26.12.1, or later on the same line. A local upgrade is the start, not the fix.
2. Check what production actually runs. This is the step people skip. Your package.json saying "node": "24.x" is a range, not a version. On a managed platform, the provider picks the patch release. In a container, the base image does. Log the version at startup and read it from a real deployment:
console.info({ nodeVersion: process.version, nodeEnv: process.env.NODE_ENV });
Use logs, deployment metadata or authenticated internal tooling for this. Don't add a public endpoint that announces your runtime version to anyone who asks.
3. Upgrade, rebuild and redeploy. Upgrade to the patched release on your supported line, rebuild the deployment artifact, redeploy it, then repeat step 2 against the new deployment.
4. Make sure the image actually changed. What matters is that the final image contains a patched Node, not that you ran a build. If you pin an exact version (node:24.21.0-alpine), bump the pin deliberately. If you use a moving tag like node:24, pull the updated base image and rebuild. Either way, check the result:
docker run --rm your-image:tag node -v
5. Check your serverless platform's runtime policy. Providers handle runtime patches differently: some roll them out automatically, some only on the next deploy. Find out which yours does, and redeploy if it's required.
6. List every place you fetch a URL a user controls. Webhook testers, link previews and Open Graph extractors, importers, URL-to-PDF services, image proxies. Patch the services behind them first, and confirm their redirect handling and destination validation while you're there.
7. Rebuild snapshots and SEAs if you use them, after upgrading Node.
8. Get off Node 20 and Node 25. They are end-of-life and will not receive these fixes.
The Takeaway
The chain is short: no zlib import, then the built-in fetch(), then Undici's decompression, then the native Brotli decoder. Security boundaries can sit below the layer where you normally look for them, and you can't remove this one from your own code.
So the action is one you can take today: find out which Node version production is actually running, not which version your config file hopes for, and if it's older than the fixed release on its line, upgrade, rebuild and redeploy.
FAQ
Can Node.js fetch() expose an application to Brotli decoder vulnerabilities?
It can reach the decoder. In Node v24.22.1 (Undici 7.30.1), fetch() passed a response marked Content-Encoding: br to the native Brotli decoder even though the request didn't advertise Brotli, so a server that controls the response controls whether that decoder runs. My test shows that path exists; it doesn't show that CVE-2026-111497 can be triggered through it. The advisory is the source for the vulnerability and its impact, and upgrading is the fix either way.
Does removing zlib imports eliminate the risk?
No. The decoder is used by the runtime on your behalf, through fetch() and anything else that decompresses data. The fix is upgrading the Node runtime itself.
How do I verify the Node.js version running in production?
Log process.version at startup and read it from a live deployment's logs, or check the runtime inside your built image with docker run --rm your-image:tag node -v. Don't trust the engines field or your local node -v.
References
Last checked against the Node.js advisory on October 22, 2026. If the advisory changes, this post will be updated and the change noted here.
- Node.js: October 21, 2026 Security Releases: full CVE list, severities, credits and fixed versions
- Node.js v24.22.1 release notes: the release used for the experiment above
- Undici v7.30.1,
lib/web/fetch/index.js: the response decoding code quoted in this post - Matthew Prince on X: #php2shell stats: the original post with Cloudflare's cumulative exploit-attempt figures
- OfficeChai: Cloudflare CEO on PHP exploit attempts: Matthew Prince's figures on attack traffic after the PHP disclosure
- The Hacker News: Eight eMudhra CA Keys Compromised: background on the certificate incident
- Railway: Security Disclosure, October 21: the Railway incident mentioned in the introduction
Going Deeper
The Node.js In-Depth modules on Native Streams and Off-Heap Buffer Storage (where zlib streams live), Configuration, Security Hardening, and Rate Limiting, npm and the Node.js Ecosystem and Dockerizing Node.js Applications for Production cover the pieces this post touched in more depth.
And if this feels familiar, it should. In December 2025, the React Server Components remote code execution vulnerability taught the same lesson from a different layer: application security depends on the platform and framework underneath you, not only the code your team writes.