Node2WAT?
This was a bad week for cyber on the internet. Along with the slightly delayed PHPocalypse (there are some serious, um, exaggerations by some folks I once thought better of — that was me being way too kind — on how generalizable an exploit can be for it, and gosh is this a super noisy attack), we have an impending wave of attacks trying to take advantage of a flaw in a compression library most folks have never heard of.
Brotli is a compression format used on the web (and many other places). Node.js (this is why you’re here, so no ‘splainer) ships its own copy of the Brotli decoder. That decoder has a memory bug: a malformed compressed stream can write past the end of a buffer. The worst case scenario is remote code execution; a more realistic case is a crash or leaked process memory.
To reiterate: the flaw is in the runtime’s compression library, not in your application code. Updating npm packages, Express, or body-parser does not fix it. Only the Node.js version (or the Brotli library, if something else provides it) fixes it.
The CVE (weakness) is in the upstream Google Brotli library (1.0.3 through 1.2.0), so anything that bundles or links that decoder is affected, not just Node.js. That wider set is sometimes called HTTP2Shell.
In terms of severity, the Node.js project rates it High (👋); the CVE record scores it at 9.2 (Critical, CVSS 4.0). Public proof of concept exists, with no exploitation in the wild had been confirmed as of 24 October 2026.
Fixed versions
| What you run | Fixed in |
|---|---|
| Node.js 22.x | 22.23.4 |
| Node.js 24.x | 24.22.1 |
| Node.js 26.x | 26.12.1 |
| Node.js 25, 23, 20 and older | No fix. End of life. Move to 24 or 26. |
| Bun | 1.4.4 |
| Google Brotli (C / system library) | 1.0.10, 1.1.1, or 1.2.1 |
Python Brotli | 1.2.1 |
Python brotlicffi | 1.2.1.0 |
| Envoy | 1.37.8, 1.38.6, 1.39.3, 1.40.0 |
| Istio | 1.29.9, 1.30.6, 1.31.2 |
The Node.js rows come from the Node.js security release and GHSA-9v8h-qqgm-4m35; every other row is that project’s own released fix (i.e., check it against the project’s advisory before you trust the number here). Distro packages are the exception in both directions: packagers backport the fix without changing the version string, so a package that looks vulnerable may already be patched.
The only reliable question to ask is “which version actually runs?”
This is not (necessarily) the version on your laptop (though you should update that too). And, you might not want to trust what’s in a given package.json in a JS project. The binary that is executing right now in any given environment holds the truth.
Some commands to run in servers and VMs to see if Node is lurking:
node --version
which -a node # ids every node on PATH
bun --version 2>/dev/null
ps -eo pid,command | grep -E '[n]ode|[b]un' # ids what is actually running
If you upgrade a binary: great! That does not, however, help a process that is still running the old one (i.e., restart the service after upgrading).
We should check containers as well:
docker ps --format '{{.ID}} {{.Image}}'
docker exec <container> node --version
docker run --rm --entrypoint sh <image> -c 'node -v' # poke at stored images not necessarily currently running
For containers, a docker pull may not be enough. It an image was built on a base image with the old runtime you should rebuild it on a fixed base and redeploy. Here’s a one-liner to check across running containers:
docker ps -q | while read -r c; do
printf '%s ' "$(docker inspect -f '{{.Config.Image}}' "$c")"
docker exec "$c" node -v 2>/dev/null || echo 'no node'
done
If Podman is installed instead of Docker (which is what you should really be doing) use the same commands with sudo podman.
For CI, build machines, you should check their configurations as well. Examine the workflow’s node-version, the engines field, .nvmrc.
For PaaS environemnts, check the platform setting (Vercel, Netlify, Render, Fly.io, Heroku), then redeploy. Hopefully they did this for you or at least WAF’d you into temporary safety.
Python, curl, git, proxies
This Brotli debacle unfortunately goes beyond Node:
pip show Brotli brotlicffi 2>/dev/null | grep -Ei '^(Name|Version)'
curl --version | grep -i brotli
dpkg -l | grep -i brotli # or: rpm -qa | grep -i brotli
envoy --version 2>/dev/null; istioctl version 2>/dev/null
# there are more but if you're running them you should know what to do
Python matters (at least in this context) because requests, httpx, aiohttp and Scrapy use Brotli/brotlicffi automatically when installed, even if your code never imports them.
So, do you actually decode data you did not create?
The bug needs attacker-controlled compressed input to reach the decoder which can come you from two different directions:
- Inbound. A request body sent with
Content-Encoding: brthat your app or proxy decompresses. Express 5 and body-parser 2.x decode Brotli bodies when configured to; Envoy and Istio do it when request decompression is enabled. A bare Node.jshttpserver does not decode request bodies by itself. - Outbound. Your code fetches a URL you do not control, and the server answers with Brotli. Node’s built-in
fetch()decodes it automatically. Webhooks, link previews, image proxies and “fetch this URL” features are the usual places.
If neither path exists, your exposure is lower. It is still not zero, and patching remains the fix.
What you cannot learn from outside
You cannot truly see this from a “light-touch” scan. Node.js by default does not send its version and the Brotli library almost never appears in a response banner, so any service promising a “light-touch” remote version check is guessing.
A behavioural probe can only show whether a URL decodes Brotli request bodies, which is exposure, not vulnerability. The safe way is to send the same valid Brotli body twice, once with Content-Encoding: br and once without, and compare:
# valid brotli of {"probe":true}, then the same bytes with and without the header
node -e 'process.stdout.write(require("node:zlib").brotliCompressSync(Buffer.from("{\"probe\":true}")))' > body.br
curl -s -o r1 -w '%{http_code}\n' -X POST -H 'Content-Encoding: br' -H 'Content-Type: application/json' --data-binary @body.br https://target/api/echo
curl -s -o r2 -w '%{http_code}\n' -X POST -H 'Content-Type: application/json' --data-binary @body.br https://target/api/echo
Different responses mean the first request was decompressed, and identical responses mean no evidence of inbound decoding on that path.
Do not send the malformed stream or the multi-megabyte “wants more input” prefix to test for the bug. That is a denial-of-service test, and doing it to systems you do not own is both an “HULK SMASH” risk and a potential legal problem.
There are usually limits of this type of probing. One is that folks usually cover a singular path, usually /, which says nothing about other routes or about the outbound path. If your app is ehind a CDN or PaaS (Fastly, Platform.sh, and similar), the edge may do the decoding and you cannot patch that (again, hopefully you have a decent/capable provider).
A legit worked example from my fleet: two Node sites on one Caddy host
We have a couple subdomains off of inferenceandin.tel that (sadly) have Node in a Podman container (thanks, Glenn) behind a Caddy proxy.
Step 1 — find what is actually running. node was not installed on the host at all, so a PATH check would have found nada. The running processes are where we need to look:
ps -eo pid,user,args | grep -E '[n]ode|[b]un'
# node server/api/app.js <- kev web
# node server/ingest/run.js <- kev ingest
# node /app/ingest/refresh.mjs <- agenda ingest
Step 2 — versions inside the containers. We need to check each container’s runtime for version ground truth:
sudo podman ps --format '{{.Image}} {{.Names}}'
for c in kev-dashboard_web_1 kev-dashboard_ingest_1 unprompted-agenda_web_1 unprompted-agenda_ingest_1; do
printf '%-28s ' "$c"; sudo podman exec "$c" node -v 2>/dev/null || echo 'no node'
done
kev-dashboard_web_1 v22.23.4
kev-dashboard_ingest_1 v22.23.4
unprompted-agenda_web_1 no node
unprompted-agenda_ingest_1 v22.23.4
Since I know what these are, we have:
| Site | What actually serves it | Node version | Status |
|---|---|---|---|
kev.inferenceandin.tel | Express app, Node in web + ingest containers | 22.23.4 | Fixed |
unprompted-agenda.inferenceandin.tel | nginx 1.27.5 serving files; a Node ingest worker beside it | 22.23.4 in the worker | Fixed |
(FWIW we did apply the fix after the initial notice went out.)
Absolutely use pinning in the Dockerfile so you can keep track of versions (i.e. FROM node:22.23.4-alpine).
Step 4 — showing the limits of an outside prove. Posting the probe body to https://kev.inferenceandin.tel/ returned an identical Express 404 (Cannot POST /) with and without Content-Encoding: br. The app is configured to refuse unknown routes before it ever parses a body, so the probe was more like “inconclusive” rather than “reassuring”. The agenda site answered 405 from nginx both ways, which only says the front end serves files. The version check (above) was the only way to answer the question.
”What if I cannot patch today?”
Well, you should at least try to reduce exposure. NOTE: the following guidance is a band-aid, not a fix:
- Reject inbound requests whose
Content-Encodingincludesbr, at the proxy or in middleware, case-insensitively and inside lists likegzip, br. - Remove
brfrom outboundAccept-Encoding, and reject Brotli responses before decoding. A malicious server can ignore what you advertise, so the rejection matters more than the advertising. - Python: uninstall
Brotliandbrotlicffiwhere nothing needs them, then rebuild images. - Envoy/Istio: disable request decompression for Brotli.
A web application firewall (WAF) is not 100% reliale here as the body is decoded inside the runtime, after the perimeter.
Free tools worth using
- OSV-Scanner, Trivy, Grype + Syft — free, open source; find vulnerable packages and distro libraries in lockfiles and images. They cannot see the Node.js runtime version unless it is a package in the image.
- osv.dev and the GitHub advisory — machine-readable ranges.
- Zybee’s free check — tells you whether a public site runs Node.js at all, deliberately not the version.
pip-audit,npm audit, Dependabot — free, but they check dependencies, not the runtime, and advisory-to-PyPI mapping for this CVE landed late.
Limits and gotchas
- End-of-life Node lines (25, 23, 20 and older) will never get a fix.
- Linux distributions often backport the fix without changing the version number. A package that looks vulnerable may already be patched; check the vendor advisory.
- Node.js embedded in Electron apps, single-executable applications, and vendor appliances is invisible to ordinary scans. Ask the vendor.
- Pulling a container image is not rebuilding it. Rebuild on a fixed base image.
- An unused old base image on disk is not a finding, and a pinned fixed tag in the Dockerfile is not a finding either — check what runs.
- Upgrading without restarting leaves the old binary running.
- A clean dependency scan proves nothing about the runtime.
- “Not affected” from any scan means one path did not decode. It is not a clearance.
- One host can mix runtimes behind many names. Check each vhost; a Node build dependency does not make an nginx front end a Node app.
- The outbound direction (fetching attacker-controlled URLs) cannot be seen from outside at all.
FIN
The answer to “am I exposed” is boring, likely tedious, and very local: the version string printed by the binary that is actually serving traffic. If it is on the fixed list above, this CVE is closed for you until the next one. If it is not, nothing else in this post substitutes for the upgrade — not the probe, not the dependency scan, and not a vendor’s assurance that the perimeter handles it.
Sources used for authoritative info: