Node2Shell and the question that follows: how to install, manage and update Node.js
The Brotli flaw in Node.js explained briefly, the parallel to PHP2Shell — and every way to install and update Node.js, from NodeSource to Docker.
by Jean Pierre Kolb ·
On the same Wednesday that PHP published its patches against PHP2Shell, Node.js closed a critical flaw as well: a bug in the Brotli decoder through which crafted compressed data can execute code. By now it has a nickname, "Node2Shell", and on 23 October Aikido (opens in a new tab) showed that the same decoder also sits inside widely used Python packages.
In the PHP2Shell article I wrote about why static sites stay calm in a week like that: no PHP runs when they are served. That's true, but it's only half the story. These sites — this knowledge base included — are built with Node.js. That makes Node2Shell a good occasion for a question I deliberately left out of Working securely with Node.js and npm: how does Node.js actually get onto the build machine, and how quickly does a security fix follow?
The article has three parts: first, briefly, what's behind Node2Shell and what links it to PHP2Shell. Then what this means for projects that generate static websites with Node.js. And then, as the largest part, all the common ways to install, manage and update Node.js — with quick guides, tips and the places where things go wrong. I measured several of the commands and figures myself in fresh containers.
The short version
| What | Status |
|---|---|
| Flaw | CVE-2026-111497, out-of-bounds write in Google's Brotli decoder, versions 1.0.3 to 1.2.0 |
| Severity | CVSS 4.0: 9.2 (critical) in the CVE record; Node.js itself rates it "high" |
| Fixed in | Node.js 22.23.4, 24.22.1, 26.12.1 (21 October 2026); Brotli 1.0.10, 1.1.1, 1.2.1; Bun 1.4.4 |
| Attack path | Node unpacks attacker-controlled Brotli data — above all responses to fetch(), inbound with matching middleware, plus any code that unpacks untrusted Brotli data |
| Exploitation | code execution shown by Imperva in the lab, exploit not published; a public proof of concept crashes standard builds and leaks memory; no attacks in the wild known |
| Workaround | only as a stopgap: don't unpack Brotli from untrusted sources; the fix is updating the runtime |
| For static sites | serving unaffected; the build is affected as soon as it fetches data from third-party servers |
| My approach | NodeSource package repositories on Debian and Ubuntu, updates via apt |
What's behind Node2Shell
Brotli is a compression format from Google that web servers and browsers use for HTTP responses (Content-Encoding: br). Node.js ships its own copy of the Brotli decoder. A Brotli stream is divided into blocks, and the decoder switches between their types. In Brotli versions 1.0.3 to 1.2.0, the function responsible could not report an impossible block switch as an error: the streaming path read it as "need more input", and a fast path ignored the return value entirely. So the decoder wrote more of the attacker's bytes past the end of an internal buffer. According to the Node.js security release (opens in a new tab), this can lead to a crash, disclosure of process memory or code execution.
The back story is remarkable: the correction has been in the Brotli source code (opens in a new tab) since 27 February 2026, under the inconspicuous commit message "Detect and report invalid stream earlier." It only became public as a security problem and shipped in releases on 21 October. According to the CVE record, it was found by Khai Tran and Sylvie Mayer of OpenAI Codex Security; according to OpenAI as early as February, without it being clear at the time that the fix closed a security hole.
The name "Node2Shell" doesn't come from Node.js. Imperva's (opens in a new tab) red team uses it for its analysis, in which it demonstrated code execution against an unmodified official Node.js 24.22.0 image, but it hasn't published the exploit. Because the bug lies in Brotli itself and reaches far beyond Node, "HTTP2Shell" and "Broccoli2Shell" are also going around. Since 22 October there has also been a public proof of concept; against standard builds, however, it only crashes Node and leaks a limited amount of memory, and it executed code only on a deliberately weakened test build. According to The Hacker News (opens in a new tab), no attacks in the wild were known as of 22 October, and CISA has not added the flaw to its catalogue of known exploited vulnerabilities.
Where does Node decompress Brotli? The advisory names the node:zlib module. What matters, though, is who calls it:
fetch()decompressesbrautomatically. If a server responds withContent-Encoding: br, Node unpacks the response without your code noticing. A malicious or compromised server can attack an unpatched Node process that queries it this way. Removingbrfrom theAccept-Encodingheader doesn't help reliably, because an attacker sends Brotli unasked as well.- A plain
node:httpserver does not decompress incoming data. Inbound, it only becomes dangerous with middleware that unpacks request bodies withContent-Encoding: br; Imperva and Aikido name Express 5 and body-parser 2. - Other runtimes and languages: Bun is fixed in 1.4.4. Also affected, among others, are the Python packages
Brotliandbrotlicffi, Envoy and Istio where Brotli decompression is enabled, and curl through its Brotli library when you fetch with--compressed. Aikido's list is long. - Not just HTTP: according to Patch the Planet, any code that unpacks untrusted Brotli data is affected, including tools for WOFF2 fonts and data files.
One trap when checking: node -p process.versions.brotli still shows 1.2.0 in the fixed version 24.22.1 (Node 22 stays at 1.1.0), because Node took over the fix as a single commit, not the new Brotli version. I checked this in a fresh container. The only thing that counts is the Node version: node -v must show at least v22.23.4, v24.22.1 or v26.12.1.
The parallel to PHP2Shell — and the difference
The two flaws have more in common than their release day. Both are listed on Patch the Planet (opens in a new tab), an OpenAI initiative that works with AI and security experts such as Trail of Bits and Calif to validate findings in open-source projects, develop patches and coordinate disclosure with maintainers. Both sit one level below the application, so no update of WordPress, Express or a plugin helps, only one of the runtime. And in both cases the root cause lay around for a long time before anyone recognised it as a security problem — for PHP since an audit in 2024, for Brotli since the silent fix in February.
The difference lies in the attack surface. PHP2Shell was present in every PHP server and could be triggered by a crafted request before any application code ran, most dangerously with Apache and mod_php. Node2Shell needs a Brotli decoder in the data path, and that sits mainly on the outbound side: in every Node process that fetches data from a third-party server. That affects crawlers, webhook receivers, link previews — and build processes. A web application firewall in front of your server never sees these responses. The stopgap is not to unpack untrusted Brotli data at all: inbound, reject requests with Content-Encoding: br before the body parser; outbound, pause the affected fetch() calls. Because fetch() can't be talked out of automatic decompression, the update is the real fix.
What this means for static websites
Nothing changes for serving: a finished HTML file on Apache or nginx doesn't decompress Brotli data from attackers. It gets interesting during the build.
A generator such as Eleventy, Astro or Hugo with Node tooling is, for the duration of the build, a Node process that runs code and sometimes fetches data from the network. It is affected as soon as it unpacks Brotli data from untrusted sources, and that happens mostly with fetch() against third-party servers:
- Headless CMSs and APIs whose content flows into the build.
- Remote data in
_datafiles, such as JSON from a service. @11ty/eleventy-fetch(opens in a new tab) and remote images with@11ty/eleventy-img. Since version 5, eleventy-fetch uses Node's globalfetch(), older versions use node-fetch. Both unpack a Brotli response automatically throughnode:zlib, so every version is affected.- Build scripts that fetch feeds, statistics or preview images from other sites.
- Tools for fonts and data files that unpack Brotli-compressed files from untrusted sources, such as WOFF2 fonts.
npm itself is less critical. In my measurement, npm 11 sends the registry the header Accept-Encoding: gzip,deflate and gets gzip back; so in normal operation, no Brotli arrives. I wouldn't rule it out completely, though: the registry serves Brotli if a client offers it, and npm unpacks Brotli even when it didn't ask for it. A compromised mirror or proxy in between can still reach the decoder that way.
The risk is manageable if you only trust your own sources. An attacker would have to control a server your build queries. But that's exactly what happens in supply-chain attacks all the time: taken-over domains, compromised CMS instances, hijacked CDNs. And a build machine is a worthwhile target, because it holds credentials for deployments, repositories and servers.
So for me, the real lesson from Node2Shell is a different one: how quickly does the fix reach your build machine — and do you even know? On 21 October, that looked very different depending on how Node was installed:
| Route | Fixed version available (21 October, UTC, amd64) |
|---|---|
| nodejs.org, release on GitHub | ~10:38 |
Snap (node, channel 24) |
12:30 |
Docker, node:24 and node:24-slim |
~14:40 |
Docker, node:lts |
15:56 at the latest |
NodeSource (node_24.x) |
16:17 |
| Debian 12 and 13 (via the system library libbrotli) | 23 October |
| Ubuntu (via libbrotli) | still pending at the time of writing |
| Version managers (nvm, fnm, n, mise) | immediately, as soon as you run them |
| DDEV with the default setting | only with the next DDEV release |
I read the times for NodeSource, Snap and Docker from the timestamps of the package repositories and registries; for NodeSource it's the package's timestamp. For other architectures some builds came later, with Docker for ppc64le only during the night. The table already shows what the rest is about: every route has its own delay and its own mechanism by which the update reaches you — or doesn't.
How to install Node.js: the routes at a glance
Before going into detail, a look at the release schedule, because it determines which version you want in the first place. Up to Node.js 26, two major versions appeared each year, and the even-numbered ones became long-term support (LTS) versions. According to the release schedule (opens in a new tab), Node.js 24 has been in the maintenance phase since 20 October and gets security updates until April 2028. Node.js 26 becomes the active LTS version on 28 October. Node.js 22 runs until April 2027, and Node.js 20 reached its end in April 2026. From Node.js 27 onwards, the model changes (opens in a new tab): only one major version per year, and every one becomes LTS. An alpha channel then replaces odd-numbered versions for testing upcoming changes.
For static websites this means: take an LTS version, so 24 today and 26 in the coming weeks. Current and alpha versions are for experiments, not for builds.
| Route | Good for | Updates come via | Several versions |
|---|---|---|---|
| NodeSource | servers and workstations on Debian/Ubuntu | apt, automatically if you like | no, one major version per system |
| Distribution | system tools that need Node on the side | the distribution's security updates | no |
| Official binaries | special cases, systems without a package repository | manual work | yes, manually |
| Snap | Ubuntu desktops | automatic snap refresh | via channels |
| Version managers | development machines with several projects | their own command, per user | yes |
| Containers and CI | reproducible builds | a new image or action run | yes, per image |
My favourite: the NodeSource package repositories
On Debian and Ubuntu systems, I prefer to install Node.js from the NodeSource (opens in a new tab) package repositories. The reason is simple: I get the current LTS version as a normal .deb package, and it's updated with apt like any other package — automatically, if I want. There's a separate repository for each major version (node_22.x, node_24.x, node_26.x), so an update never switches the major version unnoticed. The nodejs package includes npm and npx, and in Node 24 also still Corepack.
Quick guide with the setup script
NodeSource offers a setup script. It's about 120 lines long and easy to read — look at it before you run it:
curl -fsSL -o nodesource_setup.sh https://deb.nodesource.com/setup_24.x
less nodesource_setup.sh
sudo bash nodesource_setup.sh
sudo apt install nodejs
node -v && npm -vThe script installs a few prerequisites, stores the signing key at /usr/share/keyrings/nodesource.gpg, writes the package source in the new deb822 format to /etc/apt/sources.list.d/nodesource.sources and creates a pin with priority 600 in /etc/apt/preferences.d/nodejs. That makes apt prefer the NodeSource package over the distribution's nodejs. Besides setup_24.x there are setup_22.x, setup_26.x, and setup_lts.x and setup_current.x, which point to the current LTS and Current lines respectively.
Quick guide without the script
If you'd rather do every step yourself, it also works by hand:
sudo apt install -y ca-certificates curl gnupg
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| sudo gpg --dearmor -o /usr/share/keyrings/nodesource.gpg
sudo tee /etc/apt/sources.list.d/nodesource.sources > /dev/null <<'EOF'
Types: deb
URIs: https://deb.nodesource.com/node_24.x
Suites: nodistro
Components: main
Architectures: amd64
Signed-By: /usr/share/keyrings/nodesource.gpg
EOF
sudo tee /etc/apt/preferences.d/nodejs > /dev/null <<'EOF'
Package: nodejs
Pin: origin deb.nodesource.com
Pin-Priority: 600
EOF
sudo apt update && sudo apt install -y nodejsOn ARM machines, write arm64 instead of amd64. You can check the key first: gpg --show-keys --with-fingerprint /usr/share/keyrings/nodesource.gpg must show the fingerprint 6F71 F525 2828 41EE DAF8 51B4 2F59 B5F9 9B1B E0B4.
Installing updates automatically
For me, this is the biggest advantage. With unattended-upgrades, the system installs security updates by itself — by default, though, only from the distribution's sources. You have to allow NodeSource explicitly. The usual approach via Origin and Label doesn't work well here, because NodeSource's release file only carries . nodistro there. It's cleaner to allow the source by its host:
echo 'Unattended-Upgrade::Origins-Pattern { "site=deb.nodesource.com"; };' \
| sudo tee /etc/apt/apt.conf.d/52nodesource-unattended
sudo unattended-upgrade --dry-run --debug 2>&1 | grep -i nodejsI tested this in a fresh Debian 13 container: with 24.22.0 installed, unattended-upgrade recognised the 24.22.1 package from NodeSource as an update and downloaded it. The line adds to the default sources; it doesn't replace them.
One point applies to every installation route: a running Node process keeps using the old code in memory. Services that run permanently — a Node server, a worker, a build daemon — have to be restarted after the update. On Debian and Ubuntu, sudo needrestart -r l (package needrestart, which you usually have to install first on Debian) shows which processes still use old code. For a static build that starts fresh on every run, this isn't an issue.
Tips and pitfalls
- Switching the major version: run the setup script of the new line (for example
setup_26.x), thensudo apt install nodejs. It removes the old source itself first. By hand, change theURIsline tonode_26.x. - Installing a specific version:
apt-cache policy nodejsshows all available versions, andsudo apt install nodejs=24.22.1-1nodesource1installs exactly one. - Don't also install
npmfrom the distribution. The NodeSource package already contains npm and conflicts with the distribution packagenpm. Some older guides sayapt install nodejs npm— that's wrong for NodeSource. apt-mark hold nodejsis usually the wrong brake. It freezes security updates too. Because each NodeSource repository only contains one major version, you don't need an extra lock against big jumps.- Architectures: the script only supports
amd64andarm64. For an older Raspberry Pi with a 32-bit system, you need a different route. - N|Solid lives in the same repository. That's NodeSource's commercial runtime with monitoring; for normal projects, install only
nodejs. - Removing it cleanly:
sudo apt purge nodejs, then delete/etc/apt/sources.list.d/nodesource.sources,/usr/share/keyrings/nodesource.gpgand the filenodejsin/etc/apt/preferences.d/, plusnsolidwith the script route. The instructions in the NodeSource repository still list the old paths from before deb822. - For RPM systems the same exists at
rpm.nodesource.com, withsetup_24.xfor Fedora, RHEL and relatives.
The distribution's packages
The most obvious route is sudo apt install nodejs from the Debian or Ubuntu repositories. The problem is age:
| Distribution | nodejs in the default repository |
Status of the Node line at Node.js |
|---|---|---|
| Debian 12 "bookworm" | 18.20.4 | ended April 2025 |
| Debian 13 "trixie" | 20.19.2 | ended April 2026 |
| Ubuntu 24.04 LTS | 18.19.1 | ended April 2025 |
| Ubuntu 26.04 LTS | 22.22.1 | maintenance until April 2027 |
That doesn't mean these packages are insecure. Debian and Ubuntu backport security fixes for their versions themselves, even beyond the official end of a Node line. Node2Shell shows how that works: Debian builds Node against the system's Brotli library instead of the bundled copy. So the fix didn't come as a new Node package but via libbrotli1 — with DSA-6582-1 (opens in a new tab) on 23 October for Debian 13 and an LTS update for Debian 12. From 22.04 on, Ubuntu rates its nodejs as not affected for the same reason (20.04 is marked "needed"); the update for the Brotli library, however, was still pending at the time of writing.
For work on current web projects, these versions still rarely fit. With Node 18 on Debian 12 and Ubuntu 24.04 you have a line that many tools no longer support, and on Debian npm is a separate, much older package (npm 9.2 in Debian 13). Above all, you want the same LTS version locally as in CI and in the container, and with distribution packages that's decided by the distribution, not by you. They're good for system tools that need Node on the side.
The official binaries from nodejs.org
nodejs.org (opens in a new tab) offers ready-made archives for Linux, macOS and Windows. That's the route for systems without a suitable package repository, or when you need one specific version. With checksum and signature verification, it looks like this:
VER=v24.22.1
curl -fsSLO "https://nodejs.org/dist/$VER/node-$VER-linux-x64.tar.xz"
curl -fsSLO "https://nodejs.org/dist/$VER/SHASUMS256.txt.asc"
curl -fsSLo nodejs-keyring.kbx https://github.com/nodejs/release-keys/raw/HEAD/gpg/pubring.kbx
# Verify the signature, then the checksum of the archive
gpgv --keyring=./nodejs-keyring.kbx --output SHASUMS256.txt < SHASUMS256.txt.asc
sha256sum --check --ignore-missing SHASUMS256.txt
# Install into its own directory and link it
sudo mkdir -p /opt/node
sudo tar -xJf "node-$VER-linux-x64.tar.xz" -C /opt/node
sudo ln -sfn "/opt/node/node-$VER-linux-x64" /opt/node/current
echo 'export PATH="/opt/node/current/bin:$PATH"' >> ~/.zshrcThe catch is obvious: updates only happen when you do them. For a server that needs Node permanently, this is the worst of the routes — unless you automate it yourself.
Snap
On Ubuntu, Snap is a convenient alternative. The node package comes from the OpenJS Foundation, i.e. from the circle of the Node project itself, and was one of the first routes to be fixed on 21 October:
sudo snap install node --classic --channel=24/stable
node -v
# Later, switch the major version:
sudo snap refresh node --channel=26/stableSnaps update themselves automatically within their channel, so the security update arrived without you doing anything. In return, you accept Snap's peculiarities: --classic lifts the usual confinement, the paths differ (/snap/bin), and on servers without Snap support the route is out.
Version managers: nvm, fnm, n, mise and co.
On development machines with several projects, version managers are the usual route. They install Node.js per user, often several versions side by side, and switch per project. The page nodejs.org/download (opens in a new tab) itself recommends nvm and fnm for Linux and macOS, alongside Docker. I took a closer look at the common tools and measured two things that rarely appear in comparisons: whether they verify the Node archives they download, and what they cost when a new zsh starts.
| Tool | Written in | Verifies downloads | Shell startup (measured) | Global packages on a patch update |
|---|---|---|---|---|
| nvm (opens in a new tab) 0.40.8 | shell | SHA-256 | +0.5 to 1 s, with --no-use just over +100 ms |
lost, carried over with --reinstall-packages-from |
| fnm (opens in a new tab) 1.39.0 | Rust | no, HTTPS only | barely measurable (< 15 ms) | lost |
| n (opens in a new tab) 11.0.0 | Bash | no, HTTPS only | no shell hook | kept |
| mise (opens in a new tab) 2026.10 | Rust | SHA-256 and signature | a few dozen ms | lost, tools via mise instead |
| asdf 0.20 / nodenv 1.6 (with node-build) | Go / Bash | SHA-256 from a fixed list | — | separate per version |
I measured the startup times in a fresh Debian 13 container, as the mean of 20 starts of zsh -i -c exit, compared with a zsh without a version manager (8 to 16 ms). The absolute numbers depend heavily on the machine and its load: in an independent repeat run, nvm came in at 0.7 to 1 second, and the order stayed the same in every run. I looked up the download verification in the source code. That fnm and n trust the archives only via HTTPS isn't a disaster — but nvm and mise show it can be done better, and mise is the only one that also verifies the signature of the checksums.
Volta is missing from the table on purpose: since 2025 its README has said "Volta is unmaintained" and recommends moving to mise.
Quick guides
nvm is the classic, per user, in pure shell code:
curl -fsSL -o install-nvm.sh https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.8/install.sh
less install-nvm.sh && bash install-nvm.sh # adds the loader to ~/.zshrc
nvm install 24
# Security update within the 24 line, keeping global packages:
nvm install 24 --reinstall-packages-from=currentnvm doesn't switch automatically via .nvmrc when you enter a directory; the README provides a separate zsh hook with add-zsh-hook chpwd for that. And the half second of startup time is real. If it bothers you, there are two routes: --no-use after the nvm.sh call skips the automatic activation, but then you have to run nvm use yourself in each new shell before node is available. Oh My Zsh's nvm plugin with zstyle ':omz:plugins:nvm' lazy yes loads nvm only when you call nvm, node or npm.
fnm is fast and can read .nvmrc and .node-version directly:
curl -fsSL https://fnm.vercel.app/install | bash -s -- --skip-shell
# in ~/.zshrc:
eval "$(fnm env --use-on-cd --shell zsh)"
fnm install 24 && fnm default 24 # 24 resolves to the newest 24.xn has no shell hook; instead it replaces the one Node version under /usr/local (or your own N_PREFIX). That makes it simple and visible to all users and services — DDEV uses it internally:
npm install -g n # or bootstrap without npm, see the README
sudo n lts # or: sudo n 24mise manages Python, Go and many other tools besides Node and is the successor Volta itself recommends:
curl -fsSL https://mise.run | sh # or via extrepo/apt, see the docs
# in ~/.zshrc:
eval "$(~/.local/bin/mise activate zsh)"
mise use -g node@24
mise upgrade node # newest 24.xOne quirk: mise only reads .nvmrc and .node-version once you allow it with mise settings add idiomatic_version_file_enable_tools node. Without that, it silently ignores the files.
The typical trap of shell-hook version managers
nvm, fnm and mise hook into your interactive shell; n doesn't, it installs into /usr/local. A systemd service, a cron job or a sudo call doesn't see that shell — and finds either no Node at all or the old one from the distribution. That's why a version manager is the wrong route for permanently running services on a server. If you must, use mise's shims or mise exec, whose path stays the same across updates. With nvm, enter the absolute path (nvm which 24 reveals it) and remember that it changes with every update.
Containers, DDEV and CI
Docker. The official images (opens in a new tab) come in default, slim and alpine variants. A few things you should know:
node:24is currently based on Debian 12 "bookworm",node:26on Debian 13 "trixie". If you want Node 24 on trixie, writenode:24-trixie-slim.- According to the image docs,
node:ltsfollows the active LTS version. This week the tag still points to 24, which is already in maintenance. When Node 26 becomes LTS on 28 October, it is likely to jump to 26 — with a new major version and a new Debian base. For builds I therefore pin the major version, for exampleFROM node:24-bookworm-slim. - The Alpine variants with musl are considered experimental by the Node project. Unless you have a good reason, take
slim. - A digest pin (
node:24-bookworm-slim@sha256:…) makes builds exactly reproducible, but it also prevents security updates as long as no tool such as Renovate or Dependabot updates it. - After a security release, a restart isn't enough:
docker build --pullfetches the new base image.
DDEV. If you build your projects in DDEV (opens in a new tab), this is where the biggest surprise lies. DDEV installs Node in the web container with n, and the nodejs_version setting decides what happens. The default value 24 uses the version preinstalled in the image of the installed DDEV version — with DDEV 1.25.4 from September, that is necessarily one older than 24.22.1. The simplest way to get the fix is this; an exact version such as "24.22.1" works too, but then you have to bump it with every release:
nodejs_version: "v24" # newest 24.x at the time the image is builtThen rebuild the image: ddev restart --no-cache or ddev utility rebuild. The version stays fixed until the next rebuild; after the next Node security release, repeat the rebuild. With nodejs_version: "auto", DDEV reads the version from your project's .nvmrc, .node-version or engines.node.
GitHub Actions. actions/setup-node (opens in a new tab) reads the version with node-version-file from .nvmrc, .node-version, .tool-versions, mise.toml or package.json. With check-latest: true, the action fetches the newest matching version instead of the one cached on the runner — right after a security release, that's the difference.
macOS and Windows in brief: on macOS, Homebrew installs the Current version with brew install node; you get the LTS version with brew install node@24, which you have to put on your PATH yourself. On Windows there's winget install OpenJS.NodeJS.LTS and the official installer.
Pinning the Node version in the project
However Node is installed, the project should say which version it needs. There are several files for that, and not every tool reads every one:
| File | read by |
|---|---|
.nvmrc |
nvm, fnm, n, setup-node, DDEV (auto); asdf and mise once allowed |
.node-version |
fnm, n, nodenv, setup-node, DDEV (auto); asdf and mise once allowed |
.tool-versions / mise.toml |
asdf and mise, setup-node |
engines.node in package.json |
npm (only as a warning), fnm, n, setup-node, DDEV |
devEngines.runtime in package.json |
npm (aborts by default), n 11, setup-node; mise once allowed |
Two notes on this. For your own project, engines is only a recommendation: npm warns but installs anyway unless engine-strict is set. The newer devEngines (opens in a new tab) field is meant precisely for people working on the source code, and npm aborts by default on a wrong version:
{
"devEngines": {
"runtime": { "name": "node", "version": ">=24.22.1" }
}
}With the patch version as the lower bound, after a security release the field prevents anyone from accidentally building with the vulnerable version. An .nvmrc with 24 next to it doesn't hurt and serves the tools that don't know devEngines yet.
Hearing about security releases in time
The best update process is of little use if you don't know there's something to do. The Node project offers:
- the feed nodejs.org/en/feed/vulnerability.xml (opens in a new tab) for any feed reader,
- the nodejs-sec (opens in a new tab) mailing list, which only sends announcements and has little traffic,
- and the
is-my-node-vulnerabletool from the Node.js organisation, which checks your running version against the security database:npx is-my-node-vulnerable. It fetches the data fresh on every run and works well as a step in a CI pipeline. Like every npm tool, I run it through Socket Firewall.
Tips from practice
- One installation method per system. Node from NodeSource plus nvm in your user account sooner or later leads to the question of which
nodeis actually running.command -v nodeandtype -a nodeshow it. - After a security release, go through every place: workstations, build servers, CI, container base images, DDEV projects. The table above shows that each place has its own pace.
- Check
node -v, notprocess.versions.brotli. In Node 24 the fix came as a single commit; the Brotli version string stayed the same. - Restart long-running Node processes. An update on disk doesn't help the process in memory.
- Keep build dependencies on third-party servers deliberate. Every
fetch()in the build is a server you trust. Where possible, cache remote data instead of fetching it on every build.
FAQ
Is my static website affected by Node2Shell?
The served pages aren't, because no Node runs there. The build is affected if it uses a vulnerable Node version to fetch data from third-party servers via fetch() or otherwise unpacks untrusted Brotli data, such as WOFF2 fonts. Update Node on your build machine and in CI to 22.23.4, 24.22.1 or 26.12.1.
Does removing br from Accept-Encoding help?
Not reliably. A malicious server can send Brotli even if the client didn't offer it, and fetch() then decompresses it anyway. Node.js itself names no workaround; Patch the Planet advises not unpacking untrusted Brotli until you have updated. The solution is the update.
I use my distribution's Node version. Do I need to do anything?
Yes, but differently: on Debian, the fix comes via the libbrotli1 package, which a normal sudo apt upgrade brings along. Afterwards, running Node processes need a restart. On Ubuntu, check the security page for the CVE (opens in a new tab).
NodeSource, nvm or Docker — which is safest?
None of the routes is safer in itself. The safe route is the one through which the fix reaches you reliably and quickly. On servers, a lot speaks for a package manager with automatic updates, on development machines for a version manager, in CI for pinned images with a bot that keeps them current.
Why does process.versions.brotli still show the old Brotli version after the update?
Because Node took over the fix as a single commit into its bundled Brotli copy without changing the version string: Node 24 and 26 still show 1.2.0, Node 22 shows 1.1.0. Check node -v instead.
Is Corepack still included?
In Node.js 24 yes, as an experimental feature. From Node.js 25 onwards, the project no longer ships Corepack; if you need it, install it with npm install -g corepack, and tools such as mise take over the job themselves.
Conclusion
Node2Shell is not a second PHP2Shell. The flaw is serious, but it mainly affects Node processes that unpack data from third-party servers — and thus also builds that fetch remote data. The answer is the same as with PHP: update the runtime, because no application update helps.
Which installation route is right depends on where Node runs. On Debian and Ubuntu servers, the NodeSource package repositories are for me the best mix of current version and maintenance effort, because updates come via apt and can be automated. The distribution packages are well maintained but too old for web projects. Snap is fast and automatic if you use Snap anyway. Version managers belong on development machines — with mise as the most thorough verifier, fnm as the fastest and nvm as the best known but sluggish one. Containers and CI need pinned major versions and someone who rebuilds the images after a security release. And DDEV needs a deliberate rebuild after such a release.
In the end, what matters isn't the route but that, for every place where Node runs for you, you know the answer to one question: how does the next fix get here, and how long does it take?
Further reading
What PHP2Shell looked like and why static sites stay calm when served is in PHP2Shell. The other half of hardening, i.e. npm, lockfiles and install scripts, is described in Working securely with Node.js and npm. The commands for node, npm and nvm are in the cheat sheets. I've written about the two other JavaScript runtimes in Anthropic and Bun and Deno joins Cloudflare.
Sources: Node.js security release of 21 October (opens in a new tab) · Brotli fix 0ed4268 (opens in a new tab) · Imperva: Node2Shell (opens in a new tab) · Aikido: Python packages (opens in a new tab) · Patch the Planet: Brotli (opens in a new tab) · Node.js release schedule (opens in a new tab) · NodeSource distributions (opens in a new tab)