# 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.

Source: https://www.jpkc.com/db/en/blog/node2shell-nodejs-installieren/

On the same Wednesday that PHP published its patches against [PHP2Shell](https://www.jpkc.com/db/en/blog/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](https://www.aikido.dev/blog/python-packages-vulnerable-node2shell-brotli-cve-2026-111497) 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](https://www.jpkc.com/db/en/blog/nodejs-npm-absichern/): 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](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases), 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](https://github.com/google/brotli/commit/0ed4268a4401abcb44720963) 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](https://www.imperva.com/blog/node2shell-from-brotli-data-to-remote-code-execution/) 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](https://thehackernews.com/2026/10/researchers-demonstrate-node2shell-rce.html), 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](https://patchtheplanet.org/brotli), 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`](https://www.11ty.dev/docs/plugins/fetch/) 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](https://github.com/nodejs/Release), 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](https://nodejs.org/en/blog/announcements/evolving-the-nodejs-release-schedule): 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](https://nodesource.com/) 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:

```bash
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:

```bash
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:

```bash
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](https://lists.debian.org/debian-security-announce/2026/msg00498.html) 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](https://nodejs.org/en/download) 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:

```bash
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:

```bash
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](https://nodejs.org/en/download) 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](https://github.com/nvm-sh/nvm) 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](https://github.com/Schniz/fnm) 1.39.0 | Rust | no, HTTPS only | barely measurable (< 15 ms) | lost |
| [n](https://github.com/tj/n) 11.0.0 | Bash | no, HTTPS only | no shell hook | kept |
| [mise](https://mise.jdx.dev/) 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:

```bash
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:

```bash
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:

```bash
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:

```bash
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](https://hub.docker.com/_/node) 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](https://ddev.com/), 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:

```yaml title=".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`](https://github.com/actions/setup-node) 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`](https://docs.npmjs.com/cli/configuring-npm/package-json#devengines) field is meant precisely for people working on the source code, and npm aborts by default on a wrong version:

```json title="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:

- the feed [nodejs.org/en/feed/vulnerability.xml](https://nodejs.org/en/feed/vulnerability.xml) for any feed reader,
- the [nodejs-sec](https://groups.google.com/g/nodejs-sec) mailing list, which only sends announcements and has little traffic,
- and the `is-my-node-vulnerable` tool 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](https://www.jpkc.com/db/en/blog/nodejs-npm-absichern/#building-block-1-socket-firewall-sfw).

## 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](https://ubuntu.com/security/CVE-2026-111497).

### 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](https://www.jpkc.com/db/en/blog/php2shell/). The other half of hardening, i.e. npm, lockfiles and install scripts, is described in [Working securely with Node.js and npm](https://www.jpkc.com/db/en/blog/nodejs-npm-absichern/). The commands for [node](https://www.jpkc.com/db/en/cheatsheets/build-languages/node/), [npm](https://www.jpkc.com/db/en/cheatsheets/package-managers/npm/) and [nvm](https://www.jpkc.com/db/en/cheatsheets/package-managers/nvm/) are in the cheat sheets. I've written about the two other JavaScript runtimes in [Anthropic and Bun](https://www.jpkc.com/db/en/blog/bun-anthropic-claude-code/) and [Deno joins Cloudflare](https://www.jpkc.com/db/en/blog/deno-cloudflare/).

**Sources:** [Node.js security release of 21 October](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases) · [Brotli fix 0ed4268](https://github.com/google/brotli/commit/0ed4268a4401abcb44720963) · [Imperva: Node2Shell](https://www.imperva.com/blog/node2shell-from-brotli-data-to-remote-code-execution/) · [Aikido: Python packages](https://www.aikido.dev/blog/python-packages-vulnerable-node2shell-brotli-cve-2026-111497) · [Patch the Planet: Brotli](https://patchtheplanet.org/brotli) · [Node.js release schedule](https://github.com/nodejs/Release) · [NodeSource distributions](https://github.com/nodesource/distributions)

