Node 24 vs Node 26: Benchmarks, Breaking Changes and Should You Upgrade (2026)
Cross-posted from my blog. The original, with any updates, lives on fabwaseem.com.
Node.js 26 became the Active LTS line on October 28, 2026 with v26.13.0 "Lithium", and Node 24 moved to Maintenance LTS on October 20. If you're deciding between Node 24 vs Node 26 for a production app, this post covers what changes, what breaks, and how the two compare on a few benchmarks I ran on the same machine tonight. The short version: Node 26 was faster at JSON serialization, async-heavy code and a plain node:http server, but slower in two places you might not expect, fetch() and inserting a million keys into a Map.
Node 24 vs Node 26 at a glance
| | Node 24 "Krypton" | Node 26 "Lithium" |
|---|---|---|
| Version tested | v24.22.2 | v26.13.0 |
| Status (Nov 2026) | Maintenance LTS since Oct 20, 2026 | Active LTS since Oct 28, 2026 |
| End of life | April 30, 2028 | April 30, 2029 |
| V8 | 13.6 | 14.6 |
| Undici (powers fetch) | 7.30.1 | 8.12.2 |
| NODE_MODULE_VERSION | 137 | 147 |
| Bundled npm | 11.19.1 | 11.20.0 |
| Temporal global | not available | on by default |
Dates come from the Node.js release schedule. Versions are what process.versions printed on each binary.
What's new in Node 26
From the Node 26.0.0 release notes and checked on v26.13.0:
- Temporal is enabled by default. Temporal.Now.plainDateISO('Asia/Karachi') works with no flag. On Node 24, Temporal is undefined.
- V8 14.6 brings Map.prototype.getOrInsert() / getOrInsertComputed() (the upsert proposal) and Iterator.concat().
- Undici 8, so the global fetch() is a different major version of the HTTP client.
- TypeScript type stripping still works out of the box for .ts files, as it does on Node 24. --experimental-transform-types was removed, so enums and namespaces that need transformation won't run without a build step.
// Node 26 only
const counts = new Map();
for (const word of ['a', 'b', 'a']) {
counts.set(word, counts.getOrInsert(word, 0) + 1);
}
const today = Temporal.Now.plainDateISO('Asia/Karachi');
console.log(today.add({ days: 30 }).toString());
const all = Iterator.concat([1, 2].values(), [3].values()).toArray(); // [1, 2, 3]
Breaking changes to check before upgrading
These are the ones most likely to hit a typical Node.js, NestJS or Next.js codebase:
1. Native addons must be rebuilt. NODE_MODULE_VERSION went from 137 to 147. Anything with a compiled .node file (bcrypt, sharp, better-sqlite3, canvas) needs a fresh install on Node 26. Delete node_modules in your Docker build rather than copying it from a Node 24 stage.
2. http.ServerResponse.prototype.writeHeader() is gone. It was a legacy alias. On v26.13.0 typeof res.writeHeader is undefined. Use writeHead().
3. The _stream_* legacy modules are removed (_stream_readable, _stream_writable and friends). Old dependencies that require('_stream_readable') will crash at import.
4. module.register() is runtime-deprecated. Running a loader that calls it prints DEP0205 DeprecationWarning: module.register() is deprecated. Use module.registerHooks() instead. It still works, but plan to move custom loaders to registerHooks().
5. --experimental-transform-types is removed. If you run .ts with enums directly through Node, switch to erasableSyntaxOnly style code or keep a compiler step.
A quick way to find the first two in a codebase:
grep -rn "writeHeader(\|_stream_readable\|_stream_writable\|module.register(" src/ node_modules/.pnpm 2>/dev/null | head
Node 24 vs Node 26 performance: benchmark setup
I ran these on November 3, 2026 on one Linux VM with 8 vCPUs (Intel Xeon) and 16 GB RAM. The machine was shared with other work (load average around 1.5 before starting), so treat small differences as noise. To limit that:
- Each benchmark process was pinned to one CPU with taskset.
- Node 24 and Node 26 runs were interleaved over 5 rounds, and I report the median of the 5 rounds.
- The HTTP load generator (autocannon, 100 connections, 10 s) always ran on Node 24 on separate CPUs, so only the server changed.
- The fetch() test sent 20,000 requests with 50 concurrent loops to a fixed Node 24 server, so only the client changed.
The scripts are plain .mjs files: JSON.parse/JSON.stringify on a 20,000-item document, sorting 300,000 numbers, setting 1 million string keys in a Map, a global regex replace, SHA-256 over 16 MB, allocating 2 million small class instances, and 1 million sequential awaits.
Benchmark results
Lower is better for times, higher is better for requests per second. Median of 5 rounds.
| Test | Node 24.22.2 | Node 26.13.0 | Change |
|---|---|---|---|
| JSON.parse (20k items) | 11.79 ms | 10.46 ms | 11% faster |
| JSON.stringify (20k items) | 7.20 ms | 4.53 ms | 37% faster |
| Sort 300k numbers | 54.6 ms | 51.7 ms | 5% faster |
| Map.set 1M string keys | 396.7 ms | 507.5 ms | 28% slower |
| Regex replace | 2.06 ms | 1.72 ms | 16% faster |
| SHA-256 over 16 MB | 9.57 ms | 9.17 ms | 4% faster (within noise) |
| 2M class instances | 47.8 ms | 35.0 ms | 27% faster |
| 1M sequential await | 58.6 ms | 35.0 ms | 40% faster |
| node -e 0 startup | 22.3 ms | 20.7 ms | 7% faster |
| RSS after startup | 41 MB | 41 MB | same |
| node:http server, req/s | 73,079 | 79,653 | 9% more |
| node:http p99 latency | 3 ms | 2 ms | lower |
| fetch() client, req/s | 8,442 | 7,601 | 10% fewer |
What stood out:
- JSON.stringify and await got much cheaper. Those two show up everywhere in API servers, so the 9% higher node:http throughput is consistent with them.
- fetch() was slower in 4 of 5 rounds. Node 26 ships Undici 8. If your service makes lots of outbound HTTP calls, measure your own workload before and after.
- Map.set with 1 million new string keys was slower in every round. The Node 24 numbers for this test varied a lot (336 to 513 ms), so the size of the gap is uncertain, but the direction was consistent. Code that builds very large maps in a hot path is worth profiling.
These are microbenchmarks on one VM. They tell you where to look, not what your app will do.
How to upgrade from Node 24 to Node 26
# 1. Switch locally
nvm install 26 && nvm use 26 # or: fnm use 26 / volta install node@26
# 2. Reinstall so native addons rebuild against ABI 147
rm -rf node_modules && npm ci
# 3. Surface deprecations your code triggers
NODE_OPTIONS=--trace-deprecation npm test
Then update the places that pin the version:
# Dockerfile
FROM node:26-alpine AS deps
// package.json
{ "engines": { "node": ">=26" } }
Also update .nvmrc, your CI matrix and the Node version in your hosting panel. If you deploy Next.js in Docker, the multi-stage setup in my Next.js Dockerfile guide works the same with node:26-alpine as the base image.
Should you upgrade to Node 26 now?
- New projects: start on Node 26. It's the Active LTS line and is supported a year longer than Node 24.
- Existing Node 24 apps: there's no rush. Node 24 gets security fixes until April 30, 2028. Upgrade when you next touch your Docker base image, after rebuilding native addons and running your tests with --trace-deprecation.
- Services that are mostly outbound HTTP: benchmark your own fetch() traffic first, because that was the one place Node 26 was clearly slower in my test.
If you're also weighing runtimes rather than versions, I compared Bun vs Node on real workloads earlier.
FAQ
Is Node 26 LTS?
Yes. Node 26 entered Active LTS on October 28, 2026 with v26.13.0, codename "Lithium". It stays Active LTS until October 20, 2027, then Maintenance LTS until April 30, 2029.
When does Node 24 reach end of life?
April 30, 2028. Node 24 has been in Maintenance LTS since October 20, 2026, which means security and critical fixes only.
Is Node 26 faster than Node 24?
In my benchmarks on one 8-vCPU VM, Node 26 was faster at JSON.stringify (37%), sequential await (40%) and as a node:http server (9% more requests per second). It was slower for the fetch() client (10% fewer requests per second) and for inserting 1 million keys into a Map (28% slower). Measure your own workload.
What breaks when upgrading from Node 24 to Node 26?
The common ones are native addons that need rebuilding (NODE_MODULE_VERSION 147), the removed res.writeHeader() alias, the removed _stream_* modules, the removed --experimental-transform-types flag, and a runtime deprecation warning for module.register().
Does Node 26 have Temporal?
Yes. The Temporal global is enabled by default in Node 26. In the official Node 24.22.2 binary, typeof Temporal is 'undefined'.
---
Originally published on fabwaseem.com.
The Node.js Brotli flaw: your server decompresses whatever APIs send back
On October 21, Node.js fixed a flaw in its Brotli decoder, CVE-2026-111497. I went through the Node images in my projects. Two Next.js frontends are built on a tag that still ships Node 22.21.1. That tag will never get the fix.
> TL;DR: a crafted Brotli stream can make Node write outside the memory it reserved. The process can crash, leak memory, or run an attacker's code. You are exposed if your server decompresses Brotli that comes from outside. Node's fetch, axios and Express 5 all do it by default. Move to Node 22.23.4, 24.22.1 or 26.12.1. Node 18, Node 20 and some Docker tags will never be fixed.
Versions and source status: snapshot as of October 31, 2026. Check the latest fixes before deploying.
This article is for Node.js developers and for teams that ship Node in containers. You do not need to know Brotli to follow it.
Who decompresses Brotli without knowing it
The flaw has one condition. Your application must decompress Brotli data supplied by someone else. So the real question is simple. Which code on your server decompresses Brotli?
I read the source code of Node, undici, axios and body-parser. The table sums up what each one does.
The fetch case is the quietest one. Node built it on undici, the HTTP library of the Node.js project. Over HTTPS, fetch asks the server for Brotli on its own, with an accept-encoding header that includes br. Then it decompresses the response without asking you.
Changing that header does not protect you. In undici's code, the decoder depends on the response's Content-Encoding header. A hostile server can send you Brotli even if you did not ask for it.
The risk comes from URLs you do not control. Think of a webhook entered by a customer, a link preview, an RSS feed or an image to download. A compromised third-party API is enough too.
For incoming requests, Express 5 relies on body-parser 2. That version decompresses bodies sent with Content-Encoding: br. So any client can push a Brotli stream at you. Express 4 uses body-parser 1, which does not know Brotli.
An image that only builds a frontend receives no traffic. It still needs the update, but it is not the priority.
What Node.js fixed on October 21
On October 21, Node.js shipped three security releases: 22.23.4, 24.22.1 and 26.12.1. The advisory lists ten flaws. Four are rated "high". Only one can lead to code execution, the one in the Brotli decoder.
A server that compresses with Brotli says so with the Content-Encoding: br header. The client must then decompress the response before reading it. That is the decoder's job.
The decoder reads the stream piece by piece. When it hit an invalid section, it thought it was just missing a few bytes. It waited for more instead of rejecting the stream.
Meanwhile, it kept storing the next bytes in a small eight-byte buffer. It did not stop at the edge. So it wrote into the memory next to it. This is called an out-of-bounds write.
Node lists three possible effects. The process can crash. It can leak part of its memory. Or the attacker can run their own code inside your Node process.
Node rates the flaw "high". The CVE record, written by Google, scores it 9.2 out of 10. The NVD, the US vulnerability database, scores it 9.8.
The bug comes from Google's Brotli library. Google shipped the fixed versions the same day: 1.0.10, 1.1.1 and 1.2.1. But Node ships its own copy of Brotli.
I checked with ldd in two official images. The node binary loads no Brotli library from the system. So updating the packages in your image changes nothing for Node. It is the same trap as Go's eMudhra fix, which I covered yesterday.
Confirmed exploitation since October 27
On October 22, the CVE record still said "Exploitation: none". CISA added the flaw to its KEV catalog on October 27. That catalog records confirmed exploitation. The assessment changed to "active" the next day.
On October 30, Singapore’s Cyber Security Agency also reported a public proof of concept. These sources describe successive states. They do not give an account of a specific attack here. This snapshot ends on October 31, 2026.
My lab: 14 FROM node lines, three versions that will never be fixed
I listed the FROM node: lines in every Dockerfile of my projects. I count 14 of them, in 11 files. For each tag, I read Docker Hub, then ran node --version in today's image.
The tag that surprised me is node:22-alpine3.21. It looks like it follows Node 22. In fact, it ships Node 22.21.1, and it has not moved in a year.
Alpine is a very small Linux distribution. The alpine3.21 suffix pins its version. The team behind the Node images dropped Alpine 3.21 on December 4, 2025. Today it only builds the Alpine 3.23 and 3.24 variants.
Since then, that tag gets no new Node version at all. Nothing warns you. The build passes and the image starts.
One of the two repositories does have a Dependabot that watches Docker images. In May, it proposed moving to node:25-alpine3.21. It kept the suffix, so it stayed inside the frozen family. Node 25 reached end of life three weeks later, on June 1.
node:18-alpine and node:20-alpine are frozen for another reason. Node 18 has been end of life since April 30, 2025, Node 20 since April 30, 2026. Their copy of Brotli is version 1.1.0, inside the affected range. Node's advisory says it plainly. These official releases remain vulnerable because their release lines received no fix.
One last detail, on my laptop. The cached node:24-alpine image dates from August 3. It holds Node 24.19.0, without the fix. A build that reuses that copy makes a vulnerable image, even with an up-to-date tag on Docker Hub.
Then I searched my code for anything that decompresses Brotli. No service calls the Brotli functions of zlib. None uses Express. The two Next.js frontends make no server-side fetch in production. Their API calls leave from the browser.
A single route makes a server-side fetch. It only turns on in local development, toward a storage service I control. So my real exposure is low.
But a grep does not see what my dependencies do. And the next server-side fetch will arrive without anyone rereading the FROM line.
The Brotli version number proves nothing
Node shows the version of its libraries in process.versions. I compared two Node 22 images, one vulnerable and one fixed:
docker run --rm node:22-alpine3.21 node -p "process.version + ' brotli ' + process.versions.brotli"
# v22.21.1 brotli 1.1.0
docker run --rm node:22-alpine node -p "process.version + ' brotli ' + process.versions.brotli"
# v22.23.4 brotli 1.1.0
Both show Brotli 1.1.0. Node applied Google's fix to its copy without changing that number. Node 24 is the same, with 1.2.0 before and after. Compare the Node version with 22.23.4, 24.22.1 or 26.12.1 instead.
The fix also adds a constant to zlib.constants. In my tests, it only exists in the fixed versions. It makes a quick check in a running container:
docker exec my-container node -p "process.version + ' ' + ('BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH' in require('zlib').constants)"
# v22.23.4 true (fixed)
# v22.21.1 false (vulnerable)
What to change
If you cannot deploy today, two settings reduce the risk. With Express 5, the inflate: false option rejects compressed bodies with a 415 error:
app.use(express.json({ inflate: false }))
// a body sent with Content-Encoding: br gets a 415 error
With axios, the decompress: false option turns decompression off. Your code then gets the compressed bytes, and handling them is up to you. For fetch, I found no matching setting. Only a Node update protects it.
The checklist
> A Node fix only protects the container where it actually runs.
- [ ] List every FROM node: line in your Dockerfiles
- [ ] Spot end-of-life versions: Node 18, Node 20 and older
- [ ] Spot tags tied to an Alpine version that is no longer built, such as alpine3.21
- [ ] Run node -p process.version in every production container
- [ ] Do not trust process.versions.brotli
- [ ] Look for server-side fetch and axios calls to URLs you do not control
- [ ] Look for Express 5 and direct calls to the Brotli functions of zlib
- [ ] Rebuild with --pull, then redeploy
The takeaway
The Brotli flaw does not need you to call Brotli yourself. fetch, axios and Express 5 do it for you, on data that comes from outside. And a Docker tag can look current while it ships a version that is a year old.
Open your Dockerfiles today. Look for Node 18 and 20, and for tags that pin an Alpine version. Nobody will update them for you.
Want a review of your images and your build pipeline? See my DevSecOps pipeline offer, or let's talk.
---
Sources: Node.js, October 21, 2026 security releases · Node.js 22.23.4 release notes · CVE-2026-111497 record · NVD, CVE-2026-111497 · CISA KEV catalog · Singapore CSA, alert AL-2026-154 · Brotli 1.2.1 release notes · undici 6.29.1, fetch source · body-parser 2.4.0, body reading · axios HTTP adapter · Node.js zlib documentation · Node.js release schedule · docker-node, Alpine 3.21 removal · Docker Hub, node image · docker build documentation
Node.js 20 Is End of Life: How to Upgrade to Node 24
Node.js 20 stopped receiving security fixes on 30 April 2026. If a server, container image or CI job still runs it, every new Node vulnerability from now on is one you carry yourself. The October 2026 security releases, for example, patched the 22.x, 24.x and 26.x lines, and Node 20 was not among them. This guide shows what changes between Node 20 and Node 24, the handful of things that actually break, how to find them in your code before you switch, and how to roll the upgrade out.
Which version should you move to?
| Version | Status (end of October 2026) | Security support ends |
| --- | --- | --- |
| Node 20 | End of life | 30 April 2026 |
| Node 22 | Maintenance LTS | 30 April 2027 |
| Node 24 "Krypton" | Maintenance LTS since 20 October 2026 | 30 April 2028 |
| Node 26 "Lithium" | LTS since 28 October 2026 | 30 April 2029 |
Dates are from the Node.js release schedule (the nodejs/Release repository) and match endoflife.date. Node 24 is a solid target for an existing application: it has been an LTS line since October 2025, so tools and libraries have had time to add support. Node 26 has just entered LTS and gives you the longest support window, so it is a reasonable choice for new projects, as long as your dependencies (especially native ones) support it. This article is about 20 to 24, and it notes where Node 26 differs.
What changes between Node 20 and Node 24
I installed Node 20.20.2, 24.22.2 and 26.13.0 side by side and read these values from process.versions:
| | Node 20.20.2 | Node 24.22.2 | Node 26.13.0 |
| --- | --- | --- | --- |
| V8 | 11.3 | 13.6 | 14.6 |
| OpenSSL | 3.0.19 | 3.5.9 | 3.5.9 |
| Undici (fetch) | 6.24 | 7.30 | 8.12 |
| Native module ABI (process.versions.modules) | 115 | 137 | 147 |
The Node 24 release notes add npm 11 (Node 20 ships npm 10), and these new capabilities, which I confirmed are missing in Node 20 and present in 24:
- URLPattern is a global (typeof URLPattern is undefined in 20 and function in 24).
- Float16Array, RegExp.escape() and Error.isError() exist in 24 and not in 20.
- The permission model flag is now --permission.
- AsyncLocalStorage uses a more efficient implementation by default.
One thing that is _not_ new: require() of an ES module already works on Node 20.20.2. I tested it, and it returned the module without a flag. If you were avoiding that on Node 20, check your own version; on current 20.x releases it works.
What actually breaks
Most applications need no code changes. The cases below are the ones I could reproduce, each tested on Node 20 and Node 24.
| Code | Node 20 | Node 24 | Fix |
| --- | --- | --- | --- |
| tls.createSecurePair() | Function exists | Removed (undefined, calling it throws) | Use tls.TLSSocket |
| dirent.path from readdir(..., {withFileTypes: true}) | String | Removed (undefined) | Use dirent.parentPath |
| --experimental-permission flag | Works | "bad option", the process will not start | Use --permission |
| url.parse() | Silent | Runtime deprecation warning DEP0169 | Use new URL() |
| fs.F_OK, R_OK, W_OK, X_OK | Silent | Warning DEP0176 (still works) | Use fs.constants.F_OK etc. |
| spawn() / execFile() with an args array and shell: true | Silent | Warning DEP0190 | Drop shell: true, or build the command string yourself |
The first three can stop your app or script. The rest only print warnings today but are on the road to removal, and Node 26 shows how that ends: in Node 26.13.0, fs.F_OK is already undefined, so code reading it no longer works at all. The Node 24 release notes list other removals and deprecations, such as SlowBuffer, Zlib classes used without new, and 32-bit s390 and ppc support, but those are rare in application code. If you build Node from source, the release notes also raise the minimum macOS to 13.5 and require ClangCL on Windows.
The fixes in code
// url.parse() -> WHATWG URL
const u = new URL(req.url, 'http://localhost');
u.hostname; u.port; u.pathname; u.searchParams.get('y');
// dirent.path -> dirent.parentPath
const full = require('node:path').join(entry.parentPath, entry.name);
// fs.F_OK -> fs.constants.F_OK
fs.access(file, fs.constants.F_OK, cb);
// spawn without shell: arguments are passed safely, no quoting needed
require('node:child_process').spawnSync('echo', ['hi']);
One difference to watch with url.parse(). It can return the query as an object when you pass true as the second argument, while new URL() gives you searchParams instead. Replace those lines by hand rather than with a search and replace.
Find the problems before you switch
1. Search the code
grep -rnE "url\.parse\(|\.createSecurePair|fs\.(F_OK|R_OK|W_OK|X_OK)|\{ *(F_OK|R_OK|W_OK|X_OK)|new Buffer\(|\bSlowBuffer\b|experimental-permission|\b(dirent|entry|ent|e)\.path\b|shell: *true" \
src scripts package.json Dockerfile .github --include=*.js --include=*.mjs --include=*.cjs --include=*.ts --include=package.json --include=Dockerfile --include=*.yml
I ran this against a sample file and it matched each old usage and skipped the modern ones such as fs.constants.F_OK and parentPath. It will also report false positives, like a variable named e.path that has nothing to do with fs, so read each hit. Your dependencies can also trigger these warnings, which the next step covers.
2. Run your tests with deprecations turned into errors
NODE_OPTIONS=--throw-deprecation npm test
With --throw-deprecation, a deprecated API throws instead of warning, so the first one fails the run. I confirmed the process exits with code 1. To find where a warning comes from, whether in your code or in node_modules, use:
node --trace-deprecation app.js
This prints the stack for each deprecation warning. For stricter checks, --pending-deprecation turns on extra deprecation warnings, such as the one for new Buffer() (DEP0005), which I saw with that flag.
3. Rebuild native dependencies
Packages with compiled addons, such as image, database or hashing libraries, are built for one ABI version. Node 20 is ABI 115 and Node 24 is 137, so the old binaries will not load. Start from a clean install under the new version:
rm -rf node_modules
npm ci
npm rebuild # only needed if an addon did not install a prebuilt binary for Node 24
If an addon fails to build or load, check that package's release notes for Node 24 support and update it. Do the same for your build tools (Vite, webpack, test runners): each documents the Node versions it supports.
Roll it out
Local and server
# nvm
nvm install 24
nvm alias default 24
node -v
# fnm
fnm install 24 && fnm default 24
# pin it for the project
echo "24" > .nvmrc
On Debian or Ubuntu servers installed through NodeSource, switch the repository to the Node 24 setup script and reinstall the package. Restart every long-running process afterwards, because a running process keeps using the Node binary it started with. Check the version a running service really uses, not just the one on your shell's PATH.
package.json
{
"engines": {
"node": ">=24"
}
}
With engines set, npm warns when someone runs the project on an older Node. Add engine-strict=true to .npmrc if you want it to refuse instead.
Docker
FROM node:24-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]
Change the tag in every Dockerfile, including build stages, and rebuild without cache so npm ci compiles native modules against the new Node.
CI
# GitHub Actions
- uses: actions/setup-node@v4
with:
node-version: 24
Use the current major version of the action. Update any test matrix that still lists 20, and keep 22 and 24 in the matrix while you migrate.
A safe order of work
1. Run the grep and the --throw-deprecation test run on Node 20 itself to see what you will hit, then fix the code. All the fixes above work on Node 20 too, so you can ship them first.
2. Switch CI to Node 24 and get the test suite green.
3. Deploy to staging with the new Node and watch logs for deprecation warnings and native module errors.
4. Roll production forward one service or one instance group at a time, so you can roll back by redeploying the old image.
Frequently asked questions
Is Node.js 20 still safe to use?
No. It reached end of life on 30 April 2026, so it no longer receives security fixes. Vulnerabilities found since then are not patched in the 20.x line.
Should I upgrade to Node 24 or Node 26?
Either is supported. Node 24 is the safer choice for an existing app because the ecosystem has had a year with it. Node 26 became LTS on 28 October 2026 and is supported until 30 April 2029, so consider it for new projects after checking that your native dependencies support it.
Can I skip from Node 20 straight to 24?
Yes. You do not have to install 22 first, but the removals and deprecations of both releases apply at once, which is why it helps to run the checks above on Node 20 first.
Will my npm packages work on Node 24?
Pure JavaScript packages usually do. Packages with native addons must be reinstalled or rebuilt for the new ABI, and some need a newer version.
What happens to Node 24 after October 2026?
It is in Maintenance LTS: it still gets critical fixes and security updates, but not new features, until 30 April 2028.
Sources
- Node.js Release Working Group: release schedule
- endoflife.date: Node.js
- Node.js 24.0.0 release notes
- Node.js 26.13.0 "Lithium" reaches LTS
---
_Originally published on DEV Talk._
Nginx reverse proxy avec Docker Compose : le tutoriel HTTPS pas à pas (2026)
L'essentiel en une minute
Un Nginx reverse proxy reçoit les requêtes des clients à la place de votre application, termine le chiffrement HTTPS, puis transmet chaque requête au bon service interne. Avec Docker Compose, c'est le seul conteneur qui publie des ports : votre API reste invisible depuis l'extérieur.
Ce tutoriel place nginx devant l'API Node.js de notre guide pour créer une API REST avec Node.js et Express, qui écoute sur le port 3000 et expose une route /health, conteneurisée comme dans notre guide Docker pour débutants. Il repose sur la documentation de nginx.org, de Docker, de Certbot et de Let's Encrypt, consultée le 30 octobre 2026. Les fichiers suivent ces documentations, mais nous ne les avons pas exécutés : la méthode est détaillée en fin d'article.
> Points clés
>
> - nginx 1.30.6 (stable) et 1.31.7 (mainline) ont corrigé cinq vulnérabilités le 14 octobre 2026.
> - Quatre en-têtes sont à transmettre : Host, X-Real-IP, X-Forwarded-For et X-Forwarded-Proto. Côté Express, TRUST_PROXY=1 lit l'adresse du client un saut plus loin.
> - Une location qui définit un seul proxy_set_header perd tous ceux du niveau supérieur : c'est le piège classique des WebSocket.
> - Les certificats Let's Encrypt du profil par défaut, classic, passeront de 90 à 64 jours le 10 février 2027, puis à 45 jours le 16 février 2028 : le renouvellement doit être automatique.
> - Les erreurs 502, 504 et 413 ont chacune une cause typique, visible dans les journaux.
---
Reverse proxy, proxy direct, répartiteur de charge
Un reverse proxy agit pour le compte des serveurs, alors qu'un proxy direct agit pour le compte des clients. MDN résume la différence ainsi : un proxy direct peut masquer l'identité des clients, un reverse proxy masque celle des serveurs, et sert à la répartition de charge, à l'authentification, au déchiffrement ou au cache (MDN, serveurs proxy et tunnels, consulté le 30 octobre 2026).
Le répartiteur de charge est une fonction qu'un reverse proxy assure quand plusieurs instances tournent derrière lui : sans méthode précisée, nginx répartit à tour de rôle (round-robin), avec least_conn et ip_hash comme alternatives (nginx.org, répartition de charge).
| | Proxy direct (forward) | Reverse proxy | Répartiteur de charge |
|---|---|---|---|
| Agit pour | Les clients | Les serveurs | Les serveurs |
| Placé | Côté réseau du client | Devant l'application | Devant plusieurs instances |
| Usage typique | Filtrage, cache sortant, anonymat | TLS, routage par domaine ou chemin, en-têtes | Répartir le trafic, tolérer une panne |
| Exemple | Proxy d'entreprise | nginx devant une API | nginx avec un bloc upstream |
Pour une seule API, le Nginx reverse proxy apporte un point d'entrée HTTPS unique, des en-têtes fiables et un endroit où régler délais et limites.
---
Les versions à utiliser en octobre 2026
Le 14 octobre 2026, nginx.org a publié la 1.30.6 stable et la 1.31.7 mainline avec les mêmes cinq correctifs de sécurité : CVE-2026-15176 (alias combiné à rewrite), CVE-2026-93479 (expressions avec variables), CVE-2026-95101 (corps de requête en HTTP/3), CVE-2026-97629 (rotation des clés de tickets TLS) et CVE-2026-97205 (SSI avec AIO). La 1.31.8 du 21 octobre n'apporte ensuite que des changements et corrections de bugs (CHANGES-1.30 et CHANGES, consultés le 30 octobre 2026).
| Composant | État au 30 octobre 2026 | Image Docker utilisée |
|---|---|---|
| nginx stable | 1.30.6 | nginx:1.30.6-alpine |
| nginx mainline | 1.31.8 | nginx:1.31.8-alpine |
| Certbot | 5.9.0, publiée le 28 octobre 2026 | certbot/certbot:v5.9.0 |
| Node.js 26 | LTS depuis le 28 octobre 2026 (26.13.0) | Non utilisée ici |
| Node.js 24 | LTS en maintenance depuis le 20 octobre 2026, jusqu'au 30 avril 2028 | node:24-alpine |
Les étiquettes viennent de la page de l'image nginx sur Docker Hub, la version de Certbot de son journal des modifications, et les dates de Node.js du calendrier officiel des versions. Nous gardons node:24-alpine, l'image de nos deux tutoriels : la ligne 24 reste prise en charge et l'API exige Node.js 24 au minimum.
---
Étape 1 : la configuration minimale d'un Nginx reverse proxy
La directive centrale est proxy_pass, qui indique l'adresse du serveur vers lequel nginx transmet la requête. Voici le fichier nginx/conf.d/api.conf de départ, en HTTP seulement :
server {
listen 80;
server_name api.example.com;
# Servira au défi Let's Encrypt de l'étape 3
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
proxy_pass http://api:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
api est le nom du service dans Docker Compose, et 3000 le port interne de l'API. L'ordre des deux blocs location n'a pas d'importance : parmi les préfixes, nginx retient celui qui correspond le plus longuement à l'adresse demandée (module core HTTP).
Les quatre en-têtes ne sont pas décoratifs. Par défaut, nginx remplace l'en-tête Host par $proxy_host, c'est-à-dire api:3000 ici, et l'application ne verrait jamais le vrai domaine (module proxy de nginx, consulté le 30 octobre 2026).
| En-tête | Valeur envoyée | Ce que l'application en tire |
|---|---|---|
| Host | $host, le domaine demandé | Les liens absolus et le routage par domaine |
| X-Real-IP | $remote_addr, l'adresse vue par nginx | L'adresse du client, en une seule valeur |
| X-Forwarded-For | La valeur reçue, plus $remote_addr à la fin | La chaîne des adresses traversées |
| X-Forwarded-Proto | $scheme, http ou https | Savoir si le client était en HTTPS |
Le piège de la barre oblique finale
Une barre oblique de trop dans proxy_pass change l'adresse transmise : si proxy_pass contient une URI, même réduite à /, la partie qui correspond au bloc location est remplacée par cette URI ; sans URI, la requête part telle que le client l'a envoyée.
Avec location /api/ et proxy_pass http://api:3000/;, une requête vers /api/v1/taches arrive sous la forme /v1/taches. L'API du tutoriel attend /api/v1/... : pas de barre finale.
Rester cohérent avec TRUST_PROXY
Notre API Express ne lit ces en-têtes que si la variable TRUST_PROXY est réglée. La page Express derrière un proxy explique qu'avec un nombre, Express fait confiance à ce nombre de sauts, en lisant X-Forwarded-For de droite à gauche. Avec un seul nginx devant, TRUST_PROXY=1 désigne la dernière adresse de la liste, celle que nginx ajoute lui-même : un client ne peut pas la falsifier.
La même page précise que req.protocol reflète X-Forwarded-Proto. Le cas d'un CDN devant nginx est traité plus bas.
---
Étape 2 : la pile Docker Compose
Trois prérequis : un serveur Linux avec Docker Engine et Compose, un enregistrement DNS A (et AAAA en IPv6) qui fait pointer api.example.com vers lui, et les ports 80 et 443 ouverts, le 80 servant au défi de Let's Encrypt.
Seul nginx publie des ports, et c'est voulu. La page d'installation de Docker pour Ubuntu prévient que les ports publiés par des conteneurs contournent les règles des pare-feu ufw et firewalld (installer Docker Engine sur Ubuntu) : un port 3000 publié par erreur serait joignable malgré votre pare-feu.
!Schéma de la pile Docker Compose : le navigateur se connecte au Nginx reverse proxy sur les ports 80 et 443, qui transmet en HTTP au service api sur le port 3000 interne, Certbot partageant les certificats par des dossiers montés
Voici l'arborescence, placée par exemple dans /srv/pile ; le dossier api-taches est le projet du tutoriel Node.js, avec le Dockerfile et le .dockerignore de notre guide Docker :
/srv/pile/
compose.yaml
.env JWT_SECRET, jamais dans Git
api-taches/ package.json, src/, Dockerfile, .dockerignore
nginx/conf.d/api.conf
certbot/www/ défis Let's Encrypt
certbot/conf/ certificats
Ce compose.yaml remplace celui du guide Docker, qui vivait dans api-taches et publiait 127.0.0.1:3000 : ici, l'API ne publie plus rien. Comme le dossier du projet change de nom (pile au lieu de api-taches), Compose crée un nouveau volume pile_api-data : la base SQLite du guide Docker n'est pas reprise. Rappel du Dockerfile multi-étapes de ce guide :
# syntax=docker/dockerfile:1
FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:24-alpine
ENV NODE_ENV=production
ENV DB_PATH=/app/data/data.db
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
COPY src ./src
RUN mkdir -p /app/data && chown node:node /app/data
USER node
EXPOSE 3000
CMD ["node", "src/server.js"]
Puis /srv/pile/compose.yaml :
services:
api:
build: ./api-taches
environment:
JWT_SECRET: ${JWT_SECRET:?definir JWT_SECRET dans .env}
DB_PATH: /app/data/data.db
TRUST_PROXY: "1"
volumes:
- api-data:/app/data
init: true
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
restart: unless-stopped
nginx:
image: nginx:1.30.6-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./certbot/www:/var/www/certbot:ro
- ./certbot/conf:/etc/letsencrypt:ro
depends_on:
api:
condition: service_healthy
restart: unless-stopped
certbot:
image: certbot/certbot:v5.9.0
profiles: ["outils"]
volumes:
- ./certbot/www:/var/www/certbot
- ./certbot/conf:/etc/letsencrypt
volumes:
api-data:
Le secret vient du fichier .env placé à côté de compose.yaml : Compose le lit dans le dossier du projet pour remplacer ${JWT_SECRET}, et ${VAR:?message} arrête tout si la variable est vide (interpolation des variables). Le .env du tutoriel Node.js, rangé dans api-taches, n'est pas lu. Seules les variables listées sous environment entrent dans le conteneur (référence des services). Générez le secret sans installer Node.js sur le serveur :
cd /srv/pile
echo "JWT_SECRET=$(docker run --rm node:24-alpine node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))")" > .env
Les autres choix, tous tirés de la documentation de Docker :
- Pas de clé ports sur l'API. C'est elle qui l'isole : Compose relie les services sur un réseau interne où chacun est joignable par son nom et par le port du conteneur (réseau dans Compose).
- init: true. Un processus d'init transmet les signaux à Node.js, comme dans le guide Docker.
- depends_on avec service_healthy. nginx attend que le test de santé, qui appelle /health, réussisse. fetch est stable depuis Node.js 21 (globales de Node.js 24).
- restart: unless-stopped. La valeur par défaut, no, ne relance jamais un conteneur arrêté.
- Le profil du service certbot. Il ne démarre pas avec docker compose up, mais docker compose run certbot le lance quand on le cible (profils).
Monter ./nginx/conf.d masque le default.conf de l'image. Créez certbot/www et certbot/conf, puis lancez docker compose up -d --build et curl -i http://api.example.com/health, qui doit répondre {"statut":"ok"}.
---
Étape 3 : obtenir et renouveler le certificat Let's Encrypt
Le HTTPS pose un problème d'œuf et de poule : nginx refuse de démarrer si un bloc server cite un certificat absent, et Certbot a besoin de nginx pour prouver que vous contrôlez le domaine. C'est pourquoi le fichier de l'étape 1 sert déjà /.well-known/acme-challenge/ en HTTP : le défi HTTP-01 de Let's Encrypt ne passe que par le port 80, à cette adresse (types de défis, consulté le 30 octobre 2026).
Lancez Certbot avec le greffon webroot, qui dépose un fichier temporaire dans le dossier partagé avec nginx (documentation de Certbot) :
cd /srv/pile
docker compose run --rm certbot certonly --webroot -w /var/www/certbot \
-d api.example.com --email vous@example.com --agree-tos --no-eff-email
La page d'installation de Certbot prévient que l'image Docker ne sait pas configurer le serveur web : seule la commande certonly est utilisable. Le certificat arrive dans certbot/conf/live/api.example.com/, vu par nginx sous /etc/letsencrypt/live/api.example.com/, avec fullchain.pem et privkey.pem. L'étape 5 les branche.
Automatiser le renouvellement
Le renouvellement doit être automatique, car les certificats raccourcissent. La FAQ de Let's Encrypt indique des certificats de 90 jours par défaut. Son calendrier publié en décembre 2025 fait passer le profil par défaut, classic, à 64 jours le 10 février 2027, puis à 45 jours le 16 février 2028, et déconseille le renouvellement manuel.
!Graphique en barres : durée des certificats Let's Encrypt du profil classic, 90 jours en octobre 2026, 64 jours au 10 février 2027 et 45 jours au 16 février 2028, avec le dernier tiers où Certbot renouvelle selon notre calcul
Depuis sa version 4.0.0, certbot renew renouvelle quand il reste moins d'un tiers de la durée de vie ; depuis la 4.1.0, il interroge aussi l'ARI (ACME Renewal Information), qui peut avancer la date (journal des modifications de Certbot). Selon notre calcul, sans ARI, cela donne un renouvellement à 30 jours de l'échéance aujourd'hui, à environ 21 jours avec 64 jours de validité et à 15 jours avec 45. Une tâche planifiée quotidienne sur l'hôte suffit :
# crontab de l'hôte, chaque jour à 3 h 17
17 3 * * * cd /srv/pile && docker compose run --rm certbot renew --quiet && docker compose exec -T nginx nginx -s reload
Le rechargement est indispensable, nginx gardant l'ancien certificat en mémoire. cd /srv/pile && docker compose run --rm certbot renew --dry-run teste le tout sur le serveur de test de Let's Encrypt. Notre guide pour automatiser ses déploiements en CI/CD montre comment rejouer ces commandes depuis un pipeline.
---
Étape 4 : WebSocket, en-têtes de sécurité, délais et tailles
Cette étape explique chaque réglage et où il se place ; l'étape 5 les assemble dans un seul fichier. Les fichiers de conf.d sont inclus dans le bloc http du nginx.conf des paquets de nginx.org (pkg-oss) : une directive écrite hors de tout bloc server y est donc en contexte http.
Les WebSocket
Une connexion WebSocket commence par une requête HTTP qui demande un changement de protocole. La page WebSocket de nginx.org explique que Upgrade et Connection sont des en-têtes « de saut à saut » (notre traduction), donc non transmis : il faut les poser avec un map, autorisé seulement en contexte http (module map), et deux proxy_set_header.
Le piège est l'héritage : selon le module proxy, les proxy_set_header sont hérités du niveau supérieur si et seulement si le niveau courant n'en définit aucun. Une location /ws/ qui ajoute Upgrade perd donc Host et les X-Forwarded-* : il faut les répéter.
Depuis nginx 1.29.7, proxy_http_version vaut 1.1 par défaut, ce qu'exigent les WebSocket : inutile de l'écrire avec la 1.30.6. nginx coupe après 60 secondes sans données : relevez proxy_read_timeout sur cette location, ou envoyez des trames ping. L'API du tutoriel n'a pas de WebSocket : ce bloc sert si vous en ajoutez.
En-têtes de sécurité
Ne doublez pas ce que l'application envoie déjà. Notre API utilise Helmet, qui pose par défaut Strict-Transport-Security: max-age=31536000; includeSubDomains, X-Content-Type-Options: nosniff et X-Frame-Options: SAMEORIGIN, entre autres (documentation de Helmet, consultée le 30 octobre 2026). Deux réglages suffisent côté nginx, avec un piège à connaître :
- server_tokens off (contexte http) retire le numéro de version des pages d'erreur et de l'en-tête Server.
- proxy_hide_header Strict-Transport-Security puis add_header ... always (bloc server du port 443) : nginx envoie un seul en-tête HSTS, y compris sur ses propres pages 502 ou 504. Sans always, add_header ne s'applique qu'à certains codes, dont 200, 301 et 302 (module headers).
- L'héritage, encore. Un add_header placé dans une location annule ceux du server. Depuis nginx 1.29.3, add_header_inherit merge; cumule les deux niveaux, selon le même module headers.
Délais et taille des requêtes
Ces directives acceptent les contextes http, server et location ; nous les plaçons dans le server du port 443 :
- client_max_body_size vaut 1 Mo par défaut, au-delà duquel nginx renvoie l'erreur 413. L'API refuse déjà tout JSON de plus de 10 ko : une limite de 16 ko côté nginx écarte les gros corps avant qu'ils n'atteignent Node.js. Relevez-la si vous ajoutez un envoi de fichiers.
- proxy_connect_timeout vaut 60 secondes par défaut et ne peut généralement pas dépasser 75 secondes. Sur un réseau Docker local, 5 secondes suffisent pour échouer vite.
- proxy_read_timeout vaut 60 secondes et mesure le silence entre deux lectures, pas la durée totale de la réponse.
Pour les menaces que ces réglages contrent, voyez notre point sur les cyberattaques les plus fréquentes.
---
Étape 5 : le fichier api.conf final
Voici nginx/conf.d/api.conf complet, qui remplace celui de l'étape 1. Les trois premières directives sont en contexte http ; tout le reste vit dans les deux blocs server.
# Contexte http (fichier inclus par nginx.conf)
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
log_format proxy '$remote_addr [$time_local] "$request" $status '
'upstream=$upstream_status rt=$upstream_response_time '
'ct=$upstream_connect_time';
server_tokens off;
# Port 80 : défi Let's Encrypt, puis redirection vers HTTPS
server {
listen 80;
server_name api.example.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# Port 443 : le reverse proxy
server {
listen 443 ssl;
http2 on;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
access_log /var/log/nginx/access.log proxy;
client_max_body_size 16k;
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
proxy_hide_header Strict-Transport-Security;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Hérités par toute location qui ne définit aucun proxy_set_header
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
location / {
proxy_pass http://api:3000;
}
location /ws/ {
proxy_pass http://api:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 1h;
}
}
Nous avons relu le contexte autorisé de chaque directive sur nginx.org : map et log_format n'acceptent que http, return accepte server et location, ssl_certificate et http2 acceptent http et server. Le paramètre http2 de listen est déprécié au profit de http2 on;, apparu dans nginx 1.25.1 (module HTTP/2), et ssl_protocols vaut déjà TLSv1.2 TLSv1.3 par défaut (module SSL).
Vérifiez, rechargez, puis testez :
docker compose exec nginx nginx -t && docker compose exec nginx nginx -s reload
curl -I http://api.example.com/health # attendu : 301 vers https
curl -i https://api.example.com/health # attendu : 200 et {"statut":"ok"}
Si nginx -t signale une erreur, le rechargement n'a pas lieu et l'ancienne configuration continue de tourner : selon le guide du débutant de nginx, le processus maître revient à l'ancienne configuration quand la nouvelle échoue.
---
Nginx Proxy Manager, Caddy ou Traefik ?
Le Nginx reverse proxy écrit à la main n'est pas la seule voie. Trois alternatives reviennent dans les recherches, avec des compromis différents.
| Outil | Configuration | Certificats | Point d'attention |
|---|---|---|---|
| nginx écrit à la main | Fichiers texte, versionnés dans Git | Certbot, à brancher soi-même | Le plus de contrôle, le plus de travail |
| Nginx Proxy Manager | Interface web sur le port 81 | Let's Encrypt intégré | Configuration stockée en base, moins facile à relire dans Git |
| Caddy | Fichier Caddyfile court | Obtention et renouvellement automatiques par défaut | Une syntaxe de plus à apprendre |
| Traefik | Étiquettes sur les conteneurs | Résolveur ACME, suivi des expirations automatique | Accès à l'API Docker à protéger |
Le guide de Nginx Proxy Manager utilise l'image jc21/nginx-proxy-manager:2.16.0. Caddy s'appuie sur Let's Encrypt ou ZeroSSL et redirige vers HTTPS sans réglage. Traefik avertit qu'un accès sans restriction à l'API Docker pose un problème de sécurité.
Notre lecture : pour un projet ou deux, Caddy ou Nginx Proxy Manager font gagner du temps. Dès que la configuration doit être relue, versionnée et testée en CI, le fichier nginx explicite reste le plus prévisible.
---
Erreurs courantes et lecture des journaux
Chaque code d'erreur renvoyé par nginx pointe vers une cause précise, que les journaux nomment presque toujours. L'image officielle relie access.log à la sortie standard et error.log à la sortie d'erreur (dépôt docker-nginx) : docker compose logs --tail=50 nginx affiche les deux.
| Symptôme | Cause la plus fréquente | Correctif |
|---|---|---|
| 502 Bad Gateway | L'API est arrêtée, écoute sur un autre port, ou son conteneur a changé d'adresse | docker compose ps, vérifier le port 3000, recharger nginx |
| 504 Gateway Timeout | L'API ne répond pas dans le délai de proxy_read_timeout (60 s par défaut) | Accélérer la route, ou relever le délai sur cette seule location |
| 413 Request Entity Too Large | Corps de requête au-dessus de client_max_body_size (16 ko dans notre fichier) | Relever la limite dans le server ou la location concernée |
| Boucle de redirections derrière Cloudflare | Mode SSL Flexible, et redirection HTTP vers HTTPS sur l'origine | Passer en mode Full (strict) |
| Toutes les IP clientes identiques | En-têtes absents, ou CDN devant nginx sans module realip | Vérifier les quatre en-têtes, configurer set_real_ip_from |
nginx résout le nom api au chargement de sa configuration : si le conteneur est recréé avec une nouvelle adresse, un nginx -s reload règle le 502. Pour s'en passer, nginx accepte depuis la 1.27.3 le paramètre resolve dans un bloc upstream doté d'une zone, avec une directive resolver (module upstream) ; le DNS intégré de Docker répond à l'adresse 127.0.0.11 (réseau Docker).
Derrière Cloudflare
La boucle de redirections est documentée par Cloudflare : en mode Flexible, Cloudflare contacte l'origine en HTTP, l'origine redirige vers HTTPS, et le cycle recommence. Le correctif conseillé est le mode Full ou Full (strict), qui exige un certificat sur l'origine, celui de Certbot convient (Cloudflare, trop de redirections, mis à jour le 1er septembre 2026).
Pour retrouver l'adresse réelle des visiteurs, Cloudflare recommande le module realip de nginx, avec ses plages d'adresses publiées sur cloudflare.com (Cloudflare, adresses des visiteurs). Ces lignes vont dans le server du port 443 :
# Une ligne par plage publiée par Cloudflare, liste à tenir à jour
set_real_ip_from 192.0.2.0/24;
real_ip_header CF-Connecting-IP;
Le module n'est pas compilé par défaut, mais les paquets de nginx.org, base de l'image officielle, l'incluent. $remote_addr devient l'adresse du visiteur, nginx l'ajoute en fin de X-Forwarded-For, et TRUST_PROXY=1 reste juste côté Express.
Lire les journaux utilement
Le format proxy du fichier final ajoute au journal d'accès le statut et les temps de l'API, grâce aux variables du module upstream. Un 502 accompagné, dans le journal d'erreurs, d'un échec de connexion vers api:3000 signale que nginx n'a pas joint l'API. Un rt= proche de 60 secondes avant un 504 désigne une route trop lente.
Pour filtrer ces lignes, nos commandes Linux de base couvrent grep et tail. Pour héberger la pile, voyez notre guide de l'hébergement cloud pour développeurs.
---
Ce que nous n'avons pas pu vérifier
Ce tutoriel est une synthèse de documentation, pas un retour d'expérience. Les points suivants restent à confirmer de votre côté :
- L'exécution des fichiers. Ni Docker ni nginx n'étaient disponibles sur notre poste : nginx -t, docker compose up et Certbot n'ont pas été lancés.
- Les droits du volume SQLite. La documentation de Docker dit qu'un volume vide reçoit le contenu du dossier de l'image ; la page lue ne précise pas que le propriétaire node de /app/data est conservé.
- Le défi HTTP-01 derrière Cloudflare en mode proxy. Nous n'avons pas trouvé de page de Cloudflare qui le décrive pour ce cas précis. En cas d'échec, désactivez le proxy le temps de l'émission ou passez au défi DNS-01.
- Les plages d'adresses de Cloudflare. Elles changent : 192.0.2.0/24 est une adresse de documentation, à remplacer par la liste officielle.
---
Questions fréquentes
Qu'est-ce qu'un Nginx reverse proxy ?
C'est un serveur nginx placé devant une ou plusieurs applications : il reçoit les requêtes des clients, termine HTTPS et les transmet au bon service avec proxy_pass. Selon MDN, un reverse proxy masque l'identité des serveurs, là où un proxy direct masque celle des clients.
Faut-il nginx stable ou mainline en 2026 ?
Les deux reçoivent les correctifs de sécurité : le 14 octobre 2026, nginx.org a publié la 1.30.6 stable et la 1.31.7 mainline avec les mêmes corrections. La branche stable change moins souvent, d'où notre choix de l'étiquette nginx:1.30.6-alpine.
Comment corriger une erreur 413 avec nginx ?
Relevez client_max_body_size, qui vaut 1 Mo par défaut selon la documentation de nginx, dans le bloc server ou dans la seule location qui reçoit des fichiers. Vérifiez ensuite la limite de l'application, qui peut être plus basse.
Les certificats Let's Encrypt sont-ils toujours valables 90 jours ?
Oui pour l'instant avec le profil par défaut, classic. Le calendrier de Let's Encrypt prévoit 64 jours à partir du 10 février 2027 et 45 jours à partir du 16 février 2028. Un certbot renew planifié chaque jour absorbe ce changement sans modification.
---
À propos de l'auteur
Abdelilah Ourti est ingénieur IA et architecte logiciel. Il conçoit et déploie des applications web et des systèmes d'IA, et publie sur NeurauTech des guides et des comparatifs destinés aux développeurs francophones. Ses autres articles sont réunis sur sa page auteur.
Méthode. Tutoriel fondé sur des sources publiques consultées le 30 octobre 2026 : nginx.org, Docker, Certbot, Node.js, Let's Encrypt, Cloudflare, Express, Helmet, Nginx Proxy Manager, Caddy et Traefik. Le Dockerfile reprend celui de notre guide Docker. Aucune commande n'a été exécutée, Docker n'étant pas installé sur le poste de rédaction : la syntaxe nginx n'a pas été validée par nginx -t, seulement relue directive par directive contre la documentation de nginx.org. Les citations en anglais sont traduites par nos soins. Aucun lien n'est affilié et aucune relation commerciale ne lie l'auteur aux éditeurs cités.
Une erreur ou une version périmée ? Écrivez à contact@neurautech.com : l'article sera corrigé et la date de mise à jour modifiée.
Dernière mise à jour : 30 octobre 2026.
Image : Scott Rodgerson (Unsplash License).
Source: the 26.13.0 (LTS) release notes from @nodejs.org, plus the Release WG schedule.
nodejs.org
nodejs/Release
Node.js 26 resmi LTS (patch sampai April 2029). Node 22 berhenti 30 April 2027. Yang baru: Temporal tanpa flag, Map.getOrInsert(). Mulai Node 27 rilis besar setahun sekali, semua jadi LTS.
nodejs/Release
Node v26 is LTS now according to the release schedule nodejs/release
1 like · 61 views
nodejs/release
Node.js 26がLTSになった。
これでSeaとかが普通に使える
78 likes · 20 reposts · 6,711 views
Dependabot の通知を、32リポジトリぶん追えなくなった。AIと作った「依存の見回り
TL;DR
個人で32リポジトリを持っている。棚卸ししたら、15リポジトリに high 以上の Dependabot アラートが合計345件あった(アラートは脆弱性ごと・ファイルごとに1件なので、パッケージの数よりずっと多い)。Dependabot は入っていた。通知も来ていた。追いきれていなかった
足りなかったのは通知ではなく、「全リポジトリを横断した一覧」と「どれから直すかの順番」だった
clone せずに API だけで全リポジトリを週1回見回り、P1〜P3 の順位をつけて、P1 があるときだけ知らせる仕組みにした。件数を数える部分は32リポジトリで約50秒
AI にコードを書かせるときは、版を AI の記憶から書かせない。入れる直前に最新を確かめる
そして、この記事を書いている途中で、自分がその罠を踏んだ。記憶の経験則(「Node の奇数の版は短命」)で決めた設定が、もう古かった
Dependabot のメジャー更新の PR より小さい直し方があったこと、件数を数えるスクリプト、優先度の決め方、踏んだ落とし穴5つを置いておく。
プレビューが落ちた。原因は tailwind 4 だった
10月24日、Vercel から「プレビューのビルドに失敗」と通知が来た。半年以上触っていない個人アプリのリポジトリだ。
コードは何も変えていない。来ていたのは、Dependabot の PR だった。tailwindcss を 3 から 4 に上げる PR で、そのプレビューが落ちていた。
原因はすぐわかった。postcss.config.js が tailwindcss: {} を直接読んでいる。v4 では PostCSS のプラグインが @tailwindcss/postcss という別のパッケージになった(Tailwind CSS の移行ガイド)。
わからなかったのは、なぜメジャー更新の PR が来たのかだ。版の更新の設定(dependabot.yml)は入れていない。このリポジトリで動いているのは、脆弱性を直すための「セキュリティアップデート」だけのはずだった。
調べたら、こういうことだった。
postcss-selector-parser に脆弱性が公表された(GHSA-rj75-hqrm-r3gf、2026年10月5日、深刻度は moderate)。アドバイザリの記録では、対象は < 7.1.6、修正版は 7.1.6
tailwind 3 は postcss-selector-parser の 6 系(^6.1.2)を指定している
だから Dependabot は「tailwind ごと 4 に上げる」PR を出した
セキュリティアップデートでも、メジャー更新の PR は来る。 中の部品を直すために、外側のパッケージが丸ごと上がることがある。マージする前に気づけたのは、プレビューが落ちてくれたからだ。
しかも、続きがあった。postcss-selector-parser のリリースを見ると、10月19日に 6.1.5 が出ていて、同じ脆弱性の修正が 6 系にもバックポートされていた。6.1.5 は tailwind 3 の指定範囲に入る。つまり、tailwind を 3 のまま直せた。アドバイザリの記録がまだ 6.1.5 を修正版として載せていないので、Dependabot は大きいほうの直し方を出してきた、ということだ。
Dependabot が出した直し方が、いちばん小さい直し方とは限らない。 メジャー更新の PR が来たら、上げる前に、その部品のリリースノートでバックポートが出ていないかを見る。
ここで嫌な予感がした。半年触っていないリポジトリでこれなら、ほかはどうなっているのか。
棚卸ししたら、345件あった
Claude Code に、自分の GitHub の全リポジトリを棚卸ししてもらった(数字は2026年10月24日時点)。アーカイブとフォークを除いて32個。
見たもの
結果
high 以上の Dependabot アラートがあるリポジトリ
15個(合計345件)
開きっぱなしの Dependabot の PR
53本
サポートが終わったランタイムに上げようとしている PR
4本
台帳に載っていない(存在を忘れていた)リポジトリ
5個
345件は「直すべきパッケージの数」ではない。Dependabot のアラートは、脆弱性ごと・ファイルごとに1件になる。1つのパッケージだけで、アラートが30件以上あったこともある。それでも、多いことには変わりない。
Dependabot は入っていた。アラートのメールも来ていた。でも、リポジトリごとにバラバラに届くので、「どれが本番で、どれが危ないか」を毎回頭の中で組み立てないといけない。32個になると、それはもうやらない。
通知が来ていることと、見ていることは違う。
最後の行も地味に効いた。台帳から漏れていたリポジトリは、見回りの対象からも漏れる。その中には、本番で動いているアプリも含まれていた。
clone しないで、全リポジトリを見回る
やりたいことはシンプルにした。
全リポジトリについて「使っているパッケージ」「脆弱性」「ランタイム」「止まっている PR」を1つのファイルにまとめる
優先度をつけて、上から順に直せるようにする
週1回、自動で回して、P1 があるときだけ通知する
読む先は API だけ(clone しない)
最初は全リポジトリを clone して npm audit を回していた。32個 clone して、npm ci して……は重い。
GitHub には、clone しなくても読める情報がそろっている。
知りたいこと
読む先
脆弱性(深刻度・直接か間接か・実行時か開発用か・直る版)
Dependabot alerts の REST API
実際に入っている版(lockfile から GitHub が作ったもの)
SBOM の REST API
止まっている PR
gh pr list --app dependabot
最新版
npm / PyPI の公開 API
ランタイム
Dockerfile の FROM と .nvmrc(contents API で読む)
GitHub 以外に読むのは、最新版(npm / PyPI)とランタイムのサポート予定(endoflife.date)の公開 API だけだ。
まず、自分にどれだけ溜まっているかを見る
件数を数えるだけなら、これで足りる。
deps-alerts.sh
#!/bin/bash
使い方: deps-alerts.sh <owner>
リポジトリ(アーカイブ・フォークを除く)ごとに、
開いている Dependabot アラートの critical / high の件数と、Dependabot の PR の本数を出す。clone はしない
読めなかったリポジトリ(権限がない・Dependabot alerts が無効など)は 0 ではなく「?」と出す
要るもの: gh(gh auth login 済み)・jq。確認した環境: bash 3.2 / gh 2.103 / jq 1.7
set -uo pipefail
owner="${1:?owner を渡す}"
printf '%-36s %8s %5s %4s\n' repo critical high PR
for repo in $(gh repo list "$owner" --limit 200 --no-archived --source --json name --jq '.[].name'); do
if sev=$(gh api --paginate "repos/$owner/$repo/dependabot/alerts?state=open&per_page=100" \
--jq '.[].security_advisory.severity' 2>/dev/null); then
crit=$(grep -c '^critical$' <<< "$sev"); high=$(grep -c '^high$' <<< "$sev")
else
crit='?'; high='?'
fi
prs=$(gh pr list -R "$owner/$repo" --app dependabot --state open --json number --jq length 2>/dev/null || echo '?')
printf '%-36s %8s %5s %4s\n' "$repo" "$crit" "$high" "$prs"
done | sort -k2,2nr -k3,3nr
動かし方:
chmod +x deps-alerts.sh
./deps-alerts.sh <自分のユーザー名か org 名>
出力はこうなる(名前は伏せた)。critical が多い順に並ぶ。自分の32リポジトリでは、約50秒かかった(2026-10-24〜25 に計測)。
repo critical high PR
repo-a 7 41 3
repo-b 3 20 5
repo-c 0 1 1
repo-d 0 0 0
注意が3つある。
「?」は「読めなかった」で、0件ではない。権限が足りない(管理者でない org など)か、Dependabot alerts が無効になっている。リポジトリの Settings → Advanced Security(Code security)で Dependabot alerts を有効にする
最初のバージョンは、読めなかったリポジトリを 2>/dev/null で捨てていて、0件と表示していた。セキュリティの道具で「読めなかった」を「きれい」に見せるのが、いちばん危ない
修正をマージしても、アラートが閉じるのは GitHub が数え直してから。直後に数えると、件数は変わっていないことがある
優先度は「本番で、実行時に使うか」で決める
件数だけ並べても、345件は直せない。順番が要る。
優先度
当てはまるもの
いつまでに
P1
critical の脆弱性/本番のあるリポジトリで、実行時に使うパッケージの high/本番のあるリポジトリで、サポートが終わったランタイム
見つけた週のうちに PR
P2
開発用だけ・本番のないリポジトリの high/本番のあるリポジトリで、LTS でない Node
次にそのリポジトリを触るとき
P3
moderate/メジャー版の遅れ/サポート終了まで90日以内のランタイム/14日以上止まっている Dependabot の PR
月1回まとめて
「実行時か開発用か」は、アラートの dependency.scope に runtime / development で入っている。値が入っていない(null)こともあるので、そのときは実行時として扱う(甘いほうに倒さない)。
「本番があるか」は API ではわからない。自分のリポジトリの台帳(どれが本番で、どこにデプロイしているか)を Markdown の表で持っていて、そこから読んでいる。台帳が見回りの対象を決めるので、台帳に載っていないリポジトリも一覧の上に出すようにした。
| repo | 役割 | 本番 |
|------|------|------|
| my-app | 家計のアプリ | Cloud Run |
| my-tool | 手元の道具 | — |
週1回、P1 があるときだけ知らせる
自分の環境では、毎週日曜の朝に Mac mini の定期実行(LaunchAgent)で回し、結果を Markdown に書き出している。cron でも GitHub Actions の schedule でも同じことができる。
通知(自分は ntfy でスマホへ)は P1 があるときだけ。P2・P3 だけの週は送らない。毎週「P3 が40件あります」と来たら、3週目には見なくなる。それでは Dependabot のメールと同じだ。
通知にはこう出す。
依存の更新: P1 が 6 リポジトリ
(リポジトリ名):(パッケージ名)(直接)の脆弱性 critical ×N → X.Y.Z 以上に上げる(全部直る版)
...
一覧: ~/.local/state/.../inventory.md
「何が・何件・どうすればいいか」まで書く。自分の場合、最後は Claude Code に「依存の P1 を直して」と頼むだけになっている。
作るときに、古いものを入れない
見回りは「入ってしまった古いもの」を見つける仕組みだ。もう一つ、入口も閉じた。
AI にアプリを作らせると、AI が覚えている版で package.json を書くことがある。学習した時点では最新でも、書いている今は古い。古いリポジトリの package.json を参考にさせても同じことが起きる。
新しいリポジトリを作るときの雛形に、こう書いた。
版は記憶や古いリポジトリから写さない。入れる直前に最新の安定版を確かめる(npm view <名前> version、公式ドキュメント)
雛形(create-next-app など)は @latest で作る
Node は LTS の版を使う。どれが LTS かは、記憶で決めず Node.js のリリース予定で確かめる
lockfile をコミットし、npm ci で入れる
.github/dependabot.yml を最初から入れる
最初の PR で npm audit --audit-level=high が 0 件
Node の行は、最初はこう書いていた。「偶数の版を使う。奇数の版は短命なので使わない」。これが間違いだった話は、落とし穴の最後に書く。
全部は直さない、と先に決めた
345件を直すにあたって、ルールを決めた。
セキュリティ修正(P1・P2)が先。マイナー・パッチの更新はまとめて1本。メジャー更新は1パッケージ1本で、公式の移行ガイドを読んでから
各 PR で install → lint → typecheck → test → build を全部通す。Vercel などのプレビューも通す
見た目が変わっていないことを機械で確かめる。main とブランチのビルドを並べて、主要画面を 1280px と 320px で撮り、ピクセル単位で比べる
置き換えた Dependabot の PR は、新しい PR にリンクしてから閉じる
半年以上触っていないリポジトリは、脆弱性の修正だけ。ふだんのメジャー更新はしない。ただし、脆弱性を直すのにメジャー更新が要るなら、それは上げる。上げられないなら公開を止める
ビルドが通らない更新は無理に直さない。原因と選択肢を書いて止まる
3 は思ったより効いた。依存を上げると、JS のファイル名(ハッシュ)は全部変わる。差分を目で見ても「変わった」としか言えない。スクショのピクセルが一致すれば、少なくともその画面は変わっていないと言える。
ひとつ引っかかったのは、アニメーションのあるページだ。そのままだと main 同士で撮っても一致しない。アニメーションを止めてから撮り、「止めた状態なら main 同士は一致する」ことを先に確かめてから比べた。
5 は、全部を最新にしようとすると終わらないからだ。半年触っていないアプリを React 19 と tailwind 4 と vite 8 に上げる作業は、ほぼ作り直しになる。公開しているなら脆弱性だけは直す。それ以上は、そのアプリをまた触るときにやる。
この線引きで、Claude Code に8リポジトリぶんの PR を並行で作ってもらい、自分が見てマージした。
踏んだ落とし穴5つ
1. 「直る版」は、いちばん新しい修正版を見る
あるライブラリについて、最初の棚卸しで「次のマイナー版に上げれば直る(メジャー更新はいらない)」と判断した。Dependabot は次のメジャー版への PR を出していたので、「メジャーを避けられる」と思った。
間違いだった。
アラートを1件ずつ見ると、同じライブラリに、修正版の違うアラートが混ざっていた。
アラート
直る版
一部
次のマイナー版
残りの大半
次のメジャー版
マイナー版で直るのは一部だけ。同じパッケージに複数のアラートがあるとき、全部直るのは「各アラートの直る版のうち、いちばん新しいもの」。見回りの一覧では「○○ 以上に上げる(全部直る版)」と、この値を出すようにした。
2. dependabot.yml の ignore は、セキュリティ修正の PR にも効く
「当面メジャー更新しないもの」を Dependabot に伝えるのは ignore だ。ただ、GitHub の設定リファレンスでは、ignore はセキュリティアップデートの PR にも影響する設定として印がついている。
雑にパッケージを丸ごと ignore すると、そのパッケージに脆弱性が出たときの修正 PR まで止まる。だから範囲を絞る。
version: 2
updates:
- package-ecosystem: docker
directory: /
schedule:
interval: weekly
Node 25 までの奇数の版(23・25)は LTS にならないので上げない
出典: nodejs/Release%EF%BC%882026-10-25 確認)
見直す日: 2027-10-20
ignore:
- dependency-name: node
versions: [">= 23, < 24", ">= 25, < 26"]
それでも、脆弱性のアラートは ignore と関係なく届く。止まるのは PR だけだ。見回りはアラートを読むので、ignore したパッケージに脆弱性が出ても P1 には上がってくる。
ignore には必ず「理由」「出典」「見直す日」を書く。書かないと、半年後に誰も外さない。
3. ほかの部品が「その版ちょうど」を指定していることがある
あるリポジトリで、テスト用の部品(miniflare)が alpha 版に固定されていて、その中の部品(undici・sharp)に high の脆弱性があった。npm audit の提案は「miniflare を安定版の 4 系にする」。alpha の 5 系から見ると、版を戻すことになる。
調べると、Cloudflare の wrangler(使っていた 4.135.0)が、その alpha 版をちょうど指定していた。miniflare を動かすと wrangler と食い違う。
こういうときは外側を動かさず、package.json の overrides で中の部品だけを直る版に寄せた。
"overrides": {
"undici": "7.30.1",
"sharp": "0.35.5"
}
undici は同じ 7 系のマイナー、sharp は 0.35 系のパッチなので、ビルドとテストが全部通ることを確かめて入れた。overrides は便利だが、外し忘れると今度はそれが古くなる。PR に理由を書いておく。
4. 使っていない依存は、上げるより消す
半年触っていないアプリに、@vercel/node が開発用の依存として入っていた。中の部品に high があり、直すにはメジャー更新が要る。
コードを探したら、どこからも使われていなかった。api/ フォルダも vercel.json もない。上げるのをやめて、消した。
いちばん安全な依存は、入っていない依存だ。
5. 経験則も古くなる:Node 27 は奇数でも LTS になる
Dependabot が、Docker のベースイメージを node:22-alpine から node:25-alpine に上げる PR を4本出していた。Node 25 は奇数の版で、LTS にならず、2026年6月1日にサポートが終わっている。上げる理由はない。
ここまではよかった。そこで自分(と Claude Code)は、「Node の奇数の版は短命」という覚え方のまま、23・25・27 を ignore する設定を4リポジトリに入れ、規約にも「奇数の版は使わない」と書いた。
翌日、この記事の事実確認を別の AI にやらせたら、指摘が来た。Node.js のリリース予定では、v27 からすべての版が Alpha → Current → LTS になる。27 が LTS になる予定日は 2027年10月20日。奇数が LTS にならないのは 25 までの話で、27 を ignore すると、将来の LTS への更新を止めてしまうところだった。
同じ日に、Python 3.10 のサポート終了日も1か月ずれていたのが見つかった(正しくは 2026年10月1日)。
AI が古くなるのは、版の数字だけではない。「いつもこう」という方針の覚え方も古くなる。 直したのは3つだ。
ランタイムのサポート予定を手書きの表で持つのをやめ、見回りのたびに endoflife.date の API から取る
ignore には「理由・出典・確認日・見直す日」を必須にし、抜けや期限切れを一覧に出す
版や ignore の方針を決める PR は、実装した AI とは別の AI に一次ソースと照合させてからマージする
最後のものが、今回いちばん効いた。自分で決めたことを自分で確かめても、同じ記憶で確かめるだけだった。
結果
8リポジトリに、脆弱性の修正の PR を出してマージした。npm audit の high 以上は、この8リポジトリで計97件 → 4件。残った4件は、直すのにメジャー更新が要るもの(冒頭の345件は Dependabot アラートの件数で、数え方が違うので並べて比べない)
Node 25 への PR は4本とも閉じ、ignore を「23・25 だけ」に直して入れた
台帳から漏れていた5リポジトリを台帳に戻し、見回りの対象にした
本番で動いていて、直すのにメジャー更新が要る critical が1つ残っていた。Next.js 14 の画像最適化 API で、認証なしでコードを実行される脆弱性だ(14 系には修正版がない)。「手間が大きい」と後回しにしていたのを、この記事の公開前チェック(攻撃者の目で読ませる係)に指摘された。Next.js 15 に上げてデプロイしてから、この記事を出している
ほかのメジャー更新が要るものは、1パッケージ1本で、移行ガイドを読みながら進める
全部が片付いたわけではない。ただ、どこに何が残っているかを、毎週わかる仕組みにした。
自分の現場に持ち帰るなら
上のスクリプトで、自分のリポジトリにどれだけ溜まっているかを見る。「?」が出たら、まずそこを読めるようにする
件数の多い順ではなく、「本番で、実行時に使うもの」から直す
版・サポート期間・ignore は、記憶ではなく一次ソースで決める。決めた PR は、別の目(人でも AI でも)に照合させる
Dependabot は便利だ。でも、Dependabot が教えてくれるのは「このリポジトリに何があるか」までで、「32個の中でどれから直すか」は教えてくれない。そこを決めるのは、自分の台帳だった。
nekoai-lab.github.io
【要対応】Node.jsの脆弱性10件が修正。Claude Code・MCP利用者が今すぐ確認すべきこと
TL;DR
2026年10月21日、Node.jsの深刻な脆弱性を含む10件が修正された。 修正版は v22.23.4 / v24.22.1 / v26.12.1。該当する古いNodeを使っているなら更新が必要
Nodeを更新しただけでは終わらない。 自分のMacでは、修正前のNodeで動くプロセスが19個残っていた
Claude Code本体、MCPサーバー、Cursor CLI、公開アプリでは確認・更新する場所が異なる
本番のDockerイメージも、再ビルド・再デプロイしない限り古いまま残る
この記事では、今すぐ確認する手順、実際に対応した結果、同じことを繰り返さないための仕組みをまとめた
Node を上げた。19個、直っていなかった
10月23日、仕事のチャットに「Node.js が危ないのでアップデート必須。Claude Code を使っている人はガッツリ関係する。会社単位じゃなく個人単位で」と連絡が回ってきた。
自分も最初はこう思った。
「Claude Code って Node で動いてるんだっけ?」
「本体は勝手に更新されるんでしょ?」
「GitHub も見るの? GCP は? Vercel は?」
調べてみたら、答えはどれも「半分合ってる」だった。
自分の Mac を見たら、Node は修正前の 26.10.0 のまま。brew upgrade node で 26.12.1 に上げた。これで終わり、と思った。
念のためプロセスを数えた。
修正前の Node で動き続けているものが、19個あった。
Astro の dev サーバーが10個。Next.js が4個。Claude Code が裏で起動した MCP サーバーが5個。いちばん古いものは1週間前から動いていた。しかも最初に打ったコマンドでは、そのうち3個が見えていなかった。
極めつけは、この調べものに使っていた Claude Code 自身だ。スマホから使うために Mac mini で常駐させていた Remote Control の親プロセスが、1週間前の 2.1.292 のまま動いていた。claude update で新しい版を入れても、そこから起動するセッションは古いままだった(後述)。
アップデートは、インストールした時点では終わらない。この記事は、その話だ。
自分はどれ? 3つの質問で判定する
質問
はい →
いいえ →
① Claude Code・Cursor・Codex などの AI ツールを使っている?
A に該当。次の質問へ
次の質問へ
② ターミナルで node -v を打つと、バージョンが出る?
B に該当。次の質問へ
次の質問へ(ただしアプリ同梱の Node は残る。後述)
③ Node で作ったアプリやサーバーを、ネットに公開している?
C に該当
—
そのうえで、node -v や本番の Node が v18・v20・v23・v25 なら D も当てはまる。修正版が出ない。
区分
誰
やること
所要
A
AI ツールを使っている人
Claude Code・Cursor CLI・デスクトップアプリを最新に
5分
B
PC に Node が入っている人(MCP サーバー、npx、npm で入れた CLI、dev サーバー)
Node を更新して、動いているものを再起動
10〜20分
C
Node のアプリを公開している人
本番の実行環境を更新して再デプロイ
環境しだい
D
Node 20 以前などを使っている人
22 / 24 系に乗り換え
半日〜
Claude Code 本体だけ使っている人は、まず A。たぶんこの層がいちばん多い。
ただ、Claude Code を入れるときに「Node も入れてね」と言われて Homebrew で入れた人は多いはず。その場合は B も当てはまる。node -v を一回打ってみてほしい。
結局、どこを見ればいいのか
見る場所は3層ある。
層
何を見る
見落としやすいもの
① PC(持っている台数ぶん)
Claude Code の版、node -v、動いている Node、アプリ同梱の Node
普段使わないPC、起動しっぱなしの dev サーバー
② リポジトリ
Dockerfile の FROM node:..、.nvmrc / .node-version、package.json の engines、GitHub Actions の node-version
FROM node:20 のまま、node-version: 24.14.0 みたいな細かい固定
③ 本番
実際に動いているランタイムの版
「Dockerfile は直したけど、まだデプロイしてない」
②はリポジトリの置き場所でこう打てば一覧が出る。
grep -rnE "FROM node|node-version|\"node\":" \
--include=Dockerfile* --include=.yml --include=.yaml \
--include=package.json --include=.nvmrc --include=.node-version \
--exclude-dir=node_modules .
自分のリポジトリ(10本ちょっと)でやったら、こうなった。
本番が Cloud Run(自前の Dockerfile)で FROM node:20-alpine のもの:1本。修正が出ない系列。最優先
本番が Cloud Run で FROM node:22-alpine のもの:3本。イメージを作り直して再デプロイ
CI で node-version: 24.14.0 に固定しているもの:1本。本番は Cloudflare Workers なので Node では動いていない。CI だけ上げればいい
Python のサービス、静的サイト(Firebase Hosting):本番は関係なし
CI(GitHub Actions)は、外から来たデータを展開する場面が少ないので優先度は低い。でも node-version の固定は、ついでに上げておく。
そもそも何が直ったのか
Node.js の公式発表では、10件のうち4件が深刻度「高」だった。
CVE
ざっくり言うと
危ない場面
CVE-2026-111497
Brotli(圧縮形式の一つ)を展開するとき、メモリの範囲外に書き込める
外から来た Brotli データを Node が展開するとき
CVE-2026-106069
0127.0.0.1 みたいな変な書き方の IP アドレスで、TLS 証明書のチェックがずれる
接続先のホスト名を外部入力で決めていて、かつ通信経路を握られているとき
CVE-2026-105483
全角数字の IP アドレスで、TLS 証明書のチェックがずれる(24 / 26 系だけ)
同上
CVE-2026-105482
dns.resolve が名前の途中の NUL 文字(\0)で切れる
「許可リストで名前をチェックしてから名前解決する」作りのとき
残り6件は中・低。HTTP の keep-alive、crypto.randomUUID()、権限モデルまわりなど。
修正版は v22.23.4 / v24.22.1 / v26.12.1。回避策は出ていない。上げるしかない。
Brotli のバグは、理屈の上では任意のコード実行までつながる種類のものだ。Node の発表でも「リモートからのコード実行を許す」と書かれている。ただ、10月23日時点で GitHub に公開されている PoC と解析を見ると、5本のうち4本は「リモートからのコード実行は成立していない。実際にはプロセスを落とせる(DoS)」という結論だった(条件を固めたローカル環境でなら実行できた、という報告はある)。落とせるだけでも、サーバーなら十分困る。
どんな場合に危ないのか
ここがいちばん混乱しているところだと思う。
TLS と DNS の3件は、条件がかなり狭い。「接続先を外部入力で決めるサーバーを作っている」「経路上に攻撃者がいる」くらいの前提が要る。普通に使っているだけなら、ほぼ当たらない。
問題は Brotli の1件だ。こっちは入口が広い。
当たる:Node が外部のサイトやAPIを fetch() する
Node の fetch() は、HTTPS のとき「Brotli で圧縮して送ってくれていいよ」とサーバーに伝える。サーバーが Brotli で返してきたら、中身を自動で展開する。
つまり、相手のサーバーが悪意を持っていたり、乗っ取られていたりしたら、fetch() しただけで細工したデータが届く。
当てはまるもの:
URL を取りに行く MCP サーバー(Web取得、ブラウザ操作、スクレイピング系)
外部 API を叩くスクリプトやバッチ
Claude Code に作らせた「URLを入れると要約するツール」みたいなもの
当たる:外からのリクエスト本文を展開するサーバー
Express 5(body-parser 2.x)は、express.json() などのパーサーを使っていると、Content-Encoding: br のリクエスト本文を既定で展開する。
Express で API を公開しているなら、C に該当する。
ちなみに公開されている PoC(攻撃の実証コード)の一部も Express を使っている。ただし受け取った本文を自前の処理で Brotli 展開する作りで、body-parser の既定の動作を狙ったものではない。
当たる:受け取ったファイルを zlib で展開する
アップロードされた .br ファイルを展開する、みたいな処理。これも外から来たデータの展開だ。
ほぼ当たらない:自分のPCの中だけで完結するもの
localhost でしか見ていない dev サーバーが、外部と通信していないなら、細工したデータが届く経路がない。
ただし「dev サーバーの中で外部 API を fetch() している」なら当たる。Next.js のサーバー側で外部APIを呼んでいる、とか。けっこうある。
当たらない:Node を入れているだけ
インストールされているだけで、何も動いていなければ攻撃は届かない。ただ、MCP サーバーは Claude Code を開くたびに裏で起動する。「動かしていないつもり」が一番あぶない。
Claude Code 本体の話:Node じゃなくて Bun
本体は Node を使っていない
Claude Code の公式ドキュメントには、「インストールされた claude は Node を呼び出さない」とある。npm で入れた場合も、中身は同じネイティブバイナリだ。
だから「Node を更新すれば Claude Code 本体が直る」は、正しくない。
中の Bun にも、同じ Brotli のコードが入っていた
Claude Code のネイティブ版は Bun で作られている。
その Bun にも、同じ Brotli ライブラリが入っていた。Bun の issue #46352 では「全リリースが影響を受ける」とされ、10月22日に修正がマージされた(日本時間11時ごろ)。修正を含む Bun v1.4.4 のリリースは同日19時ごろだ。
じゃあ Claude Code 本体で攻撃が届くのか。ここは分からない。Claude Code の中で、外から来た Brotli データを Bun が展開する場面があるのか。Anthropic は何も公表していない。
2.1.308 に上げた。直ったかは分からない
自分の Mac に入っていたのは 2.1.306。公開は10月21日(日本時間12時ごろ)で、Bun の修正より前だ。
claude update を打ったら 2.1.308 になった。
$ claude update
Current version: 2.1.306
Updating to 2.1.308...
Successfully updated from 2.1.306 to version 2.1.308
2.1.308 の公開は10月22日の13時半ごろ。Bun の修正マージよりは後、Bun v1.4.4 のリリースよりは前。リリースノートは「Bug fixes and reliability improvements」の1行だけだった。
なので言えるのは「最新にしておくのが、いまできる最善」まで。直ったとは言えない。
「本体は勝手に更新される」は本当か
入れ方による。公式ドキュメントを読むと、こうなっている。
入れ方
自動で更新される?
手で上げるコマンド
公式インストーラー(curl ... install.sh)
される
claude update
npm
される(書き込み権限があれば)
npm install -g @anthropic-ai/claude-code@latest
Homebrew(brew install --cask claude-code)
既定ではされない
brew upgrade claude-code
WinGet
既定ではされない
winget upgrade Anthropic.ClaudeCode
apt / dnf / apk
されない
OS のアップデートで
Homebrew と WinGet は、CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE=1 を設定すると Claude Code が代わりに更新してくれる。
自分の Mac は公式インストーラー版で、9/26・10/7・10/16・10/22 と更新されていた。
それでも 2.1.306 で止まっていた。自動更新は「起動したときと、動いている間にときどき」見に行って、次に起動したときに切り替わる仕組みだからだ。急ぐなら claude update を手で打つ。
ちなみに配布には latest と stable(ふだんは約1週間遅れ)の2つのチャンネルがある。今回は10月22日の時点で、stable も 2.1.308 になっていた。
逆に Node は、誰も勝手に更新してくれない。Homebrew で入れたなら、brew upgrade を打つまで永遠に古いままだ。
手順
まず一発で確認する
PC ごとに、これをターミナルに貼る。普段使わない PC も忘れずに。
Mac / Linux:
echo "== Claude Code"; claude --version 2>/dev/null || echo "入っていない"
brew list --cask 2>/dev/null | grep '^claude-code' && echo "↑ Homebrew 版。既定では自動更新されない"
echo "== Node"; which -a node 2>/dev/null; node -v 2>/dev/null || echo "入っていない"
echo "== Cursor CLI"; cursor-agent --version 2>/dev/null || echo "入っていない"
echo "== 動いている Node"; ps -axo pid,lstart,command | grep -E '[n]ode |[n]ext-server' | cut -c1-150
Windows(PowerShell):
"== Claude Code"; claude --version
"== Node"; where.exe node; node -v
"== 動いている Node"; Get-Process node -ErrorAction SilentlyContinue | Select-Object Id, StartTime, Path
最後の「動いている Node」の読み方:
起動時刻が Node をアップデートする前 → 古い Node で動いている。再起動する
パスが /Applications/〜.app/ の中 → アプリが同梱している Node。アプリの更新を待つ
パスが Homebrew の Caskroom の中 → Homebrew で入れたツール(Cursor CLI など)が同梱している Node。brew upgrade --cask <名前> で上がることがある
コマンドの引数に node という文字を含むだけの別のプロセスも出てくる。パスを見て判断する
next-server → Next.js の dev サーバー。名前に node が付かないので、grep node だけだと見逃す。自分はこれで3個見逃した
「入っていない」と出ても、それは PATH の上に無いというだけだ。アプリ同梱の Node、Docker、本番は別に見る(下の「Node を同梱しているアプリ」と C)。
A:AI ツール本体
claude update
claude --version # 2.1.308 以降を推奨(修正が入ったかは未公表)
Homebrew で入れた人は brew upgrade claude-code(latest 版なら claude-code@latest)、WinGet なら winget upgrade Anthropic.ClaudeCode。
開いている Claude Code は、一度閉じて開き直す。動いているプロセスは古い版のままだからだ。
Remote Control(claude remote-control)を常駐させている人は要注意だ。自分の Mac mini では、スマホから使うために claude remote-control を tmux の中で常駐させていた。これが10月16日に起動したまま、当時の 2.1.292 で動き続けていた。スマホから新しく作ったセッションも、全部この 2.1.292 で起動していた。claude update で 2.1.308 を入れても、常駐している親が古いままなら子も古い。
常駐している親ごと止めて、起動し直す。自分の環境(tmux セッション名 claude、LaunchAgent で起動)ならこうだ。動いているリモートのセッションは全部切れるので、作業が終わってから。
tmux kill-session -t '=claude'
launchctl kickstart gui/$(id -u)/com.nekoai.remote-control # 自分で作った LaunchAgent の名前
ps -axo lstart,command | grep '[c]laude/versions/' # 新しい版で動いているか確認
Cursor を使っているなら CLI も。中に自前の Node を抱えている。
cursor-agent update # 公式インストーラーで入れた場合
brew upgrade --cask cursor-cli # Homebrew で入れた場合
自分の Mac では、cursor-agent update で 2026.10.22 版になって、中の Node が v26.8.1 から v26.12.1 に上がった。
デスクトップアプリも見ておく。10月23日、もう1台の Mac で Claude のデスクトップアプリを開いたら「Claude has an important security update」と出た。Mac mini のほうは、気づいたときには自動で入っていた。ChatGPT のデスクトップアプリにもアップデートが来ていた。中身が今回の件かは、どちらも公表されていない。Claude アプリの更新版(3.6556.1)の中の Node は v24.22.0 で、Node の修正版ではなかった。アプリのログを見ると、アプリに内蔵されている Claude Code が 2.1.304 から 2.1.308 に上がっていた。アプリの「新着情報」(10月22日付)には Node や Brotli の記載はなく、作業フォルダの権限や SSH まわりの変更・修正が並んでいた。「important」はそっちの話なのだと思う。それでも「important」と出ているなら入れる。
Mac の Claude アプリなら、画面上のメニューバーの「Claude」→「アップデートを確認…」→「再起動してアップデート」。アプリの中のアカウントメニューには無いので注意。
B:PC の Node
まず、どこから入れた Node かを確認する。
which -a node
ls -l "$(which node)"
node -v
ls -l の結果
入れ方
更新コマンド
.../Cellar/node/... を指している
Homebrew
brew update && brew upgrade node
~/.nvm/...
nvm
下を参照
fnm を含むパス
fnm
fnm install 24 && fnm default 24
~/.volta/...
Volta
volta install node@24
mise を含むパス
mise
mise use -g node@24
/usr/local/bin/node で Cellar を指していない
公式インストーラー
nodejs.org から最新版を入れ直す
/usr/local/bin/node は、Intel Mac の Homebrew でもここに入る。ls -l で Cellar を指していたら Homebrew だ。Homebrew で node@22 や node@24 を入れているなら、brew upgrade node@24 のように指定する。
nvm は、入れるだけだと足りない。
nvm install 24 --reinstall-packages-from=current # npm -g で入れたものも引き継ぐ
nvm alias default 24
nvm use 24 # 今のターミナルも切り替える
プロジェクトの .nvmrc や、MCP の設定ファイルに ~/.nvm/versions/node/v22.x/... のような絶対パスを書いていたら、そっちも直す。古い版は nvm uninstall <古い版> で消せる。fnm・Volta・mise も、固定や絶対パスの事情は同じだ。
Windows はこう。
where.exe node の結果
入れ方
更新
C:\Program Files\nodejs\node.exe
公式インストーラー or winget
winget list OpenJS で ID を確認して winget upgrade <ID>。無ければ nodejs.org から入れ直す
...\nvm... や nodejs へのリンク
nvm-windows
nvm install 24.22.1 → nvm use 24.22.1
.volta / fnm を含むパス
Volta / fnm
Mac と同じ
winget の ID は、LTS 版なら OpenJS.NodeJS.LTS、最新版なら OpenJS.NodeJS。入れている方を上げる。
上げたら確認。
node -v
v26.12.1 / v24.22.1 / v22.23.4 以降ならOK(v23・v25 は修正が出ないので D へ)
ここからが本番。「まず一発で確認する」の最後の行をもう一度打つ。アップデートより前に起動したものは、全部古い Node で動いている。
Claude Code(ターミナル)→ /exit して起動し直す。裏の MCP サーバーも新しい Node で起動し直す
Claude デスクトップアプリ・VS Code・Cursor → これらも MCP サーバーを起動している。ウィンドウを閉じるだけでなく、⌘Q(Windows はタスクトレイから終了)で完全に終了してから開き直す
dev サーバー → Ctrl+C で止めて、もう一度起動
最後に、もう一度プロセスを確認する。自分で入れた Node のプロセスに、アップデートより前の起動時刻が残っていなければ、B は完了だ(アプリ同梱のものは残っていい)。
Node を同梱しているアプリ
自分で入れた Node とは別に、アプリが自前の Node を抱えていることがある。自分の Mac で、10月23日に見た版はこうだった。
アプリ
中の Node の版
Cursor の CLI(cursor-agent)
v26.8.1 → 更新後 v26.12.1
Antigravity(Electron)
v24.20.0
Grok Bot(SpaceXAI と Cursor が出しているアプリ。署名は Cursor の会社の Anysphere。Electron)
v24.19.0
Claude デスクトップアプリ(Electron、更新版 3.6556.1)
v24.22.0
ChatGPT デスクトップアプリ(中の補助ツール)
v24.21.0(アプリ更新後も同じ)
版が修正前でも、そのアプリで攻撃が届くとは限らない。外から来た Brotli データをその Node で展開する場面があるかは、確認できていない。
Electron(VS Code、Cursor、Antigravity、Slack などの土台)は、10月23日時点の最新リリース(v45.0.0、10月21日)でも中の Node は 24.22.0 で、修正版ではなかった。
Cursor CLI 以外の同梱 Node は、こっちでは直せない。アップデートが来たら、すぐ入れる。それだけ。
Codex CLI は本体が Rust 製で、今回は関係ない。npm で入れた場合も、入口の小さなスクリプトが Node で動くだけだ。
C:公開しているアプリ
本番がどこで動いているかで変わる。
実行環境
自動で直る?
やること
Vercel
管理ランタイムに順次展開中。WAF ルールも入った
Node 20 を使っているなら手動で移行。WAF は外向きの fetch() のレスポンスまでは守れない
AWS Lambda
既定(Auto モード)なら自動で段階適用
Manual / Function update モードやコンテナイメージなら、再デプロイ・再ビルド
Cloud Run(関数として、または --base-image 指定でソースからデプロイ)
自動ベースイメージ更新が有効なら反映される
設定を確認。今回の版の反映告知はまだ見つからなかった
Cloud Run(自分の Dockerfile)
直らない
下の Docker と同じ。作り直して再デプロイ
Firebase Functions
第2世代は Cloud Run の関数と同じ仕組みで、自動更新が既定で有効のはず
コンソールでランタイムを確認し、心配なら再デプロイ
Cloudflare Workers / Firebase Hosting(静的)
本番は Node で動いていない
ビルドに使う Node だけ上げる
Docker
公式イメージ node:22 / 24 / 26 は更新済み
イメージを作り直して再デプロイ。node:20 は更新されない
GitHub Actions
setup-node の配布元には登録済み
check-latest: true にするか、バージョンを固定で書く。ランナーのキャッシュに古い版が残っていることがある
自前サーバー
直らない
B と同じ手順で更新して、プロセスを再起動
Docker は見落としやすい。FROM node:24 と書いてあっても、ビルドし直さないと古いイメージのままだ。
D:Node 20 以前を使っている
Node 20 は2026年4月30日にサポートが終わっている。18・23・25 も終わっている。今回の修正は出ないし、今後も出ない。
Homebrew の node@20 は、10月28日に使えなくなる予定だ。
22 系か 24 系(どちらも長期サポート。26 系も10月28日に長期サポートに入る予定)に移す。破壊的変更があるので、テストを回しながら進める。今日すぐ終わる作業ではないので、それまでは「外部から来たデータを展開する処理」を止められないか考えておく。
確認コマンドと完了の判断は、教材にもまとめた。
nekoai-lab.github.io
時系列で見る
日時(日本時間)
出来事
2026年2月27日
Google の brotli リポジトリに、今回の修正のもとになるコミットが入る
10月14日
Node.js が「21日以降にセキュリティリリースを出す」と予告
10月21日 12時ごろ
Claude Code 2.1.306 公開
10月21日 19時38分
Node.js v22.23.4 / v24.22.1 / v26.12.1 公開
10月22日 0時47分
最初の PoC リポジトリが GitHub に作られる
10月22日 4時27分
Google brotli が v1.2.1 / v1.1.1 / v1.0.10 を公開
10月22日 11時ごろ
Bun が Brotli の修正をマージ
10月22日 13時半ごろ
Claude Code 2.1.308 公開
10月22日 午後
Homebrew の node / node@24 / node@22 の修正版が配布される
10月22日 19時ごろ
Bun v1.4.4 リリース
10月22日 23時ごろ
CISA の評価は「悪用は確認されていない」
いくつか補足。
Brotli のバグは Node 固有じゃない。Google の brotli ライブラリ(1.0.3〜1.2.0)のバグで、CVE の採番も Google だ。CVSS 4.0 では 9.2(Critical)と付いていて、Node 側の評価は High。Brotli を使っている他のソフトも、同梱しているバージョンしだいで影響を受ける。
Node の発表では、Node への報告者として OpenAI の Codex Security チームの名前が載っている。上流の修正は2月に Google の開発者が入れていた。誰が最初に見つけたのかは、どの一次ソースにも書かれていない。
修正のもとになるコードは2月に brotli のリポジトリに入っていた。ただ、そのときはセキュリティ修正として出ていない。セキュリティ修正として世に出たのは10月21日の Node が最初で、brotli 自体のリリースはその翌日だった。
やった結果(実録)
対応は10月23日の朝の数時間。Mac 2台と本番4本。
対象
前
後
Mac mini の Node(Homebrew)
v26.10.0
v26.12.1
Mac mini の Claude Code
2.1.306
2.1.308
Mac mini の Cursor CLI 同梱の Node
v26.8.1
v26.12.1
Mac mini の Claude デスクトップアプリ
3.1623.4
3.6556.1(自動で更新。内蔵の Claude Code は 2.1.308)
Mac mini の起動しっぱなしの Node
19個(最初のコマンドでは16個しか見えなかった)
dev サーバー14個を止めた(親のプロセスも含めて15個を kill)。Claude アプリの MCP 1個はアプリの再起動で消えた。残りの MCP 4個は Remote Control を起動し直して消す
MacBook Pro の Claude Code
2.1.283(9月26日の版)
2.1.308
MacBook Pro の Node
v24.14.0(公式 pkg)
Homebrew の v26.12.1 に切り替え
MacBook Pro の起動しっぱなしの Node(全部 MCP サーバー)
11個(うち5個は9月19日から)
0個
本番(Cloud Run 4本)
node:20 1本、node:22 3本
全部 22.23.4 で作り直してデプロイ。版チェック入り
ChatGPT・Antigravity・Grok Bot の同梱 Node
修正前
アプリの更新待ち
MacBook Pro はひどかった。
Claude Code は1か月前の版。Node で動いていた MCP サーバー11個のうち5個は、1か月以上前の9月19日から動き続けていた。その中には、ブラウザでページを取りに行く playwright-mcp もいた。今回のバグにいちばん当たりやすい種類だ。
Mac を閉じずにスリープで使い続けていると、こうなる。自動更新は「次に起動したとき」にしか効かない。
再発防止:「入れた」と「動いている」の差を仕組みで埋める
今回の穴は、どれも同じ形をしていた。
Node は上げた。でも動いているプロセスは古いまま
Claude Code は上げた。でも常駐している親は古いまま
Dockerfile は合っている。でも本番のイメージは作り直していない
自動更新はある。でも閉じないから効かない
人が気をつけて防ぐのは無理だと思った。なので仕組みにした。
1. Node の入れ方を全部の Mac で揃えた
Mac mini は Homebrew、MacBook Pro は nodejs.org の公式インストーラーだった。入れ方が違うと、更新の手順も違う。だから後回しになる。
MacBook Pro を Homebrew に切り替えた。npm ls -g --depth=0 で、公式インストーラー側に自分で入れたツールが無いことを確かめてから。
以下は Apple Silicon の Mac(Homebrew が /opt/homebrew)で、公式インストーラーから乗り換える場合だ。Intel Mac は Homebrew 自体が /usr/local に入るので、この rm を打つと Homebrew の Node まで消える。先に brew --prefix が /opt/homebrew と出ること、ls -l /usr/local/bin/node が Cellar を指していないことを確かめる。
brew install node
公式インストーラーの Node を消す(npm -g で入れたものがあれば、先に控えておく)
sudo rm -rf /usr/local/bin/node /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack \
/usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack \
/usr/local/include/node /usr/local/share/doc/node /usr/local/share/man/man1/node.1
ターミナルを開き直して
which -a node # /opt/homebrew/bin/node だけになれば OK
これで、どの Mac も brew upgrade node の1行で済む。
2. 本番の Dockerfile に「古い Node なら作らない」を入れた
FROM node:22-alpine と書いていても、作り直さなければ古いまま。逆に、キャッシュや取り違えで古いイメージが混ざることもある。
なので、本番のイメージを作るときに Node の版を確かめて、修正前なら止めるようにした。実行ステージの FROM の直後に1行足すだけだ。
FROM node:22-alpine AS runner
Node.js 2026-10-21 のセキュリティ修正より前の版では作らない
RUN node -e "const m={22:[23,4],24:[22,1],26:[12,1]};const [a,b,c]=process.versions.node.split('.').map(Number);const min=m[a];const ok=a>26||(min&&(b>min[0]||(b===min[0]&&c>=min[1])));if(!ok){console.error('Node '+process.version+' は修正より前か、サポート外');process.exit(1)}"
22.23.4・24.22.1・26.12.1 より前と、20 系などの修正が出ない系列は、ここでビルドが止まる。4本とも入れて、作り直し、デプロイまで通した。
このチェックが効くのは、作り直すときだけだ。作り直していない本番が古いままなのは、これでは分からない。
次は Dependabot に FROM node:.. を見張らせて、新しいイメージが出たら PR が自動で立つようにする(これから)。
3. 週1回、まとめて上げて「古いまま動いているもの」を探す
2つのスクリプトを作った。
weekly-update:Homebrew で入れたもの全部(Node 以外も)、Claude Code、Cursor CLI を上げる。macOS のアップデートが来ていれば知らせる(入れはしない。再起動が要るから)
stale-procs:更新したのに、古い版のまま動いているプロセスを探す
stale-procs の考え方は単純だ。プロセスの実行ファイルを lsof で調べて、いまの node の実体と違えば古い。Homebrew のものなら、Cellar/<名前>/<版> が opt/<名前> の指している版と違えば古い。Node や Python に限らず、Homebrew で入れたもの全部に使える。
Node だけなら、これで同じことが分かる。
cur=$(realpath "$(command -v node)")
for pid in $(pgrep -f 'node|next-server'); do
exe=$(lsof -a -p "$pid" -d txt -Fn 2>/dev/null | sed -n 's/^n//p' | head -1)
case "$exe" in
/Cellar/node/bin/node|/usr/local/bin/node|/.nvm//bin/node)
[ "$(realpath "$exe" 2>/dev/null || echo "$exe")" != "$cur" ] && echo "$pid 古い Node: $exe" ;;
esac
done
next-server のように名前を変えているプロセスも、実行ファイルで見るので拾える。アプリ同梱の Node は、自分では直せないので数えない。
ただし、これで分かるのは Homebrew と nvm で入れた Node だけだ。公式インストーラーの Node は同じ場所に上書きされるので、この比べ方では新旧を見分けられない。fnm・Volta・mise も、この短い版では対象にしていない。その場合は、プロセスの起動時刻が Node を上げた時刻より前かどうかで判断する。
結果はスマホに通知する。ただ、普段から通知は短すぎて読めていなかった。「更新されたな」と分かるだけで、何をすればいいかは分からない。なので、通知だけで次の行動が決まる形にした。
古い版のまま動いているもの 9 件
・Remote Control・アプリから起動したセッション 4件(claude 2.1.292)
→ 親(Remote Control)を作り直すか、そのセッションを閉じる
・MCP サーバー 4件(mcp-pdf-server/node 26.10.0_1)
→ 起動したアプリやセッションを開き直す
・Remote Control の親 1件(claude 2.1.292)
→ tmux のセッションを作り直す
4. 通知に「プロセスの引数」を出してはいけなかった
このスクリプトは Claude Code で書いて、レビューは別の AI(Codex)にさせている。そのレビューで2回止められた。
1回目は、通知にプロセスのコマンドをそのまま載せていた。node app.js --password xxx のような起動のしかたをしていたら、パスワードがログとスマホの通知に流れる。
2回目は、それを直したつもりで「引数のうち、ハイフンで始まらない最初のもの」をスクリプト名として出していた。node -e 'key="..."' のように中身を直接渡すと、そのコードが名前として出る。
最後は「実在するファイルのときだけファイル名を出す。それ以外は node」にして通った。
プロセスの一覧を通知やログに出すスクリプトは、引数を出した時点で秘密が漏れる道になる。自分のレビューだけだったら、2回とも素通りしていたと思う。
5. 片付けをどこまで自動にするか(これから)
通知が来ても、止めるのが人の手作業なら、結局また溜まる。かといって、全部を自動で止めると作業中の dev サーバーやセッションが切れる。なので3段階に分けた。
段階
対象
誰が
全自動
起動した元がもういなくて取り残された古いプロセス。使われていないときの Remote Control の親
週1回の自動実行。止めたものは通知で報告
ひと言で
開いているセッションの MCP、dev サーバー
通知を見て、AI に「古いの片付けて」と頼む。一覧を見せて確認してから止める
人が決める
デスクトップアプリ、macOS のアップデート
自分。通知に「何をすればいいか」が書いてある
MacBook Pro の11個(うち5個は9月19日から)は、たぶん「全自動」で消える種類だった。これを作れば、今回の後片付けの大半は、寝ている間に終わる。
この部分は、設計まで書いて Codex に引き継いだ。
32リポジトリぶんの依存を、そのあとどう見回るようにしたかは、続きに書いた。
zenn.dev
まとめ
AI ツールを使っている人:claude update、Cursor CLI、デスクトップアプリを最新に。開き直す
Node が入っている人:Node を上げて、動いているものを全部再起動。アプリ同梱の Node はアプリの更新で
公開している人:本番の実行環境を確認して、再デプロイ。Docker は作り直す
Node 20 以前の人:今回は直らない。乗り換える
全員:「入れた」と「動いている」の差は、気合いでは埋まらない。週1回の更新と、古いまま動いているものの検出を仕組みにしておく
アップデートは、インストールした時点では終わらない。
古いプロセスを止めた時点で、やっと終わる。
参考
Node.js — October 2026 Security Releases
google/brotli v1.2.1
CVE-2026-111497(CVE.org API)
oven-sh/bun #46352 — Brotli の脆弱性
Bun v1.4.4
ikow/brotli2crash — PoC と解析(実質 DoS とする例)
hypnguyen1209/CVE-2026-111497 — Express を使った PoC
Claude Code — セットアップ(ドキュメント)
anthropics/claude-code — Releases
expressjs/body-parser — read.js
Electron v45.0.0
SpaceXAI and Cursor Launch Grok Bot Agent App in Beta(AI Weekly)
Vercel — Node.js security advisory
AWS Lambda — ランタイムの更新
Cloud Run — 自動ベースイメージ更新
actions/setup-node — check-latest
Node.js リリーススケジュール
4 likes
Mentions
Node 24 vs Node 26: Benchmarks, Breaking Changes and Should You Upgrade (2026) Cross-posted from my blog. The original, with any updates, lives on fabwaseem.com. Node.js 26 became the Active LTS line on October 28, 2026 with v26.13.0 "Lithium", and Node 24 moved to Maintenance LTS on October 20. If you're deciding between Node 24 vs Node 26 for a production app, this post covers what changes, what breaks, and how the two compare on a few benchmarks I ran on the same machine tonight. The short version: Node 26 was faster at JSON serialization, async-heavy code and a plain node:http server, but slower in two places you might not expect, fetch() and inserting a million keys into a Map. Node 24 vs Node 26 at a glance | | Node 24 "Krypton" | Node 26 "Lithium" | |---|---|---| | Version tested | v24.22.2 | v26.13.0 | | Status (Nov 2026) | Maintenance LTS since Oct 20, 2026 | Active LTS since Oct 28, 2026 | | End of life | April 30, 2028 | April 30, 2029 | | V8 | 13.6 | 14.6 | | Undici (powers fetch) | 7.30.1 | 8.12.2 | | NODE_MODULE_VERSION | 137 | 147 | | Bundled npm | 11.19.1 | 11.20.0 | | Temporal global | not available | on by default | Dates come from the Node.js release schedule. Versions are what process.versions printed on each binary. What's new in Node 26 From the Node 26.0.0 release notes and checked on v26.13.0: - Temporal is enabled by default. Temporal.Now.plainDateISO('Asia/Karachi') works with no flag. On Node 24, Temporal is undefined. - V8 14.6 brings Map.prototype.getOrInsert() / getOrInsertComputed() (the upsert proposal) and Iterator.concat(). - Undici 8, so the global fetch() is a different major version of the HTTP client. - TypeScript type stripping still works out of the box for .ts files, as it does on Node 24. --experimental-transform-types was removed, so enums and namespaces that need transformation won't run without a build step. // Node 26 only const counts = new Map(); for (const word of ['a', 'b', 'a']) { counts.set(word, counts.getOrInsert(word, 0) + 1); } const today = Temporal.Now.plainDateISO('Asia/Karachi'); console.log(today.add({ days: 30 }).toString()); const all = Iterator.concat([1, 2].values(), [3].values()).toArray(); // [1, 2, 3] Breaking changes to check before upgrading These are the ones most likely to hit a typical Node.js, NestJS or Next.js codebase: 1. Native addons must be rebuilt. NODE_MODULE_VERSION went from 137 to 147. Anything with a compiled .node file (bcrypt, sharp, better-sqlite3, canvas) needs a fresh install on Node 26. Delete node_modules in your Docker build rather than copying it from a Node 24 stage. 2. http.ServerResponse.prototype.writeHeader() is gone. It was a legacy alias. On v26.13.0 typeof res.writeHeader is undefined. Use writeHead(). 3. The _stream_* legacy modules are removed (_stream_readable, _stream_writable and friends). Old dependencies that require('_stream_readable') will crash at import. 4. module.register() is runtime-deprecated. Running a loader that calls it prints DEP0205 DeprecationWarning: module.register() is deprecated. Use module.registerHooks() instead. It still works, but plan to move custom loaders to registerHooks(). 5. --experimental-transform-types is removed. If you run .ts with enums directly through Node, switch to erasableSyntaxOnly style code or keep a compiler step. A quick way to find the first two in a codebase: grep -rn "writeHeader(\|_stream_readable\|_stream_writable\|module.register(" src/ node_modules/.pnpm 2>/dev/null | head Node 24 vs Node 26 performance: benchmark setup I ran these on November 3, 2026 on one Linux VM with 8 vCPUs (Intel Xeon) and 16 GB RAM. The machine was shared with other work (load average around 1.5 before starting), so treat small differences as noise. To limit that: - Each benchmark process was pinned to one CPU with taskset. - Node 24 and Node 26 runs were interleaved over 5 rounds, and I report the median of the 5 rounds. - The HTTP load generator (autocannon, 100 connections, 10 s) always ran on Node 24 on separate CPUs, so only the server changed. - The fetch() test sent 20,000 requests with 50 concurrent loops to a fixed Node 24 server, so only the client changed. The scripts are plain .mjs files: JSON.parse/JSON.stringify on a 20,000-item document, sorting 300,000 numbers, setting 1 million string keys in a Map, a global regex replace, SHA-256 over 16 MB, allocating 2 million small class instances, and 1 million sequential awaits. Benchmark results Lower is better for times, higher is better for requests per second. Median of 5 rounds. | Test | Node 24.22.2 | Node 26.13.0 | Change | |---|---|---|---| | JSON.parse (20k items) | 11.79 ms | 10.46 ms | 11% faster | | JSON.stringify (20k items) | 7.20 ms | 4.53 ms | 37% faster | | Sort 300k numbers | 54.6 ms | 51.7 ms | 5% faster | | Map.set 1M string keys | 396.7 ms | 507.5 ms | 28% slower | | Regex replace | 2.06 ms | 1.72 ms | 16% faster | | SHA-256 over 16 MB | 9.57 ms | 9.17 ms | 4% faster (within noise) | | 2M class instances | 47.8 ms | 35.0 ms | 27% faster | | 1M sequential await | 58.6 ms | 35.0 ms | 40% faster | | node -e 0 startup | 22.3 ms | 20.7 ms | 7% faster | | RSS after startup | 41 MB | 41 MB | same | | node:http server, req/s | 73,079 | 79,653 | 9% more | | node:http p99 latency | 3 ms | 2 ms | lower | | fetch() client, req/s | 8,442 | 7,601 | 10% fewer | What stood out: - JSON.stringify and await got much cheaper. Those two show up everywhere in API servers, so the 9% higher node:http throughput is consistent with them. - fetch() was slower in 4 of 5 rounds. Node 26 ships Undici 8. If your service makes lots of outbound HTTP calls, measure your own workload before and after. - Map.set with 1 million new string keys was slower in every round. The Node 24 numbers for this test varied a lot (336 to 513 ms), so the size of the gap is uncertain, but the direction was consistent. Code that builds very large maps in a hot path is worth profiling. These are microbenchmarks on one VM. They tell you where to look, not what your app will do. How to upgrade from Node 24 to Node 26 # 1. Switch locally nvm install 26 && nvm use 26 # or: fnm use 26 / volta install node@26 # 2. Reinstall so native addons rebuild against ABI 147 rm -rf node_modules && npm ci # 3. Surface deprecations your code triggers NODE_OPTIONS=--trace-deprecation npm test Then update the places that pin the version: # Dockerfile FROM node:26-alpine AS deps // package.json { "engines": { "node": ">=26" } } Also update .nvmrc, your CI matrix and the Node version in your hosting panel. If you deploy Next.js in Docker, the multi-stage setup in my Next.js Dockerfile guide works the same with node:26-alpine as the base image. Should you upgrade to Node 26 now? - New projects: start on Node 26. It's the Active LTS line and is supported a year longer than Node 24. - Existing Node 24 apps: there's no rush. Node 24 gets security fixes until April 30, 2028. Upgrade when you next touch your Docker base image, after rebuilding native addons and running your tests with --trace-deprecation. - Services that are mostly outbound HTTP: benchmark your own fetch() traffic first, because that was the one place Node 26 was clearly slower in my test. If you're also weighing runtimes rather than versions, I compared Bun vs Node on real workloads earlier. FAQ Is Node 26 LTS? Yes. Node 26 entered Active LTS on October 28, 2026 with v26.13.0, codename "Lithium". It stays Active LTS until October 20, 2027, then Maintenance LTS until April 30, 2029. When does Node 24 reach end of life? April 30, 2028. Node 24 has been in Maintenance LTS since October 20, 2026, which means security and critical fixes only. Is Node 26 faster than Node 24? In my benchmarks on one 8-vCPU VM, Node 26 was faster at JSON.stringify (37%), sequential await (40%) and as a node:http server (9% more requests per second). It was slower for the fetch() client (10% fewer requests per second) and for inserting 1 million keys into a Map (28% slower). Measure your own workload. What breaks when upgrading from Node 24 to Node 26? The common ones are native addons that need rebuilding (NODE_MODULE_VERSION 147), the removed res.writeHeader() alias, the removed _stream_* modules, the removed --experimental-transform-types flag, and a runtime deprecation warning for module.register(). Does Node 26 have Temporal? Yes. The Temporal global is enabled by default in Node 26. In the official Node 24.22.2 binary, typeof Temporal is 'undefined'. --- Originally published on fabwaseem.com.
The Node.js Brotli flaw: your server decompresses whatever APIs send back On October 21, Node.js fixed a flaw in its Brotli decoder, CVE-2026-111497. I went through the Node images in my projects. Two Next.js frontends are built on a tag that still ships Node 22.21.1. That tag will never get the fix. > TL;DR: a crafted Brotli stream can make Node write outside the memory it reserved. The process can crash, leak memory, or run an attacker's code. You are exposed if your server decompresses Brotli that comes from outside. Node's fetch, axios and Express 5 all do it by default. Move to Node 22.23.4, 24.22.1 or 26.12.1. Node 18, Node 20 and some Docker tags will never be fixed. Versions and source status: snapshot as of October 31, 2026. Check the latest fixes before deploying. This article is for Node.js developers and for teams that ship Node in containers. You do not need to know Brotli to follow it. Who decompresses Brotli without knowing it The flaw has one condition. Your application must decompress Brotli data supplied by someone else. So the real question is simple. Which code on your server decompresses Brotli? I read the source code of Node, undici, axios and body-parser. The table sums up what each one does. The fetch case is the quietest one. Node built it on undici, the HTTP library of the Node.js project. Over HTTPS, fetch asks the server for Brotli on its own, with an accept-encoding header that includes br. Then it decompresses the response without asking you. Changing that header does not protect you. In undici's code, the decoder depends on the response's Content-Encoding header. A hostile server can send you Brotli even if you did not ask for it. The risk comes from URLs you do not control. Think of a webhook entered by a customer, a link preview, an RSS feed or an image to download. A compromised third-party API is enough too. For incoming requests, Express 5 relies on body-parser 2. That version decompresses bodies sent with Content-Encoding: br. So any client can push a Brotli stream at you. Express 4 uses body-parser 1, which does not know Brotli. An image that only builds a frontend receives no traffic. It still needs the update, but it is not the priority. What Node.js fixed on October 21 On October 21, Node.js shipped three security releases: 22.23.4, 24.22.1 and 26.12.1. The advisory lists ten flaws. Four are rated "high". Only one can lead to code execution, the one in the Brotli decoder. A server that compresses with Brotli says so with the Content-Encoding: br header. The client must then decompress the response before reading it. That is the decoder's job. The decoder reads the stream piece by piece. When it hit an invalid section, it thought it was just missing a few bytes. It waited for more instead of rejecting the stream. Meanwhile, it kept storing the next bytes in a small eight-byte buffer. It did not stop at the edge. So it wrote into the memory next to it. This is called an out-of-bounds write. Node lists three possible effects. The process can crash. It can leak part of its memory. Or the attacker can run their own code inside your Node process. Node rates the flaw "high". The CVE record, written by Google, scores it 9.2 out of 10. The NVD, the US vulnerability database, scores it 9.8. The bug comes from Google's Brotli library. Google shipped the fixed versions the same day: 1.0.10, 1.1.1 and 1.2.1. But Node ships its own copy of Brotli. I checked with ldd in two official images. The node binary loads no Brotli library from the system. So updating the packages in your image changes nothing for Node. It is the same trap as Go's eMudhra fix, which I covered yesterday. Confirmed exploitation since October 27 On October 22, the CVE record still said "Exploitation: none". CISA added the flaw to its KEV catalog on October 27. That catalog records confirmed exploitation. The assessment changed to "active" the next day. On October 30, Singapore’s Cyber Security Agency also reported a public proof of concept. These sources describe successive states. They do not give an account of a specific attack here. This snapshot ends on October 31, 2026. My lab: 14 FROM node lines, three versions that will never be fixed I listed the FROM node: lines in every Dockerfile of my projects. I count 14 of them, in 11 files. For each tag, I read Docker Hub, then ran node --version in today's image. The tag that surprised me is node:22-alpine3.21. It looks like it follows Node 22. In fact, it ships Node 22.21.1, and it has not moved in a year. Alpine is a very small Linux distribution. The alpine3.21 suffix pins its version. The team behind the Node images dropped Alpine 3.21 on December 4, 2025. Today it only builds the Alpine 3.23 and 3.24 variants. Since then, that tag gets no new Node version at all. Nothing warns you. The build passes and the image starts. One of the two repositories does have a Dependabot that watches Docker images. In May, it proposed moving to node:25-alpine3.21. It kept the suffix, so it stayed inside the frozen family. Node 25 reached end of life three weeks later, on June 1. node:18-alpine and node:20-alpine are frozen for another reason. Node 18 has been end of life since April 30, 2025, Node 20 since April 30, 2026. Their copy of Brotli is version 1.1.0, inside the affected range. Node's advisory says it plainly. These official releases remain vulnerable because their release lines received no fix. One last detail, on my laptop. The cached node:24-alpine image dates from August 3. It holds Node 24.19.0, without the fix. A build that reuses that copy makes a vulnerable image, even with an up-to-date tag on Docker Hub. Then I searched my code for anything that decompresses Brotli. No service calls the Brotli functions of zlib. None uses Express. The two Next.js frontends make no server-side fetch in production. Their API calls leave from the browser. A single route makes a server-side fetch. It only turns on in local development, toward a storage service I control. So my real exposure is low. But a grep does not see what my dependencies do. And the next server-side fetch will arrive without anyone rereading the FROM line. The Brotli version number proves nothing Node shows the version of its libraries in process.versions. I compared two Node 22 images, one vulnerable and one fixed: docker run --rm node:22-alpine3.21 node -p "process.version + ' brotli ' + process.versions.brotli" # v22.21.1 brotli 1.1.0 docker run --rm node:22-alpine node -p "process.version + ' brotli ' + process.versions.brotli" # v22.23.4 brotli 1.1.0 Both show Brotli 1.1.0. Node applied Google's fix to its copy without changing that number. Node 24 is the same, with 1.2.0 before and after. Compare the Node version with 22.23.4, 24.22.1 or 26.12.1 instead. The fix also adds a constant to zlib.constants. In my tests, it only exists in the fixed versions. It makes a quick check in a running container: docker exec my-container node -p "process.version + ' ' + ('BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH' in require('zlib').constants)" # v22.23.4 true (fixed) # v22.21.1 false (vulnerable) What to change If you cannot deploy today, two settings reduce the risk. With Express 5, the inflate: false option rejects compressed bodies with a 415 error: app.use(express.json({ inflate: false })) // a body sent with Content-Encoding: br gets a 415 error With axios, the decompress: false option turns decompression off. Your code then gets the compressed bytes, and handling them is up to you. For fetch, I found no matching setting. Only a Node update protects it. The checklist > A Node fix only protects the container where it actually runs. - [ ] List every FROM node: line in your Dockerfiles - [ ] Spot end-of-life versions: Node 18, Node 20 and older - [ ] Spot tags tied to an Alpine version that is no longer built, such as alpine3.21 - [ ] Run node -p process.version in every production container - [ ] Do not trust process.versions.brotli - [ ] Look for server-side fetch and axios calls to URLs you do not control - [ ] Look for Express 5 and direct calls to the Brotli functions of zlib - [ ] Rebuild with --pull, then redeploy The takeaway The Brotli flaw does not need you to call Brotli yourself. fetch, axios and Express 5 do it for you, on data that comes from outside. And a Docker tag can look current while it ships a version that is a year old. Open your Dockerfiles today. Look for Node 18 and 20, and for tags that pin an Alpine version. Nobody will update them for you. Want a review of your images and your build pipeline? See my DevSecOps pipeline offer, or let's talk. --- Sources: Node.js, October 21, 2026 security releases · Node.js 22.23.4 release notes · CVE-2026-111497 record · NVD, CVE-2026-111497 · CISA KEV catalog · Singapore CSA, alert AL-2026-154 · Brotli 1.2.1 release notes · undici 6.29.1, fetch source · body-parser 2.4.0, body reading · axios HTTP adapter · Node.js zlib documentation · Node.js release schedule · docker-node, Alpine 3.21 removal · Docker Hub, node image · docker build documentation
Node.js 20 Is End of Life: How to Upgrade to Node 24 Node.js 20 stopped receiving security fixes on 30 April 2026. If a server, container image or CI job still runs it, every new Node vulnerability from now on is one you carry yourself. The October 2026 security releases, for example, patched the 22.x, 24.x and 26.x lines, and Node 20 was not among them. This guide shows what changes between Node 20 and Node 24, the handful of things that actually break, how to find them in your code before you switch, and how to roll the upgrade out. Which version should you move to? | Version | Status (end of October 2026) | Security support ends | | --- | --- | --- | | Node 20 | End of life | 30 April 2026 | | Node 22 | Maintenance LTS | 30 April 2027 | | Node 24 "Krypton" | Maintenance LTS since 20 October 2026 | 30 April 2028 | | Node 26 "Lithium" | LTS since 28 October 2026 | 30 April 2029 | Dates are from the Node.js release schedule (the nodejs/Release repository) and match endoflife.date. Node 24 is a solid target for an existing application: it has been an LTS line since October 2025, so tools and libraries have had time to add support. Node 26 has just entered LTS and gives you the longest support window, so it is a reasonable choice for new projects, as long as your dependencies (especially native ones) support it. This article is about 20 to 24, and it notes where Node 26 differs. What changes between Node 20 and Node 24 I installed Node 20.20.2, 24.22.2 and 26.13.0 side by side and read these values from process.versions: | | Node 20.20.2 | Node 24.22.2 | Node 26.13.0 | | --- | --- | --- | --- | | V8 | 11.3 | 13.6 | 14.6 | | OpenSSL | 3.0.19 | 3.5.9 | 3.5.9 | | Undici (fetch) | 6.24 | 7.30 | 8.12 | | Native module ABI (process.versions.modules) | 115 | 137 | 147 | The Node 24 release notes add npm 11 (Node 20 ships npm 10), and these new capabilities, which I confirmed are missing in Node 20 and present in 24: - URLPattern is a global (typeof URLPattern is undefined in 20 and function in 24). - Float16Array, RegExp.escape() and Error.isError() exist in 24 and not in 20. - The permission model flag is now --permission. - AsyncLocalStorage uses a more efficient implementation by default. One thing that is _not_ new: require() of an ES module already works on Node 20.20.2. I tested it, and it returned the module without a flag. If you were avoiding that on Node 20, check your own version; on current 20.x releases it works. What actually breaks Most applications need no code changes. The cases below are the ones I could reproduce, each tested on Node 20 and Node 24. | Code | Node 20 | Node 24 | Fix | | --- | --- | --- | --- | | tls.createSecurePair() | Function exists | Removed (undefined, calling it throws) | Use tls.TLSSocket | | dirent.path from readdir(..., {withFileTypes: true}) | String | Removed (undefined) | Use dirent.parentPath | | --experimental-permission flag | Works | "bad option", the process will not start | Use --permission | | url.parse() | Silent | Runtime deprecation warning DEP0169 | Use new URL() | | fs.F_OK, R_OK, W_OK, X_OK | Silent | Warning DEP0176 (still works) | Use fs.constants.F_OK etc. | | spawn() / execFile() with an args array and shell: true | Silent | Warning DEP0190 | Drop shell: true, or build the command string yourself | The first three can stop your app or script. The rest only print warnings today but are on the road to removal, and Node 26 shows how that ends: in Node 26.13.0, fs.F_OK is already undefined, so code reading it no longer works at all. The Node 24 release notes list other removals and deprecations, such as SlowBuffer, Zlib classes used without new, and 32-bit s390 and ppc support, but those are rare in application code. If you build Node from source, the release notes also raise the minimum macOS to 13.5 and require ClangCL on Windows. The fixes in code // url.parse() -> WHATWG URL const u = new URL(req.url, 'http://localhost'); u.hostname; u.port; u.pathname; u.searchParams.get('y'); // dirent.path -> dirent.parentPath const full = require('node:path').join(entry.parentPath, entry.name); // fs.F_OK -> fs.constants.F_OK fs.access(file, fs.constants.F_OK, cb); // spawn without shell: arguments are passed safely, no quoting needed require('node:child_process').spawnSync('echo', ['hi']); One difference to watch with url.parse(). It can return the query as an object when you pass true as the second argument, while new URL() gives you searchParams instead. Replace those lines by hand rather than with a search and replace. Find the problems before you switch 1. Search the code grep -rnE "url\.parse\(|\.createSecurePair|fs\.(F_OK|R_OK|W_OK|X_OK)|\{ *(F_OK|R_OK|W_OK|X_OK)|new Buffer\(|\bSlowBuffer\b|experimental-permission|\b(dirent|entry|ent|e)\.path\b|shell: *true" \ src scripts package.json Dockerfile .github --include=*.js --include=*.mjs --include=*.cjs --include=*.ts --include=package.json --include=Dockerfile --include=*.yml I ran this against a sample file and it matched each old usage and skipped the modern ones such as fs.constants.F_OK and parentPath. It will also report false positives, like a variable named e.path that has nothing to do with fs, so read each hit. Your dependencies can also trigger these warnings, which the next step covers. 2. Run your tests with deprecations turned into errors NODE_OPTIONS=--throw-deprecation npm test With --throw-deprecation, a deprecated API throws instead of warning, so the first one fails the run. I confirmed the process exits with code 1. To find where a warning comes from, whether in your code or in node_modules, use: node --trace-deprecation app.js This prints the stack for each deprecation warning. For stricter checks, --pending-deprecation turns on extra deprecation warnings, such as the one for new Buffer() (DEP0005), which I saw with that flag. 3. Rebuild native dependencies Packages with compiled addons, such as image, database or hashing libraries, are built for one ABI version. Node 20 is ABI 115 and Node 24 is 137, so the old binaries will not load. Start from a clean install under the new version: rm -rf node_modules npm ci npm rebuild # only needed if an addon did not install a prebuilt binary for Node 24 If an addon fails to build or load, check that package's release notes for Node 24 support and update it. Do the same for your build tools (Vite, webpack, test runners): each documents the Node versions it supports. Roll it out Local and server # nvm nvm install 24 nvm alias default 24 node -v # fnm fnm install 24 && fnm default 24 # pin it for the project echo "24" > .nvmrc On Debian or Ubuntu servers installed through NodeSource, switch the repository to the Node 24 setup script and reinstall the package. Restart every long-running process afterwards, because a running process keeps using the Node binary it started with. Check the version a running service really uses, not just the one on your shell's PATH. package.json { "engines": { "node": ">=24" } } With engines set, npm warns when someone runs the project on an older Node. Add engine-strict=true to .npmrc if you want it to refuse instead. Docker FROM node:24-slim WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev COPY . . CMD ["node", "server.js"] Change the tag in every Dockerfile, including build stages, and rebuild without cache so npm ci compiles native modules against the new Node. CI # GitHub Actions - uses: actions/setup-node@v4 with: node-version: 24 Use the current major version of the action. Update any test matrix that still lists 20, and keep 22 and 24 in the matrix while you migrate. A safe order of work 1. Run the grep and the --throw-deprecation test run on Node 20 itself to see what you will hit, then fix the code. All the fixes above work on Node 20 too, so you can ship them first. 2. Switch CI to Node 24 and get the test suite green. 3. Deploy to staging with the new Node and watch logs for deprecation warnings and native module errors. 4. Roll production forward one service or one instance group at a time, so you can roll back by redeploying the old image. Frequently asked questions Is Node.js 20 still safe to use? No. It reached end of life on 30 April 2026, so it no longer receives security fixes. Vulnerabilities found since then are not patched in the 20.x line. Should I upgrade to Node 24 or Node 26? Either is supported. Node 24 is the safer choice for an existing app because the ecosystem has had a year with it. Node 26 became LTS on 28 October 2026 and is supported until 30 April 2029, so consider it for new projects after checking that your native dependencies support it. Can I skip from Node 20 straight to 24? Yes. You do not have to install 22 first, but the removals and deprecations of both releases apply at once, which is why it helps to run the checks above on Node 20 first. Will my npm packages work on Node 24? Pure JavaScript packages usually do. Packages with native addons must be reinstalled or rebuilt for the new ABI, and some need a newer version. What happens to Node 24 after October 2026? It is in Maintenance LTS: it still gets critical fixes and security updates, but not new features, until 30 April 2028. Sources - Node.js Release Working Group: release schedule - endoflife.date: Node.js - Node.js 24.0.0 release notes - Node.js 26.13.0 "Lithium" reaches LTS --- _Originally published on DEV Talk._
Nginx reverse proxy avec Docker Compose : le tutoriel HTTPS pas à pas (2026) L'essentiel en une minute Un Nginx reverse proxy reçoit les requêtes des clients à la place de votre application, termine le chiffrement HTTPS, puis transmet chaque requête au bon service interne. Avec Docker Compose, c'est le seul conteneur qui publie des ports : votre API reste invisible depuis l'extérieur. Ce tutoriel place nginx devant l'API Node.js de notre guide pour créer une API REST avec Node.js et Express, qui écoute sur le port 3000 et expose une route /health, conteneurisée comme dans notre guide Docker pour débutants. Il repose sur la documentation de nginx.org, de Docker, de Certbot et de Let's Encrypt, consultée le 30 octobre 2026. Les fichiers suivent ces documentations, mais nous ne les avons pas exécutés : la méthode est détaillée en fin d'article. > Points clés > > - nginx 1.30.6 (stable) et 1.31.7 (mainline) ont corrigé cinq vulnérabilités le 14 octobre 2026. > - Quatre en-têtes sont à transmettre : Host, X-Real-IP, X-Forwarded-For et X-Forwarded-Proto. Côté Express, TRUST_PROXY=1 lit l'adresse du client un saut plus loin. > - Une location qui définit un seul proxy_set_header perd tous ceux du niveau supérieur : c'est le piège classique des WebSocket. > - Les certificats Let's Encrypt du profil par défaut, classic, passeront de 90 à 64 jours le 10 février 2027, puis à 45 jours le 16 février 2028 : le renouvellement doit être automatique. > - Les erreurs 502, 504 et 413 ont chacune une cause typique, visible dans les journaux. --- Reverse proxy, proxy direct, répartiteur de charge Un reverse proxy agit pour le compte des serveurs, alors qu'un proxy direct agit pour le compte des clients. MDN résume la différence ainsi : un proxy direct peut masquer l'identité des clients, un reverse proxy masque celle des serveurs, et sert à la répartition de charge, à l'authentification, au déchiffrement ou au cache (MDN, serveurs proxy et tunnels, consulté le 30 octobre 2026). Le répartiteur de charge est une fonction qu'un reverse proxy assure quand plusieurs instances tournent derrière lui : sans méthode précisée, nginx répartit à tour de rôle (round-robin), avec least_conn et ip_hash comme alternatives (nginx.org, répartition de charge). | | Proxy direct (forward) | Reverse proxy | Répartiteur de charge | |---|---|---|---| | Agit pour | Les clients | Les serveurs | Les serveurs | | Placé | Côté réseau du client | Devant l'application | Devant plusieurs instances | | Usage typique | Filtrage, cache sortant, anonymat | TLS, routage par domaine ou chemin, en-têtes | Répartir le trafic, tolérer une panne | | Exemple | Proxy d'entreprise | nginx devant une API | nginx avec un bloc upstream | Pour une seule API, le Nginx reverse proxy apporte un point d'entrée HTTPS unique, des en-têtes fiables et un endroit où régler délais et limites. --- Les versions à utiliser en octobre 2026 Le 14 octobre 2026, nginx.org a publié la 1.30.6 stable et la 1.31.7 mainline avec les mêmes cinq correctifs de sécurité : CVE-2026-15176 (alias combiné à rewrite), CVE-2026-93479 (expressions avec variables), CVE-2026-95101 (corps de requête en HTTP/3), CVE-2026-97629 (rotation des clés de tickets TLS) et CVE-2026-97205 (SSI avec AIO). La 1.31.8 du 21 octobre n'apporte ensuite que des changements et corrections de bugs (CHANGES-1.30 et CHANGES, consultés le 30 octobre 2026). | Composant | État au 30 octobre 2026 | Image Docker utilisée | |---|---|---| | nginx stable | 1.30.6 | nginx:1.30.6-alpine | | nginx mainline | 1.31.8 | nginx:1.31.8-alpine | | Certbot | 5.9.0, publiée le 28 octobre 2026 | certbot/certbot:v5.9.0 | | Node.js 26 | LTS depuis le 28 octobre 2026 (26.13.0) | Non utilisée ici | | Node.js 24 | LTS en maintenance depuis le 20 octobre 2026, jusqu'au 30 avril 2028 | node:24-alpine | Les étiquettes viennent de la page de l'image nginx sur Docker Hub, la version de Certbot de son journal des modifications, et les dates de Node.js du calendrier officiel des versions. Nous gardons node:24-alpine, l'image de nos deux tutoriels : la ligne 24 reste prise en charge et l'API exige Node.js 24 au minimum. --- Étape 1 : la configuration minimale d'un Nginx reverse proxy La directive centrale est proxy_pass, qui indique l'adresse du serveur vers lequel nginx transmet la requête. Voici le fichier nginx/conf.d/api.conf de départ, en HTTP seulement : server { listen 80; server_name api.example.com; # Servira au défi Let's Encrypt de l'étape 3 location /.well-known/acme-challenge/ { root /var/www/certbot; } location / { proxy_pass http://api:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } api est le nom du service dans Docker Compose, et 3000 le port interne de l'API. L'ordre des deux blocs location n'a pas d'importance : parmi les préfixes, nginx retient celui qui correspond le plus longuement à l'adresse demandée (module core HTTP). Les quatre en-têtes ne sont pas décoratifs. Par défaut, nginx remplace l'en-tête Host par $proxy_host, c'est-à-dire api:3000 ici, et l'application ne verrait jamais le vrai domaine (module proxy de nginx, consulté le 30 octobre 2026). | En-tête | Valeur envoyée | Ce que l'application en tire | |---|---|---| | Host | $host, le domaine demandé | Les liens absolus et le routage par domaine | | X-Real-IP | $remote_addr, l'adresse vue par nginx | L'adresse du client, en une seule valeur | | X-Forwarded-For | La valeur reçue, plus $remote_addr à la fin | La chaîne des adresses traversées | | X-Forwarded-Proto | $scheme, http ou https | Savoir si le client était en HTTPS | Le piège de la barre oblique finale Une barre oblique de trop dans proxy_pass change l'adresse transmise : si proxy_pass contient une URI, même réduite à /, la partie qui correspond au bloc location est remplacée par cette URI ; sans URI, la requête part telle que le client l'a envoyée. Avec location /api/ et proxy_pass http://api:3000/;, une requête vers /api/v1/taches arrive sous la forme /v1/taches. L'API du tutoriel attend /api/v1/... : pas de barre finale. Rester cohérent avec TRUST_PROXY Notre API Express ne lit ces en-têtes que si la variable TRUST_PROXY est réglée. La page Express derrière un proxy explique qu'avec un nombre, Express fait confiance à ce nombre de sauts, en lisant X-Forwarded-For de droite à gauche. Avec un seul nginx devant, TRUST_PROXY=1 désigne la dernière adresse de la liste, celle que nginx ajoute lui-même : un client ne peut pas la falsifier. La même page précise que req.protocol reflète X-Forwarded-Proto. Le cas d'un CDN devant nginx est traité plus bas. --- Étape 2 : la pile Docker Compose Trois prérequis : un serveur Linux avec Docker Engine et Compose, un enregistrement DNS A (et AAAA en IPv6) qui fait pointer api.example.com vers lui, et les ports 80 et 443 ouverts, le 80 servant au défi de Let's Encrypt. Seul nginx publie des ports, et c'est voulu. La page d'installation de Docker pour Ubuntu prévient que les ports publiés par des conteneurs contournent les règles des pare-feu ufw et firewalld (installer Docker Engine sur Ubuntu) : un port 3000 publié par erreur serait joignable malgré votre pare-feu. !Schéma de la pile Docker Compose : le navigateur se connecte au Nginx reverse proxy sur les ports 80 et 443, qui transmet en HTTP au service api sur le port 3000 interne, Certbot partageant les certificats par des dossiers montés Voici l'arborescence, placée par exemple dans /srv/pile ; le dossier api-taches est le projet du tutoriel Node.js, avec le Dockerfile et le .dockerignore de notre guide Docker : /srv/pile/ compose.yaml .env JWT_SECRET, jamais dans Git api-taches/ package.json, src/, Dockerfile, .dockerignore nginx/conf.d/api.conf certbot/www/ défis Let's Encrypt certbot/conf/ certificats Ce compose.yaml remplace celui du guide Docker, qui vivait dans api-taches et publiait 127.0.0.1:3000 : ici, l'API ne publie plus rien. Comme le dossier du projet change de nom (pile au lieu de api-taches), Compose crée un nouveau volume pile_api-data : la base SQLite du guide Docker n'est pas reprise. Rappel du Dockerfile multi-étapes de ce guide : # syntax=docker/dockerfile:1 FROM node:24-alpine AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --omit=dev FROM node:24-alpine ENV NODE_ENV=production ENV DB_PATH=/app/data/data.db WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY package.json ./ COPY src ./src RUN mkdir -p /app/data && chown node:node /app/data USER node EXPOSE 3000 CMD ["node", "src/server.js"] Puis /srv/pile/compose.yaml : services: api: build: ./api-taches environment: JWT_SECRET: ${JWT_SECRET:?definir JWT_SECRET dans .env} DB_PATH: /app/data/data.db TRUST_PROXY: "1" volumes: - api-data:/app/data init: true healthcheck: test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] interval: 30s timeout: 5s retries: 3 start_period: 10s restart: unless-stopped nginx: image: nginx:1.30.6-alpine ports: - "80:80" - "443:443" volumes: - ./nginx/conf.d:/etc/nginx/conf.d:ro - ./certbot/www:/var/www/certbot:ro - ./certbot/conf:/etc/letsencrypt:ro depends_on: api: condition: service_healthy restart: unless-stopped certbot: image: certbot/certbot:v5.9.0 profiles: ["outils"] volumes: - ./certbot/www:/var/www/certbot - ./certbot/conf:/etc/letsencrypt volumes: api-data: Le secret vient du fichier .env placé à côté de compose.yaml : Compose le lit dans le dossier du projet pour remplacer ${JWT_SECRET}, et ${VAR:?message} arrête tout si la variable est vide (interpolation des variables). Le .env du tutoriel Node.js, rangé dans api-taches, n'est pas lu. Seules les variables listées sous environment entrent dans le conteneur (référence des services). Générez le secret sans installer Node.js sur le serveur : cd /srv/pile echo "JWT_SECRET=$(docker run --rm node:24-alpine node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))")" > .env Les autres choix, tous tirés de la documentation de Docker : - Pas de clé ports sur l'API. C'est elle qui l'isole : Compose relie les services sur un réseau interne où chacun est joignable par son nom et par le port du conteneur (réseau dans Compose). - init: true. Un processus d'init transmet les signaux à Node.js, comme dans le guide Docker. - depends_on avec service_healthy. nginx attend que le test de santé, qui appelle /health, réussisse. fetch est stable depuis Node.js 21 (globales de Node.js 24). - restart: unless-stopped. La valeur par défaut, no, ne relance jamais un conteneur arrêté. - Le profil du service certbot. Il ne démarre pas avec docker compose up, mais docker compose run certbot le lance quand on le cible (profils). Monter ./nginx/conf.d masque le default.conf de l'image. Créez certbot/www et certbot/conf, puis lancez docker compose up -d --build et curl -i http://api.example.com/health, qui doit répondre {"statut":"ok"}. --- Étape 3 : obtenir et renouveler le certificat Let's Encrypt Le HTTPS pose un problème d'œuf et de poule : nginx refuse de démarrer si un bloc server cite un certificat absent, et Certbot a besoin de nginx pour prouver que vous contrôlez le domaine. C'est pourquoi le fichier de l'étape 1 sert déjà /.well-known/acme-challenge/ en HTTP : le défi HTTP-01 de Let's Encrypt ne passe que par le port 80, à cette adresse (types de défis, consulté le 30 octobre 2026). Lancez Certbot avec le greffon webroot, qui dépose un fichier temporaire dans le dossier partagé avec nginx (documentation de Certbot) : cd /srv/pile docker compose run --rm certbot certonly --webroot -w /var/www/certbot \ -d api.example.com --email vous@example.com --agree-tos --no-eff-email La page d'installation de Certbot prévient que l'image Docker ne sait pas configurer le serveur web : seule la commande certonly est utilisable. Le certificat arrive dans certbot/conf/live/api.example.com/, vu par nginx sous /etc/letsencrypt/live/api.example.com/, avec fullchain.pem et privkey.pem. L'étape 5 les branche. Automatiser le renouvellement Le renouvellement doit être automatique, car les certificats raccourcissent. La FAQ de Let's Encrypt indique des certificats de 90 jours par défaut. Son calendrier publié en décembre 2025 fait passer le profil par défaut, classic, à 64 jours le 10 février 2027, puis à 45 jours le 16 février 2028, et déconseille le renouvellement manuel. !Graphique en barres : durée des certificats Let's Encrypt du profil classic, 90 jours en octobre 2026, 64 jours au 10 février 2027 et 45 jours au 16 février 2028, avec le dernier tiers où Certbot renouvelle selon notre calcul Depuis sa version 4.0.0, certbot renew renouvelle quand il reste moins d'un tiers de la durée de vie ; depuis la 4.1.0, il interroge aussi l'ARI (ACME Renewal Information), qui peut avancer la date (journal des modifications de Certbot). Selon notre calcul, sans ARI, cela donne un renouvellement à 30 jours de l'échéance aujourd'hui, à environ 21 jours avec 64 jours de validité et à 15 jours avec 45. Une tâche planifiée quotidienne sur l'hôte suffit : # crontab de l'hôte, chaque jour à 3 h 17 17 3 * * * cd /srv/pile && docker compose run --rm certbot renew --quiet && docker compose exec -T nginx nginx -s reload Le rechargement est indispensable, nginx gardant l'ancien certificat en mémoire. cd /srv/pile && docker compose run --rm certbot renew --dry-run teste le tout sur le serveur de test de Let's Encrypt. Notre guide pour automatiser ses déploiements en CI/CD montre comment rejouer ces commandes depuis un pipeline. --- Étape 4 : WebSocket, en-têtes de sécurité, délais et tailles Cette étape explique chaque réglage et où il se place ; l'étape 5 les assemble dans un seul fichier. Les fichiers de conf.d sont inclus dans le bloc http du nginx.conf des paquets de nginx.org (pkg-oss) : une directive écrite hors de tout bloc server y est donc en contexte http. Les WebSocket Une connexion WebSocket commence par une requête HTTP qui demande un changement de protocole. La page WebSocket de nginx.org explique que Upgrade et Connection sont des en-têtes « de saut à saut » (notre traduction), donc non transmis : il faut les poser avec un map, autorisé seulement en contexte http (module map), et deux proxy_set_header. Le piège est l'héritage : selon le module proxy, les proxy_set_header sont hérités du niveau supérieur si et seulement si le niveau courant n'en définit aucun. Une location /ws/ qui ajoute Upgrade perd donc Host et les X-Forwarded-* : il faut les répéter. Depuis nginx 1.29.7, proxy_http_version vaut 1.1 par défaut, ce qu'exigent les WebSocket : inutile de l'écrire avec la 1.30.6. nginx coupe après 60 secondes sans données : relevez proxy_read_timeout sur cette location, ou envoyez des trames ping. L'API du tutoriel n'a pas de WebSocket : ce bloc sert si vous en ajoutez. En-têtes de sécurité Ne doublez pas ce que l'application envoie déjà. Notre API utilise Helmet, qui pose par défaut Strict-Transport-Security: max-age=31536000; includeSubDomains, X-Content-Type-Options: nosniff et X-Frame-Options: SAMEORIGIN, entre autres (documentation de Helmet, consultée le 30 octobre 2026). Deux réglages suffisent côté nginx, avec un piège à connaître : - server_tokens off (contexte http) retire le numéro de version des pages d'erreur et de l'en-tête Server. - proxy_hide_header Strict-Transport-Security puis add_header ... always (bloc server du port 443) : nginx envoie un seul en-tête HSTS, y compris sur ses propres pages 502 ou 504. Sans always, add_header ne s'applique qu'à certains codes, dont 200, 301 et 302 (module headers). - L'héritage, encore. Un add_header placé dans une location annule ceux du server. Depuis nginx 1.29.3, add_header_inherit merge; cumule les deux niveaux, selon le même module headers. Délais et taille des requêtes Ces directives acceptent les contextes http, server et location ; nous les plaçons dans le server du port 443 : - client_max_body_size vaut 1 Mo par défaut, au-delà duquel nginx renvoie l'erreur 413. L'API refuse déjà tout JSON de plus de 10 ko : une limite de 16 ko côté nginx écarte les gros corps avant qu'ils n'atteignent Node.js. Relevez-la si vous ajoutez un envoi de fichiers. - proxy_connect_timeout vaut 60 secondes par défaut et ne peut généralement pas dépasser 75 secondes. Sur un réseau Docker local, 5 secondes suffisent pour échouer vite. - proxy_read_timeout vaut 60 secondes et mesure le silence entre deux lectures, pas la durée totale de la réponse. Pour les menaces que ces réglages contrent, voyez notre point sur les cyberattaques les plus fréquentes. --- Étape 5 : le fichier api.conf final Voici nginx/conf.d/api.conf complet, qui remplace celui de l'étape 1. Les trois premières directives sont en contexte http ; tout le reste vit dans les deux blocs server. # Contexte http (fichier inclus par nginx.conf) map $http_upgrade $connection_upgrade { default upgrade; '' close; } log_format proxy '$remote_addr [$time_local] "$request" $status ' 'upstream=$upstream_status rt=$upstream_response_time ' 'ct=$upstream_connect_time'; server_tokens off; # Port 80 : défi Let's Encrypt, puis redirection vers HTTPS server { listen 80; server_name api.example.com; location /.well-known/acme-challenge/ { root /var/www/certbot; } location / { return 301 https://$host$request_uri; } } # Port 443 : le reverse proxy server { listen 443 ssl; http2 on; server_name api.example.com; ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem; access_log /var/log/nginx/access.log proxy; client_max_body_size 16k; proxy_connect_timeout 5s; proxy_read_timeout 60s; proxy_hide_header Strict-Transport-Security; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; # Hérités par toute location qui ne définit aucun proxy_set_header proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; location / { proxy_pass http://api:3000; } location /ws/ { proxy_pass http://api:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 1h; } } Nous avons relu le contexte autorisé de chaque directive sur nginx.org : map et log_format n'acceptent que http, return accepte server et location, ssl_certificate et http2 acceptent http et server. Le paramètre http2 de listen est déprécié au profit de http2 on;, apparu dans nginx 1.25.1 (module HTTP/2), et ssl_protocols vaut déjà TLSv1.2 TLSv1.3 par défaut (module SSL). Vérifiez, rechargez, puis testez : docker compose exec nginx nginx -t && docker compose exec nginx nginx -s reload curl -I http://api.example.com/health # attendu : 301 vers https curl -i https://api.example.com/health # attendu : 200 et {"statut":"ok"} Si nginx -t signale une erreur, le rechargement n'a pas lieu et l'ancienne configuration continue de tourner : selon le guide du débutant de nginx, le processus maître revient à l'ancienne configuration quand la nouvelle échoue. --- Nginx Proxy Manager, Caddy ou Traefik ? Le Nginx reverse proxy écrit à la main n'est pas la seule voie. Trois alternatives reviennent dans les recherches, avec des compromis différents. | Outil | Configuration | Certificats | Point d'attention | |---|---|---|---| | nginx écrit à la main | Fichiers texte, versionnés dans Git | Certbot, à brancher soi-même | Le plus de contrôle, le plus de travail | | Nginx Proxy Manager | Interface web sur le port 81 | Let's Encrypt intégré | Configuration stockée en base, moins facile à relire dans Git | | Caddy | Fichier Caddyfile court | Obtention et renouvellement automatiques par défaut | Une syntaxe de plus à apprendre | | Traefik | Étiquettes sur les conteneurs | Résolveur ACME, suivi des expirations automatique | Accès à l'API Docker à protéger | Le guide de Nginx Proxy Manager utilise l'image jc21/nginx-proxy-manager:2.16.0. Caddy s'appuie sur Let's Encrypt ou ZeroSSL et redirige vers HTTPS sans réglage. Traefik avertit qu'un accès sans restriction à l'API Docker pose un problème de sécurité. Notre lecture : pour un projet ou deux, Caddy ou Nginx Proxy Manager font gagner du temps. Dès que la configuration doit être relue, versionnée et testée en CI, le fichier nginx explicite reste le plus prévisible. --- Erreurs courantes et lecture des journaux Chaque code d'erreur renvoyé par nginx pointe vers une cause précise, que les journaux nomment presque toujours. L'image officielle relie access.log à la sortie standard et error.log à la sortie d'erreur (dépôt docker-nginx) : docker compose logs --tail=50 nginx affiche les deux. | Symptôme | Cause la plus fréquente | Correctif | |---|---|---| | 502 Bad Gateway | L'API est arrêtée, écoute sur un autre port, ou son conteneur a changé d'adresse | docker compose ps, vérifier le port 3000, recharger nginx | | 504 Gateway Timeout | L'API ne répond pas dans le délai de proxy_read_timeout (60 s par défaut) | Accélérer la route, ou relever le délai sur cette seule location | | 413 Request Entity Too Large | Corps de requête au-dessus de client_max_body_size (16 ko dans notre fichier) | Relever la limite dans le server ou la location concernée | | Boucle de redirections derrière Cloudflare | Mode SSL Flexible, et redirection HTTP vers HTTPS sur l'origine | Passer en mode Full (strict) | | Toutes les IP clientes identiques | En-têtes absents, ou CDN devant nginx sans module realip | Vérifier les quatre en-têtes, configurer set_real_ip_from | nginx résout le nom api au chargement de sa configuration : si le conteneur est recréé avec une nouvelle adresse, un nginx -s reload règle le 502. Pour s'en passer, nginx accepte depuis la 1.27.3 le paramètre resolve dans un bloc upstream doté d'une zone, avec une directive resolver (module upstream) ; le DNS intégré de Docker répond à l'adresse 127.0.0.11 (réseau Docker). Derrière Cloudflare La boucle de redirections est documentée par Cloudflare : en mode Flexible, Cloudflare contacte l'origine en HTTP, l'origine redirige vers HTTPS, et le cycle recommence. Le correctif conseillé est le mode Full ou Full (strict), qui exige un certificat sur l'origine, celui de Certbot convient (Cloudflare, trop de redirections, mis à jour le 1er septembre 2026). Pour retrouver l'adresse réelle des visiteurs, Cloudflare recommande le module realip de nginx, avec ses plages d'adresses publiées sur cloudflare.com (Cloudflare, adresses des visiteurs). Ces lignes vont dans le server du port 443 : # Une ligne par plage publiée par Cloudflare, liste à tenir à jour set_real_ip_from 192.0.2.0/24; real_ip_header CF-Connecting-IP; Le module n'est pas compilé par défaut, mais les paquets de nginx.org, base de l'image officielle, l'incluent. $remote_addr devient l'adresse du visiteur, nginx l'ajoute en fin de X-Forwarded-For, et TRUST_PROXY=1 reste juste côté Express. Lire les journaux utilement Le format proxy du fichier final ajoute au journal d'accès le statut et les temps de l'API, grâce aux variables du module upstream. Un 502 accompagné, dans le journal d'erreurs, d'un échec de connexion vers api:3000 signale que nginx n'a pas joint l'API. Un rt= proche de 60 secondes avant un 504 désigne une route trop lente. Pour filtrer ces lignes, nos commandes Linux de base couvrent grep et tail. Pour héberger la pile, voyez notre guide de l'hébergement cloud pour développeurs. --- Ce que nous n'avons pas pu vérifier Ce tutoriel est une synthèse de documentation, pas un retour d'expérience. Les points suivants restent à confirmer de votre côté : - L'exécution des fichiers. Ni Docker ni nginx n'étaient disponibles sur notre poste : nginx -t, docker compose up et Certbot n'ont pas été lancés. - Les droits du volume SQLite. La documentation de Docker dit qu'un volume vide reçoit le contenu du dossier de l'image ; la page lue ne précise pas que le propriétaire node de /app/data est conservé. - Le défi HTTP-01 derrière Cloudflare en mode proxy. Nous n'avons pas trouvé de page de Cloudflare qui le décrive pour ce cas précis. En cas d'échec, désactivez le proxy le temps de l'émission ou passez au défi DNS-01. - Les plages d'adresses de Cloudflare. Elles changent : 192.0.2.0/24 est une adresse de documentation, à remplacer par la liste officielle. --- Questions fréquentes Qu'est-ce qu'un Nginx reverse proxy ? C'est un serveur nginx placé devant une ou plusieurs applications : il reçoit les requêtes des clients, termine HTTPS et les transmet au bon service avec proxy_pass. Selon MDN, un reverse proxy masque l'identité des serveurs, là où un proxy direct masque celle des clients. Faut-il nginx stable ou mainline en 2026 ? Les deux reçoivent les correctifs de sécurité : le 14 octobre 2026, nginx.org a publié la 1.30.6 stable et la 1.31.7 mainline avec les mêmes corrections. La branche stable change moins souvent, d'où notre choix de l'étiquette nginx:1.30.6-alpine. Comment corriger une erreur 413 avec nginx ? Relevez client_max_body_size, qui vaut 1 Mo par défaut selon la documentation de nginx, dans le bloc server ou dans la seule location qui reçoit des fichiers. Vérifiez ensuite la limite de l'application, qui peut être plus basse. Les certificats Let's Encrypt sont-ils toujours valables 90 jours ? Oui pour l'instant avec le profil par défaut, classic. Le calendrier de Let's Encrypt prévoit 64 jours à partir du 10 février 2027 et 45 jours à partir du 16 février 2028. Un certbot renew planifié chaque jour absorbe ce changement sans modification. --- À propos de l'auteur Abdelilah Ourti est ingénieur IA et architecte logiciel. Il conçoit et déploie des applications web et des systèmes d'IA, et publie sur NeurauTech des guides et des comparatifs destinés aux développeurs francophones. Ses autres articles sont réunis sur sa page auteur. Méthode. Tutoriel fondé sur des sources publiques consultées le 30 octobre 2026 : nginx.org, Docker, Certbot, Node.js, Let's Encrypt, Cloudflare, Express, Helmet, Nginx Proxy Manager, Caddy et Traefik. Le Dockerfile reprend celui de notre guide Docker. Aucune commande n'a été exécutée, Docker n'étant pas installé sur le poste de rédaction : la syntaxe nginx n'a pas été validée par nginx -t, seulement relue directive par directive contre la documentation de nginx.org. Les citations en anglais sont traduites par nos soins. Aucun lien n'est affilié et aucune relation commerciale ne lie l'auteur aux éditeurs cités. Une erreur ou une version périmée ? Écrivez à contact@neurautech.com : l'article sera corrigé et la date de mise à jour modifiée. Dernière mise à jour : 30 octobre 2026. Image : Scott Rodgerson (Unsplash License).
Source: the 26.13.0 (LTS) release notes from @nodejs.org, plus the Release WG schedule. nodejs.org nodejs/Release
Node.js 26 resmi LTS (patch sampai April 2029). Node 22 berhenti 30 April 2027. Yang baru: Temporal tanpa flag, Map.getOrInsert(). Mulai Node 27 rilis besar setahun sekali, semua jadi LTS. nodejs/Release
Node v26 is LTS now according to the release schedule nodejs/release
nodejs/release Node.js 26がLTSになった。 これでSeaとかが普通に使える
Dependabot の通知を、32リポジトリぶん追えなくなった。AIと作った「依存の見回り TL;DR 個人で32リポジトリを持っている。棚卸ししたら、15リポジトリに high 以上の Dependabot アラートが合計345件あった(アラートは脆弱性ごと・ファイルごとに1件なので、パッケージの数よりずっと多い)。Dependabot は入っていた。通知も来ていた。追いきれていなかった 足りなかったのは通知ではなく、「全リポジトリを横断した一覧」と「どれから直すかの順番」だった clone せずに API だけで全リポジトリを週1回見回り、P1〜P3 の順位をつけて、P1 があるときだけ知らせる仕組みにした。件数を数える部分は32リポジトリで約50秒 AI にコードを書かせるときは、版を AI の記憶から書かせない。入れる直前に最新を確かめる そして、この記事を書いている途中で、自分がその罠を踏んだ。記憶の経験則(「Node の奇数の版は短命」)で決めた設定が、もう古かった Dependabot のメジャー更新の PR より小さい直し方があったこと、件数を数えるスクリプト、優先度の決め方、踏んだ落とし穴5つを置いておく。 プレビューが落ちた。原因は tailwind 4 だった 10月24日、Vercel から「プレビューのビルドに失敗」と通知が来た。半年以上触っていない個人アプリのリポジトリだ。 コードは何も変えていない。来ていたのは、Dependabot の PR だった。tailwindcss を 3 から 4 に上げる PR で、そのプレビューが落ちていた。 原因はすぐわかった。postcss.config.js が tailwindcss: {} を直接読んでいる。v4 では PostCSS のプラグインが @tailwindcss/postcss という別のパッケージになった(Tailwind CSS の移行ガイド)。 わからなかったのは、なぜメジャー更新の PR が来たのかだ。版の更新の設定(dependabot.yml)は入れていない。このリポジトリで動いているのは、脆弱性を直すための「セキュリティアップデート」だけのはずだった。 調べたら、こういうことだった。 postcss-selector-parser に脆弱性が公表された(GHSA-rj75-hqrm-r3gf、2026年10月5日、深刻度は moderate)。アドバイザリの記録では、対象は < 7.1.6、修正版は 7.1.6 tailwind 3 は postcss-selector-parser の 6 系(^6.1.2)を指定している だから Dependabot は「tailwind ごと 4 に上げる」PR を出した セキュリティアップデートでも、メジャー更新の PR は来る。 中の部品を直すために、外側のパッケージが丸ごと上がることがある。マージする前に気づけたのは、プレビューが落ちてくれたからだ。 しかも、続きがあった。postcss-selector-parser のリリースを見ると、10月19日に 6.1.5 が出ていて、同じ脆弱性の修正が 6 系にもバックポートされていた。6.1.5 は tailwind 3 の指定範囲に入る。つまり、tailwind を 3 のまま直せた。アドバイザリの記録がまだ 6.1.5 を修正版として載せていないので、Dependabot は大きいほうの直し方を出してきた、ということだ。 Dependabot が出した直し方が、いちばん小さい直し方とは限らない。 メジャー更新の PR が来たら、上げる前に、その部品のリリースノートでバックポートが出ていないかを見る。 ここで嫌な予感がした。半年触っていないリポジトリでこれなら、ほかはどうなっているのか。 棚卸ししたら、345件あった Claude Code に、自分の GitHub の全リポジトリを棚卸ししてもらった(数字は2026年10月24日時点)。アーカイブとフォークを除いて32個。 見たもの 結果 high 以上の Dependabot アラートがあるリポジトリ 15個(合計345件) 開きっぱなしの Dependabot の PR 53本 サポートが終わったランタイムに上げようとしている PR 4本 台帳に載っていない(存在を忘れていた)リポジトリ 5個 345件は「直すべきパッケージの数」ではない。Dependabot のアラートは、脆弱性ごと・ファイルごとに1件になる。1つのパッケージだけで、アラートが30件以上あったこともある。それでも、多いことには変わりない。 Dependabot は入っていた。アラートのメールも来ていた。でも、リポジトリごとにバラバラに届くので、「どれが本番で、どれが危ないか」を毎回頭の中で組み立てないといけない。32個になると、それはもうやらない。 通知が来ていることと、見ていることは違う。 最後の行も地味に効いた。台帳から漏れていたリポジトリは、見回りの対象からも漏れる。その中には、本番で動いているアプリも含まれていた。 clone しないで、全リポジトリを見回る やりたいことはシンプルにした。 全リポジトリについて「使っているパッケージ」「脆弱性」「ランタイム」「止まっている PR」を1つのファイルにまとめる 優先度をつけて、上から順に直せるようにする 週1回、自動で回して、P1 があるときだけ通知する 読む先は API だけ(clone しない) 最初は全リポジトリを clone して npm audit を回していた。32個 clone して、npm ci して……は重い。 GitHub には、clone しなくても読める情報がそろっている。 知りたいこと 読む先 脆弱性(深刻度・直接か間接か・実行時か開発用か・直る版) Dependabot alerts の REST API 実際に入っている版(lockfile から GitHub が作ったもの) SBOM の REST API 止まっている PR gh pr list --app dependabot 最新版 npm / PyPI の公開 API ランタイム Dockerfile の FROM と .nvmrc(contents API で読む) GitHub 以外に読むのは、最新版(npm / PyPI)とランタイムのサポート予定(endoflife.date)の公開 API だけだ。 まず、自分にどれだけ溜まっているかを見る 件数を数えるだけなら、これで足りる。 deps-alerts.sh #!/bin/bash 使い方: deps-alerts.sh <owner> リポジトリ(アーカイブ・フォークを除く)ごとに、 開いている Dependabot アラートの critical / high の件数と、Dependabot の PR の本数を出す。clone はしない 読めなかったリポジトリ(権限がない・Dependabot alerts が無効など)は 0 ではなく「?」と出す 要るもの: gh(gh auth login 済み)・jq。確認した環境: bash 3.2 / gh 2.103 / jq 1.7 set -uo pipefail owner="${1:?owner を渡す}" printf '%-36s %8s %5s %4s\n' repo critical high PR for repo in $(gh repo list "$owner" --limit 200 --no-archived --source --json name --jq '.[].name'); do if sev=$(gh api --paginate "repos/$owner/$repo/dependabot/alerts?state=open&per_page=100" \ --jq '.[].security_advisory.severity' 2>/dev/null); then crit=$(grep -c '^critical$' <<< "$sev"); high=$(grep -c '^high$' <<< "$sev") else crit='?'; high='?' fi prs=$(gh pr list -R "$owner/$repo" --app dependabot --state open --json number --jq length 2>/dev/null || echo '?') printf '%-36s %8s %5s %4s\n' "$repo" "$crit" "$high" "$prs" done | sort -k2,2nr -k3,3nr 動かし方: chmod +x deps-alerts.sh ./deps-alerts.sh <自分のユーザー名か org 名> 出力はこうなる(名前は伏せた)。critical が多い順に並ぶ。自分の32リポジトリでは、約50秒かかった(2026-10-24〜25 に計測)。 repo critical high PR repo-a 7 41 3 repo-b 3 20 5 repo-c 0 1 1 repo-d 0 0 0 注意が3つある。 「?」は「読めなかった」で、0件ではない。権限が足りない(管理者でない org など)か、Dependabot alerts が無効になっている。リポジトリの Settings → Advanced Security(Code security)で Dependabot alerts を有効にする 最初のバージョンは、読めなかったリポジトリを 2>/dev/null で捨てていて、0件と表示していた。セキュリティの道具で「読めなかった」を「きれい」に見せるのが、いちばん危ない 修正をマージしても、アラートが閉じるのは GitHub が数え直してから。直後に数えると、件数は変わっていないことがある 優先度は「本番で、実行時に使うか」で決める 件数だけ並べても、345件は直せない。順番が要る。 優先度 当てはまるもの いつまでに P1 critical の脆弱性/本番のあるリポジトリで、実行時に使うパッケージの high/本番のあるリポジトリで、サポートが終わったランタイム 見つけた週のうちに PR P2 開発用だけ・本番のないリポジトリの high/本番のあるリポジトリで、LTS でない Node 次にそのリポジトリを触るとき P3 moderate/メジャー版の遅れ/サポート終了まで90日以内のランタイム/14日以上止まっている Dependabot の PR 月1回まとめて 「実行時か開発用か」は、アラートの dependency.scope に runtime / development で入っている。値が入っていない(null)こともあるので、そのときは実行時として扱う(甘いほうに倒さない)。 「本番があるか」は API ではわからない。自分のリポジトリの台帳(どれが本番で、どこにデプロイしているか)を Markdown の表で持っていて、そこから読んでいる。台帳が見回りの対象を決めるので、台帳に載っていないリポジトリも一覧の上に出すようにした。 | repo | 役割 | 本番 | |------|------|------| | my-app | 家計のアプリ | Cloud Run | | my-tool | 手元の道具 | — | 週1回、P1 があるときだけ知らせる 自分の環境では、毎週日曜の朝に Mac mini の定期実行(LaunchAgent)で回し、結果を Markdown に書き出している。cron でも GitHub Actions の schedule でも同じことができる。 通知(自分は ntfy でスマホへ)は P1 があるときだけ。P2・P3 だけの週は送らない。毎週「P3 が40件あります」と来たら、3週目には見なくなる。それでは Dependabot のメールと同じだ。 通知にはこう出す。 依存の更新: P1 が 6 リポジトリ (リポジトリ名):(パッケージ名)(直接)の脆弱性 critical ×N → X.Y.Z 以上に上げる(全部直る版) ... 一覧: ~/.local/state/.../inventory.md 「何が・何件・どうすればいいか」まで書く。自分の場合、最後は Claude Code に「依存の P1 を直して」と頼むだけになっている。 作るときに、古いものを入れない 見回りは「入ってしまった古いもの」を見つける仕組みだ。もう一つ、入口も閉じた。 AI にアプリを作らせると、AI が覚えている版で package.json を書くことがある。学習した時点では最新でも、書いている今は古い。古いリポジトリの package.json を参考にさせても同じことが起きる。 新しいリポジトリを作るときの雛形に、こう書いた。 版は記憶や古いリポジトリから写さない。入れる直前に最新の安定版を確かめる(npm view <名前> version、公式ドキュメント) 雛形(create-next-app など)は @latest で作る Node は LTS の版を使う。どれが LTS かは、記憶で決めず Node.js のリリース予定で確かめる lockfile をコミットし、npm ci で入れる .github/dependabot.yml を最初から入れる 最初の PR で npm audit --audit-level=high が 0 件 Node の行は、最初はこう書いていた。「偶数の版を使う。奇数の版は短命なので使わない」。これが間違いだった話は、落とし穴の最後に書く。 全部は直さない、と先に決めた 345件を直すにあたって、ルールを決めた。 セキュリティ修正(P1・P2)が先。マイナー・パッチの更新はまとめて1本。メジャー更新は1パッケージ1本で、公式の移行ガイドを読んでから 各 PR で install → lint → typecheck → test → build を全部通す。Vercel などのプレビューも通す 見た目が変わっていないことを機械で確かめる。main とブランチのビルドを並べて、主要画面を 1280px と 320px で撮り、ピクセル単位で比べる 置き換えた Dependabot の PR は、新しい PR にリンクしてから閉じる 半年以上触っていないリポジトリは、脆弱性の修正だけ。ふだんのメジャー更新はしない。ただし、脆弱性を直すのにメジャー更新が要るなら、それは上げる。上げられないなら公開を止める ビルドが通らない更新は無理に直さない。原因と選択肢を書いて止まる 3 は思ったより効いた。依存を上げると、JS のファイル名(ハッシュ)は全部変わる。差分を目で見ても「変わった」としか言えない。スクショのピクセルが一致すれば、少なくともその画面は変わっていないと言える。 ひとつ引っかかったのは、アニメーションのあるページだ。そのままだと main 同士で撮っても一致しない。アニメーションを止めてから撮り、「止めた状態なら main 同士は一致する」ことを先に確かめてから比べた。 5 は、全部を最新にしようとすると終わらないからだ。半年触っていないアプリを React 19 と tailwind 4 と vite 8 に上げる作業は、ほぼ作り直しになる。公開しているなら脆弱性だけは直す。それ以上は、そのアプリをまた触るときにやる。 この線引きで、Claude Code に8リポジトリぶんの PR を並行で作ってもらい、自分が見てマージした。 踏んだ落とし穴5つ 1. 「直る版」は、いちばん新しい修正版を見る あるライブラリについて、最初の棚卸しで「次のマイナー版に上げれば直る(メジャー更新はいらない)」と判断した。Dependabot は次のメジャー版への PR を出していたので、「メジャーを避けられる」と思った。 間違いだった。 アラートを1件ずつ見ると、同じライブラリに、修正版の違うアラートが混ざっていた。 アラート 直る版 一部 次のマイナー版 残りの大半 次のメジャー版 マイナー版で直るのは一部だけ。同じパッケージに複数のアラートがあるとき、全部直るのは「各アラートの直る版のうち、いちばん新しいもの」。見回りの一覧では「○○ 以上に上げる(全部直る版)」と、この値を出すようにした。 2. dependabot.yml の ignore は、セキュリティ修正の PR にも効く 「当面メジャー更新しないもの」を Dependabot に伝えるのは ignore だ。ただ、GitHub の設定リファレンスでは、ignore はセキュリティアップデートの PR にも影響する設定として印がついている。 雑にパッケージを丸ごと ignore すると、そのパッケージに脆弱性が出たときの修正 PR まで止まる。だから範囲を絞る。 version: 2 updates: - package-ecosystem: docker directory: / schedule: interval: weekly Node 25 までの奇数の版(23・25)は LTS にならないので上げない 出典: nodejs/Release%EF%BC%882026-10-25 確認) 見直す日: 2027-10-20 ignore: - dependency-name: node versions: [">= 23, < 24", ">= 25, < 26"] それでも、脆弱性のアラートは ignore と関係なく届く。止まるのは PR だけだ。見回りはアラートを読むので、ignore したパッケージに脆弱性が出ても P1 には上がってくる。 ignore には必ず「理由」「出典」「見直す日」を書く。書かないと、半年後に誰も外さない。 3. ほかの部品が「その版ちょうど」を指定していることがある あるリポジトリで、テスト用の部品(miniflare)が alpha 版に固定されていて、その中の部品(undici・sharp)に high の脆弱性があった。npm audit の提案は「miniflare を安定版の 4 系にする」。alpha の 5 系から見ると、版を戻すことになる。 調べると、Cloudflare の wrangler(使っていた 4.135.0)が、その alpha 版をちょうど指定していた。miniflare を動かすと wrangler と食い違う。 こういうときは外側を動かさず、package.json の overrides で中の部品だけを直る版に寄せた。 "overrides": { "undici": "7.30.1", "sharp": "0.35.5" } undici は同じ 7 系のマイナー、sharp は 0.35 系のパッチなので、ビルドとテストが全部通ることを確かめて入れた。overrides は便利だが、外し忘れると今度はそれが古くなる。PR に理由を書いておく。 4. 使っていない依存は、上げるより消す 半年触っていないアプリに、@vercel/node が開発用の依存として入っていた。中の部品に high があり、直すにはメジャー更新が要る。 コードを探したら、どこからも使われていなかった。api/ フォルダも vercel.json もない。上げるのをやめて、消した。 いちばん安全な依存は、入っていない依存だ。 5. 経験則も古くなる:Node 27 は奇数でも LTS になる Dependabot が、Docker のベースイメージを node:22-alpine から node:25-alpine に上げる PR を4本出していた。Node 25 は奇数の版で、LTS にならず、2026年6月1日にサポートが終わっている。上げる理由はない。 ここまではよかった。そこで自分(と Claude Code)は、「Node の奇数の版は短命」という覚え方のまま、23・25・27 を ignore する設定を4リポジトリに入れ、規約にも「奇数の版は使わない」と書いた。 翌日、この記事の事実確認を別の AI にやらせたら、指摘が来た。Node.js のリリース予定では、v27 からすべての版が Alpha → Current → LTS になる。27 が LTS になる予定日は 2027年10月20日。奇数が LTS にならないのは 25 までの話で、27 を ignore すると、将来の LTS への更新を止めてしまうところだった。 同じ日に、Python 3.10 のサポート終了日も1か月ずれていたのが見つかった(正しくは 2026年10月1日)。 AI が古くなるのは、版の数字だけではない。「いつもこう」という方針の覚え方も古くなる。 直したのは3つだ。 ランタイムのサポート予定を手書きの表で持つのをやめ、見回りのたびに endoflife.date の API から取る ignore には「理由・出典・確認日・見直す日」を必須にし、抜けや期限切れを一覧に出す 版や ignore の方針を決める PR は、実装した AI とは別の AI に一次ソースと照合させてからマージする 最後のものが、今回いちばん効いた。自分で決めたことを自分で確かめても、同じ記憶で確かめるだけだった。 結果 8リポジトリに、脆弱性の修正の PR を出してマージした。npm audit の high 以上は、この8リポジトリで計97件 → 4件。残った4件は、直すのにメジャー更新が要るもの(冒頭の345件は Dependabot アラートの件数で、数え方が違うので並べて比べない) Node 25 への PR は4本とも閉じ、ignore を「23・25 だけ」に直して入れた 台帳から漏れていた5リポジトリを台帳に戻し、見回りの対象にした 本番で動いていて、直すのにメジャー更新が要る critical が1つ残っていた。Next.js 14 の画像最適化 API で、認証なしでコードを実行される脆弱性だ(14 系には修正版がない)。「手間が大きい」と後回しにしていたのを、この記事の公開前チェック(攻撃者の目で読ませる係)に指摘された。Next.js 15 に上げてデプロイしてから、この記事を出している ほかのメジャー更新が要るものは、1パッケージ1本で、移行ガイドを読みながら進める 全部が片付いたわけではない。ただ、どこに何が残っているかを、毎週わかる仕組みにした。 自分の現場に持ち帰るなら 上のスクリプトで、自分のリポジトリにどれだけ溜まっているかを見る。「?」が出たら、まずそこを読めるようにする 件数の多い順ではなく、「本番で、実行時に使うもの」から直す 版・サポート期間・ignore は、記憶ではなく一次ソースで決める。決めた PR は、別の目(人でも AI でも)に照合させる Dependabot は便利だ。でも、Dependabot が教えてくれるのは「このリポジトリに何があるか」までで、「32個の中でどれから直すか」は教えてくれない。そこを決めるのは、自分の台帳だった。 nekoai-lab.github.io
【要対応】Node.jsの脆弱性10件が修正。Claude Code・MCP利用者が今すぐ確認すべきこと TL;DR 2026年10月21日、Node.jsの深刻な脆弱性を含む10件が修正された。 修正版は v22.23.4 / v24.22.1 / v26.12.1。該当する古いNodeを使っているなら更新が必要 Nodeを更新しただけでは終わらない。 自分のMacでは、修正前のNodeで動くプロセスが19個残っていた Claude Code本体、MCPサーバー、Cursor CLI、公開アプリでは確認・更新する場所が異なる 本番のDockerイメージも、再ビルド・再デプロイしない限り古いまま残る この記事では、今すぐ確認する手順、実際に対応した結果、同じことを繰り返さないための仕組みをまとめた Node を上げた。19個、直っていなかった 10月23日、仕事のチャットに「Node.js が危ないのでアップデート必須。Claude Code を使っている人はガッツリ関係する。会社単位じゃなく個人単位で」と連絡が回ってきた。 自分も最初はこう思った。 「Claude Code って Node で動いてるんだっけ?」 「本体は勝手に更新されるんでしょ?」 「GitHub も見るの? GCP は? Vercel は?」 調べてみたら、答えはどれも「半分合ってる」だった。 自分の Mac を見たら、Node は修正前の 26.10.0 のまま。brew upgrade node で 26.12.1 に上げた。これで終わり、と思った。 念のためプロセスを数えた。 修正前の Node で動き続けているものが、19個あった。 Astro の dev サーバーが10個。Next.js が4個。Claude Code が裏で起動した MCP サーバーが5個。いちばん古いものは1週間前から動いていた。しかも最初に打ったコマンドでは、そのうち3個が見えていなかった。 極めつけは、この調べものに使っていた Claude Code 自身だ。スマホから使うために Mac mini で常駐させていた Remote Control の親プロセスが、1週間前の 2.1.292 のまま動いていた。claude update で新しい版を入れても、そこから起動するセッションは古いままだった(後述)。 アップデートは、インストールした時点では終わらない。この記事は、その話だ。 自分はどれ? 3つの質問で判定する 質問 はい → いいえ → ① Claude Code・Cursor・Codex などの AI ツールを使っている? A に該当。次の質問へ 次の質問へ ② ターミナルで node -v を打つと、バージョンが出る? B に該当。次の質問へ 次の質問へ(ただしアプリ同梱の Node は残る。後述) ③ Node で作ったアプリやサーバーを、ネットに公開している? C に該当 — そのうえで、node -v や本番の Node が v18・v20・v23・v25 なら D も当てはまる。修正版が出ない。 区分 誰 やること 所要 A AI ツールを使っている人 Claude Code・Cursor CLI・デスクトップアプリを最新に 5分 B PC に Node が入っている人(MCP サーバー、npx、npm で入れた CLI、dev サーバー) Node を更新して、動いているものを再起動 10〜20分 C Node のアプリを公開している人 本番の実行環境を更新して再デプロイ 環境しだい D Node 20 以前などを使っている人 22 / 24 系に乗り換え 半日〜 Claude Code 本体だけ使っている人は、まず A。たぶんこの層がいちばん多い。 ただ、Claude Code を入れるときに「Node も入れてね」と言われて Homebrew で入れた人は多いはず。その場合は B も当てはまる。node -v を一回打ってみてほしい。 結局、どこを見ればいいのか 見る場所は3層ある。 層 何を見る 見落としやすいもの ① PC(持っている台数ぶん) Claude Code の版、node -v、動いている Node、アプリ同梱の Node 普段使わないPC、起動しっぱなしの dev サーバー ② リポジトリ Dockerfile の FROM node:..、.nvmrc / .node-version、package.json の engines、GitHub Actions の node-version FROM node:20 のまま、node-version: 24.14.0 みたいな細かい固定 ③ 本番 実際に動いているランタイムの版 「Dockerfile は直したけど、まだデプロイしてない」 ②はリポジトリの置き場所でこう打てば一覧が出る。 grep -rnE "FROM node|node-version|\"node\":" \ --include=Dockerfile* --include=.yml --include=.yaml \ --include=package.json --include=.nvmrc --include=.node-version \ --exclude-dir=node_modules . 自分のリポジトリ(10本ちょっと)でやったら、こうなった。 本番が Cloud Run(自前の Dockerfile)で FROM node:20-alpine のもの:1本。修正が出ない系列。最優先 本番が Cloud Run で FROM node:22-alpine のもの:3本。イメージを作り直して再デプロイ CI で node-version: 24.14.0 に固定しているもの:1本。本番は Cloudflare Workers なので Node では動いていない。CI だけ上げればいい Python のサービス、静的サイト(Firebase Hosting):本番は関係なし CI(GitHub Actions)は、外から来たデータを展開する場面が少ないので優先度は低い。でも node-version の固定は、ついでに上げておく。 そもそも何が直ったのか Node.js の公式発表では、10件のうち4件が深刻度「高」だった。 CVE ざっくり言うと 危ない場面 CVE-2026-111497 Brotli(圧縮形式の一つ)を展開するとき、メモリの範囲外に書き込める 外から来た Brotli データを Node が展開するとき CVE-2026-106069 0127.0.0.1 みたいな変な書き方の IP アドレスで、TLS 証明書のチェックがずれる 接続先のホスト名を外部入力で決めていて、かつ通信経路を握られているとき CVE-2026-105483 全角数字の IP アドレスで、TLS 証明書のチェックがずれる(24 / 26 系だけ) 同上 CVE-2026-105482 dns.resolve が名前の途中の NUL 文字(\0)で切れる 「許可リストで名前をチェックしてから名前解決する」作りのとき 残り6件は中・低。HTTP の keep-alive、crypto.randomUUID()、権限モデルまわりなど。 修正版は v22.23.4 / v24.22.1 / v26.12.1。回避策は出ていない。上げるしかない。 Brotli のバグは、理屈の上では任意のコード実行までつながる種類のものだ。Node の発表でも「リモートからのコード実行を許す」と書かれている。ただ、10月23日時点で GitHub に公開されている PoC と解析を見ると、5本のうち4本は「リモートからのコード実行は成立していない。実際にはプロセスを落とせる(DoS)」という結論だった(条件を固めたローカル環境でなら実行できた、という報告はある)。落とせるだけでも、サーバーなら十分困る。 どんな場合に危ないのか ここがいちばん混乱しているところだと思う。 TLS と DNS の3件は、条件がかなり狭い。「接続先を外部入力で決めるサーバーを作っている」「経路上に攻撃者がいる」くらいの前提が要る。普通に使っているだけなら、ほぼ当たらない。 問題は Brotli の1件だ。こっちは入口が広い。 当たる:Node が外部のサイトやAPIを fetch() する Node の fetch() は、HTTPS のとき「Brotli で圧縮して送ってくれていいよ」とサーバーに伝える。サーバーが Brotli で返してきたら、中身を自動で展開する。 つまり、相手のサーバーが悪意を持っていたり、乗っ取られていたりしたら、fetch() しただけで細工したデータが届く。 当てはまるもの: URL を取りに行く MCP サーバー(Web取得、ブラウザ操作、スクレイピング系) 外部 API を叩くスクリプトやバッチ Claude Code に作らせた「URLを入れると要約するツール」みたいなもの 当たる:外からのリクエスト本文を展開するサーバー Express 5(body-parser 2.x)は、express.json() などのパーサーを使っていると、Content-Encoding: br のリクエスト本文を既定で展開する。 Express で API を公開しているなら、C に該当する。 ちなみに公開されている PoC(攻撃の実証コード)の一部も Express を使っている。ただし受け取った本文を自前の処理で Brotli 展開する作りで、body-parser の既定の動作を狙ったものではない。 当たる:受け取ったファイルを zlib で展開する アップロードされた .br ファイルを展開する、みたいな処理。これも外から来たデータの展開だ。 ほぼ当たらない:自分のPCの中だけで完結するもの localhost でしか見ていない dev サーバーが、外部と通信していないなら、細工したデータが届く経路がない。 ただし「dev サーバーの中で外部 API を fetch() している」なら当たる。Next.js のサーバー側で外部APIを呼んでいる、とか。けっこうある。 当たらない:Node を入れているだけ インストールされているだけで、何も動いていなければ攻撃は届かない。ただ、MCP サーバーは Claude Code を開くたびに裏で起動する。「動かしていないつもり」が一番あぶない。 Claude Code 本体の話:Node じゃなくて Bun 本体は Node を使っていない Claude Code の公式ドキュメントには、「インストールされた claude は Node を呼び出さない」とある。npm で入れた場合も、中身は同じネイティブバイナリだ。 だから「Node を更新すれば Claude Code 本体が直る」は、正しくない。 中の Bun にも、同じ Brotli のコードが入っていた Claude Code のネイティブ版は Bun で作られている。 その Bun にも、同じ Brotli ライブラリが入っていた。Bun の issue #46352 では「全リリースが影響を受ける」とされ、10月22日に修正がマージされた(日本時間11時ごろ)。修正を含む Bun v1.4.4 のリリースは同日19時ごろだ。 じゃあ Claude Code 本体で攻撃が届くのか。ここは分からない。Claude Code の中で、外から来た Brotli データを Bun が展開する場面があるのか。Anthropic は何も公表していない。 2.1.308 に上げた。直ったかは分からない 自分の Mac に入っていたのは 2.1.306。公開は10月21日(日本時間12時ごろ)で、Bun の修正より前だ。 claude update を打ったら 2.1.308 になった。 $ claude update Current version: 2.1.306 Updating to 2.1.308... Successfully updated from 2.1.306 to version 2.1.308 2.1.308 の公開は10月22日の13時半ごろ。Bun の修正マージよりは後、Bun v1.4.4 のリリースよりは前。リリースノートは「Bug fixes and reliability improvements」の1行だけだった。 なので言えるのは「最新にしておくのが、いまできる最善」まで。直ったとは言えない。 「本体は勝手に更新される」は本当か 入れ方による。公式ドキュメントを読むと、こうなっている。 入れ方 自動で更新される? 手で上げるコマンド 公式インストーラー(curl ... install.sh) される claude update npm される(書き込み権限があれば) npm install -g @anthropic-ai/claude-code@latest Homebrew(brew install --cask claude-code) 既定ではされない brew upgrade claude-code WinGet 既定ではされない winget upgrade Anthropic.ClaudeCode apt / dnf / apk されない OS のアップデートで Homebrew と WinGet は、CLAUDE_CODE_PACKAGE_MANAGER_AUTO_UPDATE=1 を設定すると Claude Code が代わりに更新してくれる。 自分の Mac は公式インストーラー版で、9/26・10/7・10/16・10/22 と更新されていた。 それでも 2.1.306 で止まっていた。自動更新は「起動したときと、動いている間にときどき」見に行って、次に起動したときに切り替わる仕組みだからだ。急ぐなら claude update を手で打つ。 ちなみに配布には latest と stable(ふだんは約1週間遅れ)の2つのチャンネルがある。今回は10月22日の時点で、stable も 2.1.308 になっていた。 逆に Node は、誰も勝手に更新してくれない。Homebrew で入れたなら、brew upgrade を打つまで永遠に古いままだ。 手順 まず一発で確認する PC ごとに、これをターミナルに貼る。普段使わない PC も忘れずに。 Mac / Linux: echo "== Claude Code"; claude --version 2>/dev/null || echo "入っていない" brew list --cask 2>/dev/null | grep '^claude-code' && echo "↑ Homebrew 版。既定では自動更新されない" echo "== Node"; which -a node 2>/dev/null; node -v 2>/dev/null || echo "入っていない" echo "== Cursor CLI"; cursor-agent --version 2>/dev/null || echo "入っていない" echo "== 動いている Node"; ps -axo pid,lstart,command | grep -E '[n]ode |[n]ext-server' | cut -c1-150 Windows(PowerShell): "== Claude Code"; claude --version "== Node"; where.exe node; node -v "== 動いている Node"; Get-Process node -ErrorAction SilentlyContinue | Select-Object Id, StartTime, Path 最後の「動いている Node」の読み方: 起動時刻が Node をアップデートする前 → 古い Node で動いている。再起動する パスが /Applications/〜.app/ の中 → アプリが同梱している Node。アプリの更新を待つ パスが Homebrew の Caskroom の中 → Homebrew で入れたツール(Cursor CLI など)が同梱している Node。brew upgrade --cask <名前> で上がることがある コマンドの引数に node という文字を含むだけの別のプロセスも出てくる。パスを見て判断する next-server → Next.js の dev サーバー。名前に node が付かないので、grep node だけだと見逃す。自分はこれで3個見逃した 「入っていない」と出ても、それは PATH の上に無いというだけだ。アプリ同梱の Node、Docker、本番は別に見る(下の「Node を同梱しているアプリ」と C)。 A:AI ツール本体 claude update claude --version # 2.1.308 以降を推奨(修正が入ったかは未公表) Homebrew で入れた人は brew upgrade claude-code(latest 版なら claude-code@latest)、WinGet なら winget upgrade Anthropic.ClaudeCode。 開いている Claude Code は、一度閉じて開き直す。動いているプロセスは古い版のままだからだ。 Remote Control(claude remote-control)を常駐させている人は要注意だ。自分の Mac mini では、スマホから使うために claude remote-control を tmux の中で常駐させていた。これが10月16日に起動したまま、当時の 2.1.292 で動き続けていた。スマホから新しく作ったセッションも、全部この 2.1.292 で起動していた。claude update で 2.1.308 を入れても、常駐している親が古いままなら子も古い。 常駐している親ごと止めて、起動し直す。自分の環境(tmux セッション名 claude、LaunchAgent で起動)ならこうだ。動いているリモートのセッションは全部切れるので、作業が終わってから。 tmux kill-session -t '=claude' launchctl kickstart gui/$(id -u)/com.nekoai.remote-control # 自分で作った LaunchAgent の名前 ps -axo lstart,command | grep '[c]laude/versions/' # 新しい版で動いているか確認 Cursor を使っているなら CLI も。中に自前の Node を抱えている。 cursor-agent update # 公式インストーラーで入れた場合 brew upgrade --cask cursor-cli # Homebrew で入れた場合 自分の Mac では、cursor-agent update で 2026.10.22 版になって、中の Node が v26.8.1 から v26.12.1 に上がった。 デスクトップアプリも見ておく。10月23日、もう1台の Mac で Claude のデスクトップアプリを開いたら「Claude has an important security update」と出た。Mac mini のほうは、気づいたときには自動で入っていた。ChatGPT のデスクトップアプリにもアップデートが来ていた。中身が今回の件かは、どちらも公表されていない。Claude アプリの更新版(3.6556.1)の中の Node は v24.22.0 で、Node の修正版ではなかった。アプリのログを見ると、アプリに内蔵されている Claude Code が 2.1.304 から 2.1.308 に上がっていた。アプリの「新着情報」(10月22日付)には Node や Brotli の記載はなく、作業フォルダの権限や SSH まわりの変更・修正が並んでいた。「important」はそっちの話なのだと思う。それでも「important」と出ているなら入れる。 Mac の Claude アプリなら、画面上のメニューバーの「Claude」→「アップデートを確認…」→「再起動してアップデート」。アプリの中のアカウントメニューには無いので注意。 B:PC の Node まず、どこから入れた Node かを確認する。 which -a node ls -l "$(which node)" node -v ls -l の結果 入れ方 更新コマンド .../Cellar/node/... を指している Homebrew brew update && brew upgrade node ~/.nvm/... nvm 下を参照 fnm を含むパス fnm fnm install 24 && fnm default 24 ~/.volta/... Volta volta install node@24 mise を含むパス mise mise use -g node@24 /usr/local/bin/node で Cellar を指していない 公式インストーラー nodejs.org から最新版を入れ直す /usr/local/bin/node は、Intel Mac の Homebrew でもここに入る。ls -l で Cellar を指していたら Homebrew だ。Homebrew で node@22 や node@24 を入れているなら、brew upgrade node@24 のように指定する。 nvm は、入れるだけだと足りない。 nvm install 24 --reinstall-packages-from=current # npm -g で入れたものも引き継ぐ nvm alias default 24 nvm use 24 # 今のターミナルも切り替える プロジェクトの .nvmrc や、MCP の設定ファイルに ~/.nvm/versions/node/v22.x/... のような絶対パスを書いていたら、そっちも直す。古い版は nvm uninstall <古い版> で消せる。fnm・Volta・mise も、固定や絶対パスの事情は同じだ。 Windows はこう。 where.exe node の結果 入れ方 更新 C:\Program Files\nodejs\node.exe 公式インストーラー or winget winget list OpenJS で ID を確認して winget upgrade <ID>。無ければ nodejs.org から入れ直す ...\nvm... や nodejs へのリンク nvm-windows nvm install 24.22.1 → nvm use 24.22.1 .volta / fnm を含むパス Volta / fnm Mac と同じ winget の ID は、LTS 版なら OpenJS.NodeJS.LTS、最新版なら OpenJS.NodeJS。入れている方を上げる。 上げたら確認。 node -v v26.12.1 / v24.22.1 / v22.23.4 以降ならOK(v23・v25 は修正が出ないので D へ) ここからが本番。「まず一発で確認する」の最後の行をもう一度打つ。アップデートより前に起動したものは、全部古い Node で動いている。 Claude Code(ターミナル)→ /exit して起動し直す。裏の MCP サーバーも新しい Node で起動し直す Claude デスクトップアプリ・VS Code・Cursor → これらも MCP サーバーを起動している。ウィンドウを閉じるだけでなく、⌘Q(Windows はタスクトレイから終了)で完全に終了してから開き直す dev サーバー → Ctrl+C で止めて、もう一度起動 最後に、もう一度プロセスを確認する。自分で入れた Node のプロセスに、アップデートより前の起動時刻が残っていなければ、B は完了だ(アプリ同梱のものは残っていい)。 Node を同梱しているアプリ 自分で入れた Node とは別に、アプリが自前の Node を抱えていることがある。自分の Mac で、10月23日に見た版はこうだった。 アプリ 中の Node の版 Cursor の CLI(cursor-agent) v26.8.1 → 更新後 v26.12.1 Antigravity(Electron) v24.20.0 Grok Bot(SpaceXAI と Cursor が出しているアプリ。署名は Cursor の会社の Anysphere。Electron) v24.19.0 Claude デスクトップアプリ(Electron、更新版 3.6556.1) v24.22.0 ChatGPT デスクトップアプリ(中の補助ツール) v24.21.0(アプリ更新後も同じ) 版が修正前でも、そのアプリで攻撃が届くとは限らない。外から来た Brotli データをその Node で展開する場面があるかは、確認できていない。 Electron(VS Code、Cursor、Antigravity、Slack などの土台)は、10月23日時点の最新リリース(v45.0.0、10月21日)でも中の Node は 24.22.0 で、修正版ではなかった。 Cursor CLI 以外の同梱 Node は、こっちでは直せない。アップデートが来たら、すぐ入れる。それだけ。 Codex CLI は本体が Rust 製で、今回は関係ない。npm で入れた場合も、入口の小さなスクリプトが Node で動くだけだ。 C:公開しているアプリ 本番がどこで動いているかで変わる。 実行環境 自動で直る? やること Vercel 管理ランタイムに順次展開中。WAF ルールも入った Node 20 を使っているなら手動で移行。WAF は外向きの fetch() のレスポンスまでは守れない AWS Lambda 既定(Auto モード)なら自動で段階適用 Manual / Function update モードやコンテナイメージなら、再デプロイ・再ビルド Cloud Run(関数として、または --base-image 指定でソースからデプロイ) 自動ベースイメージ更新が有効なら反映される 設定を確認。今回の版の反映告知はまだ見つからなかった Cloud Run(自分の Dockerfile) 直らない 下の Docker と同じ。作り直して再デプロイ Firebase Functions 第2世代は Cloud Run の関数と同じ仕組みで、自動更新が既定で有効のはず コンソールでランタイムを確認し、心配なら再デプロイ Cloudflare Workers / Firebase Hosting(静的) 本番は Node で動いていない ビルドに使う Node だけ上げる Docker 公式イメージ node:22 / 24 / 26 は更新済み イメージを作り直して再デプロイ。node:20 は更新されない GitHub Actions setup-node の配布元には登録済み check-latest: true にするか、バージョンを固定で書く。ランナーのキャッシュに古い版が残っていることがある 自前サーバー 直らない B と同じ手順で更新して、プロセスを再起動 Docker は見落としやすい。FROM node:24 と書いてあっても、ビルドし直さないと古いイメージのままだ。 D:Node 20 以前を使っている Node 20 は2026年4月30日にサポートが終わっている。18・23・25 も終わっている。今回の修正は出ないし、今後も出ない。 Homebrew の node@20 は、10月28日に使えなくなる予定だ。 22 系か 24 系(どちらも長期サポート。26 系も10月28日に長期サポートに入る予定)に移す。破壊的変更があるので、テストを回しながら進める。今日すぐ終わる作業ではないので、それまでは「外部から来たデータを展開する処理」を止められないか考えておく。 確認コマンドと完了の判断は、教材にもまとめた。 nekoai-lab.github.io 時系列で見る 日時(日本時間) 出来事 2026年2月27日 Google の brotli リポジトリに、今回の修正のもとになるコミットが入る 10月14日 Node.js が「21日以降にセキュリティリリースを出す」と予告 10月21日 12時ごろ Claude Code 2.1.306 公開 10月21日 19時38分 Node.js v22.23.4 / v24.22.1 / v26.12.1 公開 10月22日 0時47分 最初の PoC リポジトリが GitHub に作られる 10月22日 4時27分 Google brotli が v1.2.1 / v1.1.1 / v1.0.10 を公開 10月22日 11時ごろ Bun が Brotli の修正をマージ 10月22日 13時半ごろ Claude Code 2.1.308 公開 10月22日 午後 Homebrew の node / node@24 / node@22 の修正版が配布される 10月22日 19時ごろ Bun v1.4.4 リリース 10月22日 23時ごろ CISA の評価は「悪用は確認されていない」 いくつか補足。 Brotli のバグは Node 固有じゃない。Google の brotli ライブラリ(1.0.3〜1.2.0)のバグで、CVE の採番も Google だ。CVSS 4.0 では 9.2(Critical)と付いていて、Node 側の評価は High。Brotli を使っている他のソフトも、同梱しているバージョンしだいで影響を受ける。 Node の発表では、Node への報告者として OpenAI の Codex Security チームの名前が載っている。上流の修正は2月に Google の開発者が入れていた。誰が最初に見つけたのかは、どの一次ソースにも書かれていない。 修正のもとになるコードは2月に brotli のリポジトリに入っていた。ただ、そのときはセキュリティ修正として出ていない。セキュリティ修正として世に出たのは10月21日の Node が最初で、brotli 自体のリリースはその翌日だった。 やった結果(実録) 対応は10月23日の朝の数時間。Mac 2台と本番4本。 対象 前 後 Mac mini の Node(Homebrew) v26.10.0 v26.12.1 Mac mini の Claude Code 2.1.306 2.1.308 Mac mini の Cursor CLI 同梱の Node v26.8.1 v26.12.1 Mac mini の Claude デスクトップアプリ 3.1623.4 3.6556.1(自動で更新。内蔵の Claude Code は 2.1.308) Mac mini の起動しっぱなしの Node 19個(最初のコマンドでは16個しか見えなかった) dev サーバー14個を止めた(親のプロセスも含めて15個を kill)。Claude アプリの MCP 1個はアプリの再起動で消えた。残りの MCP 4個は Remote Control を起動し直して消す MacBook Pro の Claude Code 2.1.283(9月26日の版) 2.1.308 MacBook Pro の Node v24.14.0(公式 pkg) Homebrew の v26.12.1 に切り替え MacBook Pro の起動しっぱなしの Node(全部 MCP サーバー) 11個(うち5個は9月19日から) 0個 本番(Cloud Run 4本) node:20 1本、node:22 3本 全部 22.23.4 で作り直してデプロイ。版チェック入り ChatGPT・Antigravity・Grok Bot の同梱 Node 修正前 アプリの更新待ち MacBook Pro はひどかった。 Claude Code は1か月前の版。Node で動いていた MCP サーバー11個のうち5個は、1か月以上前の9月19日から動き続けていた。その中には、ブラウザでページを取りに行く playwright-mcp もいた。今回のバグにいちばん当たりやすい種類だ。 Mac を閉じずにスリープで使い続けていると、こうなる。自動更新は「次に起動したとき」にしか効かない。 再発防止:「入れた」と「動いている」の差を仕組みで埋める 今回の穴は、どれも同じ形をしていた。 Node は上げた。でも動いているプロセスは古いまま Claude Code は上げた。でも常駐している親は古いまま Dockerfile は合っている。でも本番のイメージは作り直していない 自動更新はある。でも閉じないから効かない 人が気をつけて防ぐのは無理だと思った。なので仕組みにした。 1. Node の入れ方を全部の Mac で揃えた Mac mini は Homebrew、MacBook Pro は nodejs.org の公式インストーラーだった。入れ方が違うと、更新の手順も違う。だから後回しになる。 MacBook Pro を Homebrew に切り替えた。npm ls -g --depth=0 で、公式インストーラー側に自分で入れたツールが無いことを確かめてから。 以下は Apple Silicon の Mac(Homebrew が /opt/homebrew)で、公式インストーラーから乗り換える場合だ。Intel Mac は Homebrew 自体が /usr/local に入るので、この rm を打つと Homebrew の Node まで消える。先に brew --prefix が /opt/homebrew と出ること、ls -l /usr/local/bin/node が Cellar を指していないことを確かめる。 brew install node 公式インストーラーの Node を消す(npm -g で入れたものがあれば、先に控えておく) sudo rm -rf /usr/local/bin/node /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack \ /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack \ /usr/local/include/node /usr/local/share/doc/node /usr/local/share/man/man1/node.1 ターミナルを開き直して which -a node # /opt/homebrew/bin/node だけになれば OK これで、どの Mac も brew upgrade node の1行で済む。 2. 本番の Dockerfile に「古い Node なら作らない」を入れた FROM node:22-alpine と書いていても、作り直さなければ古いまま。逆に、キャッシュや取り違えで古いイメージが混ざることもある。 なので、本番のイメージを作るときに Node の版を確かめて、修正前なら止めるようにした。実行ステージの FROM の直後に1行足すだけだ。 FROM node:22-alpine AS runner Node.js 2026-10-21 のセキュリティ修正より前の版では作らない RUN node -e "const m={22:[23,4],24:[22,1],26:[12,1]};const [a,b,c]=process.versions.node.split('.').map(Number);const min=m[a];const ok=a>26||(min&&(b>min[0]||(b===min[0]&&c>=min[1])));if(!ok){console.error('Node '+process.version+' は修正より前か、サポート外');process.exit(1)}" 22.23.4・24.22.1・26.12.1 より前と、20 系などの修正が出ない系列は、ここでビルドが止まる。4本とも入れて、作り直し、デプロイまで通した。 このチェックが効くのは、作り直すときだけだ。作り直していない本番が古いままなのは、これでは分からない。 次は Dependabot に FROM node:.. を見張らせて、新しいイメージが出たら PR が自動で立つようにする(これから)。 3. 週1回、まとめて上げて「古いまま動いているもの」を探す 2つのスクリプトを作った。 weekly-update:Homebrew で入れたもの全部(Node 以外も)、Claude Code、Cursor CLI を上げる。macOS のアップデートが来ていれば知らせる(入れはしない。再起動が要るから) stale-procs:更新したのに、古い版のまま動いているプロセスを探す stale-procs の考え方は単純だ。プロセスの実行ファイルを lsof で調べて、いまの node の実体と違えば古い。Homebrew のものなら、Cellar/<名前>/<版> が opt/<名前> の指している版と違えば古い。Node や Python に限らず、Homebrew で入れたもの全部に使える。 Node だけなら、これで同じことが分かる。 cur=$(realpath "$(command -v node)") for pid in $(pgrep -f 'node|next-server'); do exe=$(lsof -a -p "$pid" -d txt -Fn 2>/dev/null | sed -n 's/^n//p' | head -1) case "$exe" in /Cellar/node/bin/node|/usr/local/bin/node|/.nvm//bin/node) [ "$(realpath "$exe" 2>/dev/null || echo "$exe")" != "$cur" ] && echo "$pid 古い Node: $exe" ;; esac done next-server のように名前を変えているプロセスも、実行ファイルで見るので拾える。アプリ同梱の Node は、自分では直せないので数えない。 ただし、これで分かるのは Homebrew と nvm で入れた Node だけだ。公式インストーラーの Node は同じ場所に上書きされるので、この比べ方では新旧を見分けられない。fnm・Volta・mise も、この短い版では対象にしていない。その場合は、プロセスの起動時刻が Node を上げた時刻より前かどうかで判断する。 結果はスマホに通知する。ただ、普段から通知は短すぎて読めていなかった。「更新されたな」と分かるだけで、何をすればいいかは分からない。なので、通知だけで次の行動が決まる形にした。 古い版のまま動いているもの 9 件 ・Remote Control・アプリから起動したセッション 4件(claude 2.1.292) → 親(Remote Control)を作り直すか、そのセッションを閉じる ・MCP サーバー 4件(mcp-pdf-server/node 26.10.0_1) → 起動したアプリやセッションを開き直す ・Remote Control の親 1件(claude 2.1.292) → tmux のセッションを作り直す 4. 通知に「プロセスの引数」を出してはいけなかった このスクリプトは Claude Code で書いて、レビューは別の AI(Codex)にさせている。そのレビューで2回止められた。 1回目は、通知にプロセスのコマンドをそのまま載せていた。node app.js --password xxx のような起動のしかたをしていたら、パスワードがログとスマホの通知に流れる。 2回目は、それを直したつもりで「引数のうち、ハイフンで始まらない最初のもの」をスクリプト名として出していた。node -e 'key="..."' のように中身を直接渡すと、そのコードが名前として出る。 最後は「実在するファイルのときだけファイル名を出す。それ以外は node」にして通った。 プロセスの一覧を通知やログに出すスクリプトは、引数を出した時点で秘密が漏れる道になる。自分のレビューだけだったら、2回とも素通りしていたと思う。 5. 片付けをどこまで自動にするか(これから) 通知が来ても、止めるのが人の手作業なら、結局また溜まる。かといって、全部を自動で止めると作業中の dev サーバーやセッションが切れる。なので3段階に分けた。 段階 対象 誰が 全自動 起動した元がもういなくて取り残された古いプロセス。使われていないときの Remote Control の親 週1回の自動実行。止めたものは通知で報告 ひと言で 開いているセッションの MCP、dev サーバー 通知を見て、AI に「古いの片付けて」と頼む。一覧を見せて確認してから止める 人が決める デスクトップアプリ、macOS のアップデート 自分。通知に「何をすればいいか」が書いてある MacBook Pro の11個(うち5個は9月19日から)は、たぶん「全自動」で消える種類だった。これを作れば、今回の後片付けの大半は、寝ている間に終わる。 この部分は、設計まで書いて Codex に引き継いだ。 32リポジトリぶんの依存を、そのあとどう見回るようにしたかは、続きに書いた。 zenn.dev まとめ AI ツールを使っている人:claude update、Cursor CLI、デスクトップアプリを最新に。開き直す Node が入っている人:Node を上げて、動いているものを全部再起動。アプリ同梱の Node はアプリの更新で 公開している人:本番の実行環境を確認して、再デプロイ。Docker は作り直す Node 20 以前の人:今回は直らない。乗り換える 全員:「入れた」と「動いている」の差は、気合いでは埋まらない。週1回の更新と、古いまま動いているものの検出を仕組みにしておく アップデートは、インストールした時点では終わらない。 古いプロセスを止めた時点で、やっと終わる。 参考 Node.js — October 2026 Security Releases google/brotli v1.2.1 CVE-2026-111497(CVE.org API) oven-sh/bun #46352 — Brotli の脆弱性 Bun v1.4.4 ikow/brotli2crash — PoC と解析(実質 DoS とする例) hypnguyen1209/CVE-2026-111497 — Express を使った PoC Claude Code — セットアップ(ドキュメント) anthropics/claude-code — Releases expressjs/body-parser — read.js Electron v45.0.0 SpaceXAI and Cursor Launch Grok Bot Agent App in Beta(AI Weekly) Vercel — Node.js security advisory AWS Lambda — ランタイムの更新 Cloud Run — 自動ベースイメージ更新 actions/setup-node — check-latest Node.js リリーススケジュール