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 ·

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() decompresses br automatically. If a server responds with Content-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. Removing br from the Accept-Encoding header doesn't help reliably, because an attacker sends Brotli unasked as well.
  • A plain node:http server does not decompress incoming data. Inbound, it only becomes dangerous with middleware that unpacks request bodies with Content-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 Brotli and brotlicffi, 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 _data files, 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 global fetch(), older versions use node-fetch. Both unpack a Brotli response automatically through node: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 -v

The 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:

Terminal
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 nodejs

On 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 nodejs

I 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), then sudo apt install nodejs. It removes the old source itself first. By hand, change the URIs line to node_26.x.
  • Installing a specific version: apt-cache policy nodejs shows all available versions, and sudo apt install nodejs=24.22.1-1nodesource1 installs exactly one.
  • Don't also install npm from the distribution. The NodeSource package already contains npm and conflicts with the distribution package npm. Some older guides say apt install nodejs npm — that's wrong for NodeSource.
  • apt-mark hold nodejs is 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 amd64 and arm64. 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.gpg and the file nodejs in /etc/apt/preferences.d/, plus nsolid with 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, with setup_24.x for 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:

Terminal
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"' >> ~/.zshrc

The 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/stable

Snaps 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=current

nvm 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.x

n 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 24

mise 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.x

One 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:24 is currently based on Debian 12 "bookworm", node:26 on Debian 13 "trixie". If you want Node 24 on trixie, write node:24-trixie-slim.
  • According to the image docs, node:lts follows 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 example FROM 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 --pull fetches 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:

.ddev/config.yaml
nodejs_version: "v24"   # newest 24.x at the time the image is built

Then 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:

package.json
{
  "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:

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 node is actually running. command -v node and type -a node show 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, not process.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)