Skip to content
7 min read

Node.js vulnerabilities in 2026: Node2Shell and the registry

October's Brotli out-of-bounds write gives remote code execution through fetch() and body parsing. Plus the TLS fixes shipped beside it, and why npm trusted publishing did not end supply-chain worms.

On this page
  1. Node2Shell, in the order it happens
  2. Am I even reachable?
  3. The nine that got no coverage
  4. The registry is the bigger problem
  5. Trusted publishing was supposed to end this
  6. The one I got wrong
  7. Where I think this goes

Last Wednesday the Node.js project shipped ten CVEs in one release. One of them has a nickname, a logo's worth of vendor blog posts, and a place on CISA's exploited list within six days. The other nine got nothing, and one of those nine is a certificate hostname bypass.

So: both halves. Then the part that worries me more than either, which is that the most likely way malicious code reaches your Node process in 2026 is not a CVE at all.

Node2Shell, in the order it happens

CVE-2026-111497 is an out-of-bounds write in the Brotli decoder compiled into Node itself.

The mechanism is a nice piece of work. A Brotli stream declares how many block types it uses. A crafted stream declares one and then signals a switch to another, which is structurally invalid. The decoder's streaming path does not treat that as a format error. It reports it as "I need more input", and then goes on appending bytes to an internal buffer that is eight bytes long. Past the end it goes, into whatever is next in the heap.

The upstream fix makes the decoder fail immediately on a stream that switches block types while declaring one. That is in Google's Brotli at 1.0.10, 1.1.1 and 1.2.1, and in Node at:

code
v22.23.4   v24.22.1   v26.12.1

Node 23.x and 25.x are end-of-life. There is no patch coming for them and no workaround documented upstream, which makes this the second time this year I have seen an EOL line turn into an emergency migration.

Two things about severity. Google, as the CNA, scores it 9.2 critical on CVSS 4.0; the Node.js project rates it High; Singapore's CSA advisory cites 9.8 on CVSS 3.1. And on exploitation, Imperva's red team got code execution against the official Node 24.22.0 Linux arm64 image and reported it took several attempts, which is the usual distance between a heap write and a reliable exploit. Hadrian saw nothing in the wild as of 22 October. CISA added it to the KEV catalog on the 27th. I would not spend any time reconciling those; the patch is the same either way.

Am I even reachable?

This is the question worth answering carefully, because the obvious answer is too narrow.

The direct paths are the ones you would guess:

js
import zlib from 'node:zlib'

zlib.brotliDecompress(untrusted, cb)      // reachable
zlib.brotliDecompressSync(untrusted)      // reachable
untrustedStream.pipe(zlib.createBrotliDecompress())   // reachable

The one that catches people is fetch. Node's built-in fetch sends Accept-Encoding: br and decodes the response for you, so this is a reachable path:

js
// If that host is compromised, or you let users supply the URL, the response
// body reaches the Brotli decoder before any of your code sees it.
const res = await fetch(url)

Which means webhook verification, link previews, OAuth userinfo calls, scraping, an SSRF you already have, and any of the hundred small outbound requests a modern app makes. Express 5 and Koa's co-body decode Brotli request bodies, so inbound counts too. A bare node:http server does not, on its own.

I went looking in our own services with:

bash
grep -rn "createBrotliDecompress\|brotliDecompress" --include="*.{js,ts}" src/
rg -n "accept-encoding|content-encoding" src/
node -p process.versions.brotli    # the version compiled into this binary

That last line is the one that matters at the end. Everything else is context.

The nine that got no coverage

Three of them are High and TLS or DNS related, and I think they are more interesting than the heap bug for anyone running service-to-service traffic: CVE-2026-105482, CVE-2026-105483 and CVE-2026-106069, covering certificate hostname verification bypasses and evasion of SSRF defences.

Read that second one slowly. If your protection against SSRF is resolving a hostname, checking that the address is not private, and then making the request, a DNS-level evasion puts you back where you started. The defence that survives is not a check; it is an egress policy, enforced outside the process.

The rest are lower severity and cover HTTP keep-alive handling, crypto.randomUUID, zlib.ZipFile and the Permission Model. Still, a weakness in randomUUID is the kind of thing that shows up in a password-reset flow two years later, long after everyone stopped thinking about where the token came from.

The registry is the bigger problem

Honestly, if you handed me a budget for Node security, I would not spend most of it on CVEs. I would spend it on install time.

The Shai-Hulud worm family started in September 2025 with a postinstall script, which is the hook everyone knows about. Version 2 in November moved to preinstall, so the payload runs before installation finishes and fires even when the install then fails. Microsoft's August 2026 write-up of the ChainDrop variant describes the same pattern, by which point Singapore's CSA put it at over 1,300 compromised package versions representing around two billion monthly downloads. Microsoft's own count was 400-plus packages, naming keyv, flat-cache and cache-manager.

The propagation loop is what makes it a worm rather than an incident. It gets one valid npm token from a developer machine or a CI runner, lists every package that token can publish, adds its hook to each, bumps the patch version, republishes. No human in the loop.

Trusted publishing was supposed to end this

It helped, and then attackers moved up a level.

Unit 42 documented a June 2026 attack on @redhat-cloud-services where the attacker triggered GitHub Actions workflows to request OIDC tokens and published trojanised packages with valid SLSA provenance, because the publication genuinely did come from the project's legitimate workflow identity. JFrog found a sample that checked for one specific repository and workflow before exchanging an OIDC token for a publish token, then slipped a malicious entry into optionalDependencies on an @opensearch-project/opensearch package. There is a similar account of the @tanstack packages in May 2026, though I have only seen that one written up in a single blog post, so hold it loosely.

The lesson I took is uncomfortable. Provenance tells you which pipeline built a package. It does not tell you that the pipeline was doing what its maintainers intended. A green provenance badge is evidence, not a verdict.

What does help, in rough order of value per hour spent:

npm ci --ignore-scripts in CI and in production image builds. Most dependencies do not need lifecycle scripts; the ones that genuinely do (native builds) can be allow-listed and run deliberately. This single flag defeats the entire preinstall and postinstall class.

A minimum release age on your resolver, so a version published eleven minutes ago is not what your deploy pulls. Most of these campaigns are caught within hours. A 72-hour quarantine converts "we were patient zero" into "we read about it".

No long-lived publish tokens anywhere a build can read them, and npm audit signatures against your lockfile. Then pin the lockfile and actually review its diff in pull requests, which nobody does, including me, until the week after something like this.

The one I got wrong

Prototype pollution. I spent years filing it under "theoretical", in the same mental drawer as timing attacks on a login form.

The bug that changed my mind was a settings endpoint that merged a request body into a config object with a recursive helper somebody wrote in 2019. Standard shape:

js
// deepMerge(target, source) walking keys with no key check
app.post('/api/settings', (req, res) => {
  deepMerge(config, req.body)     // {"__proto__": {"isAdmin": true}}
  res.json(config)
})

Nothing visible happened. The payload did not touch config. It touched Object.prototype, and every object created afterwards in that process inherited isAdmin: true, including the ones an authorisation check was reading from thirty files away. The symptom was a permissions bug that only reproduced after a specific request had been served, which is a fun thing to debug at the time of day we were debugging it.

The fix is Object.create(null) for anything that holds parsed input, a key check for __proto__, constructor and prototype in any merge function, and schema validation at the boundary so a body with those keys never reaches your code. --disable-proto=throw on the Node command line turns the whole class loud, and I now put it on anything that parses untrusted JSON. I had the same realisation about PHP's unserialize a long time earlier and somehow did not transfer it.

Where I think this goes

The runtime is in decent shape. Node's security releases are regular, the Permission Model keeps improving, and a heap write in a bundled compression library is the sort of bug that any runtime with C dependencies will produce occasionally. Patching is a solved problem for teams who have decided to solve it.

The registry is not in decent shape, and I do not think the current direction fixes it. Each round of hardening raises the floor and the campaigns move to whatever is above it: tokens, then CI, now the identity federation we built to replace tokens. The next one will abuse something we are currently rolling out as a mitigation.

If someone has a convincing argument that this converges, I would like to read it, because the argument I keep reaching for is that install-time code execution was the original mistake and --ignore-scripts is the only part of the stack I fully trust.

Sources: Akamai on the Node2Shell internals · Rapid7's advisory · Sysdig on reachability and detection · Imperva's working exploit · CSA Singapore advisory · Microsoft on the ChainDrop worm · Unit 42 on the npm threat landscape · JFrog on the August keyv wave

Frequently asked

What is CVE-2026-111497 (Node2Shell)?

An out-of-bounds write in the Brotli decoder that Node.js bundles, disclosed on 21 October 2026 and exposed through node:zlib. A crafted Brotli stream declares one block type and then switches, which the decoder reports as needing more input instead of as a format error, so it keeps writing past the end of an eight-byte internal buffer. Google scores it CVSS 4.0 9.2 critical; the Node.js project rates it High. Fixed in 22.23.4, 24.22.1 and 26.12.1.

Is my app affected if I never call zlib directly?

Possibly. The decoder is reached by anything that decompresses Brotli, which includes Node's built-in fetch() handling a br-encoded response, Express 5 and Koa request-body parsing, and any library doing content negotiation for you. A bare node:http server does not decode Brotli request bodies on its own, so the question is what sits in front of your handler and what your code calls outbound.

Does updating the npm brotli package fix it?

No. The vulnerable code is compiled into the Node.js binary from Google's Brotli library, so the only fix is a Node.js upgrade (or an OS package rebuild against Brotli 1.0.10, 1.1.1 or 1.2.1). The similarly named npm package is unrelated to the runtime's decoder.

How do I stop an npm supply-chain worm?

Nothing stops one entirely, but the cheap measures are real: install with npm ci --ignore-scripts in CI and in production images, pin with a committed lockfile, use a minimum release age so brand-new versions are not pulled automatically, and keep publish tokens out of long-lived environments. The 2026 waves abused trusted publishing through legitimate CI workflows, so provenance alone is not a verdict.

Found this useful?Share on XShare on LinkedIn

Related reading