Créer une API REST Node.js avec Express en 2026 : le tutoriel pas à pas
L'essentiel en une minute
Créer une API REST Node.js en 2026, c'est d'abord choisir les bonnes versions : Node.js 24 est la LTS la plus récente au 24 octobre 2026, et Express 5.3.0 est sortie le 9 octobre. La première version de cet article reposait sur Node.js 20, arrivé en fin de vie le 30 avril 2026 : tout le code a été réécrit.
Ce tutoriel construit une petite API de gestion de tâches, de l'initialisation au déploiement. Il s'appuie sur la documentation de Node.js et d'Express, sur MDN pour HTTP et sur l'OWASP pour la sécurité, consultées le 24 octobre 2026. Le code suit ces documentations, mais nous ne l'avons pas exécuté.
> Points clés
>
> - Visez Node.js 24 LTS aujourd'hui, ou Node.js 26 à partir du 28 octobre 2026, date à laquelle il doit passer en LTS selon le calendrier officiel. Express 5 exige Node.js 18 au minimum.
> - Express 5 transmet automatiquement les erreurs des fonctions async au gestionnaire d'erreurs : plus besoin d'envelopper chaque route dans un try/catch.
> - Le module node:sqlite, intégré à Node.js, suffit pour apprendre sans dépendance, mais il est encore au stade « release candidate » et synchrone : passez à PostgreSQL en production.
> - Pour l'authentification, l'OWASP demande de refuser alg: none, de vérifier iss, aud et exp, et de hacher les mots de passe avec Argon2id, ou à défaut scrypt.
> - Le lanceur de tests intégré à Node.js, stable depuis la version 20, et fetch suffisent pour tester une API REST Node.js sans framework de test.
---
Les principes REST et les codes de statut
REST n'est pas une norme mais un ensemble de contraintes d'architecture. MDN précise qu'un débutant peut voir une API REST comme un service HTTP appelable avec les outils web standard (glossaire MDN, REST, mis à jour le 11 juillet 2025). En pratique : chaque ressource a une URL (/api/v1/taches/42), le verbe HTTP dit ce qu'on en fait, le code de statut dit comment ça s'est passé.
| Verbe | Usage dans notre API | Idempotent | Code en cas de succès |
|---|---|---|---|
| GET | Lire la liste ou une tâche | Oui | 200 |
| POST | Créer une tâche | Non | 201 |
| PUT | Remplacer une tâche entière | Oui | 200 |
| PATCH | Modifier une partie d'une tâche | Non garanti | 200 |
| DELETE | Supprimer une tâche | Oui | 204 |
Selon MDN, les méthodes sûres, PUT et DELETE sont idempotentes, alors que POST et PATCH ne le sont pas forcément (glossaire MDN, Idempotent) : deux PUT identiques laissent la ressource dans le même état, deux POST créent deux tâches.
Les codes de statut forment cinq classes : informations (1xx), succès (2xx), redirections (3xx), erreurs du client (4xx) et du serveur (5xx), selon la référence MDN des codes de statut. Voici ceux de notre API :
| Code | Sens selon MDN | Quand notre API le renvoie |
|---|---|---|
| 201 Created | Une nouvelle ressource a été créée | Après un POST réussi |
| 204 No Content | Pas de contenu à envoyer | Après une suppression |
| 400 Bad Request | Requête incompréhensible, syntaxe invalide | JSON mal formé |
| 401 Unauthorized | Le client doit s'authentifier | Jeton absent, invalide ou expiré |
| 404 Not Found | Ressource introuvable | Tâche inexistante ou d'un autre utilisateur |
| 409 Conflict | Conflit avec l'état du serveur | E-mail déjà inscrit |
| 413 Content Too Large | Corps de requête plus grand que la limite du serveur | Corps de plus de 10 ko |
| 415 Unsupported Media Type | Format de données non supporté | Corps absent ou qui n'est pas du JSON |
| 422 Unprocessable Content | Requête bien formée mais erreurs sémantiques | Données refusées par la validation |
| 429 Too Many Requests | Trop de requêtes en un temps donné | Trop de tentatives d'inscription ou de connexion |
| 500 Internal Server Error | Situation que le serveur ne sait pas traiter | Bug ou panne imprévue |
La fiche REST Security de l'OWASP tranche le piège classique : 401 quand l'authentification manque ou échoue, 403 quand elle a réussi mais que l'utilisateur n'a pas les droits. Pour la tâche d'un autre utilisateur, notre API répond 404, ce qui évite de confirmer qu'elle existe.
---
Pourquoi une API REST Node.js avec Express en 2026
Node.js reste la technologie web la plus citée par les développeurs, et Express le framework serveur le plus utilisé de cet écosystème. Dans l'enquête Stack Overflow 2025, 48,7 % des 23 678 répondants à la question sur les technologies web déclarent avoir beaucoup travaillé avec Node.js sur l'année écoulée, et 19,9 % avec Express. NestJS est à 6,7 % et Fastify à 2,9 %.
!Graphique en barres : usage de technologies serveur dans l'enquête Stack Overflow 2025, Node.js 48,7 %, Express 19,9 %, ASP.NET Core 19,7 %, FastAPI 14,8 %, Spring Boot 14,7 %, Flask 14,4 %, Django 12,6 %, NestJS 6,7 %, Fastify 2,9 %
L'édition 2026, menée du 23 juin au 5 août 2026 selon sa méthodologie, ne publie pas de classement des frameworks web : ces chiffres de 2025 restent les plus récents de cette source. Elle confirme que JavaScript est le langage le plus utilisé, par 62,0 % des 14 099 répondants à la question (Stack Overflow 2026).
Express n'est ni le plus rapide ni le plus structurant, mais sa simplicité en fait un bon outil pour apprendre : un routeur, des middlewares, rien d'imposé. Côté Python, l'équivalent serait Flask ou FastAPI, présentés dans notre guide pour apprendre Python en 2026.
---
Étape 1 : installer Node.js et initialiser le projet
Installez une version LTS : la page des versions de Node.js réserve la production aux versions LTS, actives ou en maintenance.
| Composant | Version au 24 octobre 2026 | Statut |
|---|---|---|
| Node.js 24 « Krypton » | 24.22.1, publiée le 20 octobre 2026 | LTS, en maintenance depuis le 20 octobre, fin prévue le 30 avril 2028 |
| Node.js 26 | 26.12.1, publiée le 21 octobre 2026 | Current, passage en LTS prévu le 28 octobre 2026 |
| Node.js 22 « Jod » | 22.23.4 | LTS de maintenance, fin prévue le 30 avril 2027 |
| Node.js 20 | 20.20.2 | Fin de vie |
| Express 5 | 5.3.0, publiée le 9 octobre 2026 | Version par défaut sur npm, Node.js 18 minimum |
Sources : le calendrier officiel des versions, les notes de version d'Express et sa page de support. À partir de Node.js 27, chaque version majeure deviendra LTS, sur un cycle annuel. Créez ensuite le projet :
node --version
mkdir api-taches && cd api-taches
npm init -y
npm install express zod jsonwebtoken helmet rate-limiter-flexible
Au 24 octobre 2026, le registre npm sert Express 5.3.0, Zod 4.6.7, jsonwebtoken 9.0.3, Helmet 8.3.0 et rate-limiter-flexible 11.2.3. Complétez package.json ainsi :
{
"name": "api-taches",
"version": "1.0.0",
"type": "module",
"engines": { "node": ">=24" },
"scripts": {
"dev": "node --watch --env-file=.env src/server.js",
"start": "node --env-file-if-exists=.env src/server.js",
"test": "node --test"
},
"dependencies": {
"express": "^5.3.0",
"helmet": "^8.3.0",
"jsonwebtoken": "^9.0.3",
"rate-limiter-flexible": "^11.2.3",
"zod": "^4.6.7"
}
}
Plus besoin de nodemon ni de dotenv : selon la documentation de la ligne de commande, --watch est stable depuis Node.js 22, et --env-file comme --env-file-if-exists ne sont plus expérimentaux depuis Node.js 24.10 (journal des modifications de la 24.10.0). Le script start charge .env s'il existe, sans échouer en production où les variables sont injectées par l'hébergeur. Créez enfin un fichier .env, exclu de Git :
PORT=3000
DB_PATH=data.db
JWT_SECRET=collez-ici-une-valeur-aleatoire
Pour générer le secret, node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))" produit 256 bits aléatoires, la taille minimale qu'impose la RFC 7518, section 3.2 pour une clé HS256 ; sans cette valeur, server.js refuse de démarrer. Pour l'éditeur, voyez notre sélection d'extensions VS Code.
---
Étape 2 : créer le serveur Express 5
L'application et son démarrage vivent dans deux fichiers, pour que les tests importent l'application sans ouvrir de port. L'arborescence finale :
api-taches/
package.json
.env
src/
app.js application Express
server.js démarrage
db.js base SQLite
schemas.js schémas Zod
motdepasse.js hachage scrypt
middleware/ validation, auth, limiteur, erreurs
routes/ auth.js, taches.js
test/
api.test.js
Le fichier src/app.js assemble les middlewares et les routeurs :
import express from 'express';
import helmet from 'helmet';
import { routerAuth } from './routes/auth.js';
import { routerTaches } from './routes/taches.js';
import { routeInconnue, gestionErreurs } from './middleware/erreurs.js';
export const app = express();
if (process.env.TRUST_PROXY) {
app.set('trust proxy', Number(process.env.TRUST_PROXY));
}
app.use(helmet());
app.use(express.json({ limit: '10kb' }));
app.get('/health', (req, res) => {
res.json({ statut: 'ok' });
});
app.use('/api/v1/auth', routerAuth);
app.use('/api/v1/taches', routerTaches);
app.use(routeInconnue);
app.use(gestionErreurs);
Helmet ajoute des en-têtes de sécurité et retire X-Powered-By, comme le recommande la page sécurité en production d'Express. express.json() limite le corps à 100 ko par défaut selon la référence de l'API Express 5 : 10 ko suffisent pour une tâche. Le préfixe /api/v1 permettra une version 2 sans casser les clients. Derrière un proxy inverse, TRUST_PROXY=1 indique à Express de lire l'adresse du client dans X-Forwarded-For à un saut de distance, comme le décrit la page Express derrière un proxy ; sans proxy, laissez la variable vide, sinon un client pourrait falsifier son adresse.
Le fichier src/server.js démarre le serveur :
import { app } from './app.js';
if (Buffer.byteLength(process.env.JWT_SECRET ?? '') < 32) {
throw new Error('JWT_SECRET doit contenir au moins 32 octets (256 bits)');
}
const port = Number(process.env.PORT ?? 3000);
app.listen(port, (error) => {
if (error) throw error;
console.log(`API à l'écoute sur http://localhost:${port}`);
});
Le paramètre error est une nouveauté d'Express 5 : selon le guide de migration, app.listen appelle ce rappel en cas d'erreur du serveur, comme un port déjà occupé.
---
Étape 3 : stocker les données dans SQLite
Node.js embarque SQLite depuis la version 22.5, dans le module node:sqlite, sans option à activer depuis les versions 22.13 et 23.4. La documentation de Node.js 24 le classe « release candidate » (stabilité 1.2) depuis la version 24.15, publiée le 15 avril 2026 (journal des modifications de la 24.15.0). Pour un tutoriel, c'est le plus simple : zéro dépendance, zéro serveur.
import { DatabaseSync } from 'node:sqlite';
export const db = new DatabaseSync(process.env.DB_PATH ?? 'data.db');
db.exec(`
CREATE TABLE IF NOT EXISTS utilisateurs (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
hash TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS taches (
id INTEGER PRIMARY KEY,
titre TEXT NOT NULL,
terminee INTEGER NOT NULL DEFAULT 0,
proprietaire INTEGER NOT NULL REFERENCES utilisateurs(id)
);
`);
Les clés étrangères sont actives par défaut, selon la même page. Toutes les requêtes passent par db.prepare() avec des paramètres ? : les valeurs sont liées à part du texte SQL, ce qui bloque l'injection SQL.
Deux limites. Sous Node.js 26, la classe s'appelle Database et DatabaseSync devient un alias déprécié dans la documentation seulement (DEP0210). Surtout, l'API est synchrone : chaque requête bloque la boucle d'événements, alors que la page performances en production d'Express déconseille les fonctions synchrones.
!Graphique en sucettes : bases de données candidates pour une API REST Node.js, les plus utilisées selon l'enquête Stack Overflow 2026, PostgreSQL 57,9 %, SQLite 42,1 %, MySQL 35,1 %, Redis 28,3 %, Microsoft SQL Server 27,1 %, MariaDB 20,3 %, MongoDB 19,0 %
Pour une API REST Node.js en production avec du trafic concurrent, préférez PostgreSQL, la base la plus utilisée : 57,9 % des 12 061 répondants concernés de l'enquête Stack Overflow 2026, devant SQLite (42,1 %) et MySQL (35,1 %) ; MongoDB est à 19,0 %. Il faudra un pilote asynchrone et des await, mais les routes gardent la même forme.
---
Étape 4 : écrire les routes et les verbes HTTP
Les routes traduisent le tableau des verbes en code. Toutes passent par exigerAuth : chaque utilisateur ne voit que ses tâches. Les middlewares utilisés sont écrits aux étapes 5 et 7. Voici src/routes/taches.js :
import { Router } from 'express';
import { db } from '../db.js';
import { exigerAuth } from '../middleware/auth.js';
import { exigerJson, valider } from '../middleware/validation.js';
import { schemaCreation, schemaRemplacement, schemaModification } from '../schemas.js';
export const routerTaches = Router();
routerTaches.use(exigerAuth);
const versJson = (t) => ({ id: t.id, titre: t.titre, terminee: t.terminee === 1 });
const introuvable = (res) => res.status(404).json({ erreur: 'Tâche introuvable' });
function lireId(req) {
const id = Number(req.params.id);
return Number.isSafeInteger(id) && id > 0 ? id : null;
}
function trouver(id, proprietaire) {
return db
.prepare('SELECT id, titre, terminee FROM taches WHERE id = ? AND proprietaire = ?')
.get(id, proprietaire);
}
routerTaches.get('/', (req, res) => {
const taches = db
.prepare('SELECT id, titre, terminee FROM taches WHERE proprietaire = ? ORDER BY id')
.all(req.utilisateurId);
res.json(taches.map(versJson));
});
routerTaches.get('/:id', (req, res) => {
const id = lireId(req);
const tache = id && trouver(id, req.utilisateurId);
if (!tache) return introuvable(res);
res.json(versJson(tache));
});
routerTaches.post('/', exigerJson, valider(schemaCreation), (req, res) => {
const { titre, terminee = false } = req.donnees;
const { lastInsertRowid } = db
.prepare('INSERT INTO taches (titre, terminee, proprietaire) VALUES (?, ?, ?)')
.run(titre, terminee ? 1 : 0, req.utilisateurId);
const id = Number(lastInsertRowid);
res.status(201).location(`${req.baseUrl}/${id}`).json({ id, titre, terminee });
});
function mettreAJour(req, res) {
const id = lireId(req);
const actuelle = id && trouver(id, req.utilisateurId);
if (!actuelle) return introuvable(res);
const tache = { ...versJson(actuelle), ...req.donnees };
db.prepare('UPDATE taches SET titre = ?, terminee = ? WHERE id = ?')
.run(tache.titre, tache.terminee ? 1 : 0, id);
res.json(tache);
}
routerTaches.put('/:id', exigerJson, valider(schemaRemplacement), mettreAJour);
routerTaches.patch('/:id', exigerJson, valider(schemaModification), mettreAJour);
routerTaches.delete('/:id', (req, res) => {
const id = lireId(req);
if (!id) return introuvable(res);
const { changes } = db
.prepare('DELETE FROM taches WHERE id = ? AND proprietaire = ?')
.run(id, req.utilisateurId);
if (Number(changes) === 0) return introuvable(res);
res.sendStatus(204);
});
Le guide de migration vers Express 5 retire res.json(objet, 201) au profit de res.status(201).json(objet), et res.send(204) au profit de res.sendStatus(204). L'en-tête Location du POST donne au client l'URL de la ressource créée.
La syntaxe des chemins a aussi changé : un joker doit être nommé (/*splat au lieu de /*) et un paramètre optionnel s'écrit entre accolades. Pour migrer un projet Express 4, le guide propose npx codemod@latest @expressjs/v5-migration-recipe.
---
Étape 5 : valider les données avec Zod
Ne faites jamais confiance au corps d'une requête. La fiche REST de l'OWASP demande de valider longueur, plage, format et type de chaque entrée, et de refuser en 415 un Content-Type inattendu. Les schémas vont dans src/schemas.js :
import * as z from 'zod';
const titre = z.string().trim().min(1).max(200);
export const schemaIdentifiants = z.strictObject({
email: z.string().trim().toLowerCase().pipe(z.email()),
motDePasse: z.string().min(15).max(128),
});
export const schemaCreation = z.strictObject({ titre, terminee: z.boolean().optional() });
export const schemaRemplacement = z.strictObject({ titre, terminee: z.boolean() });
export const schemaModification = schemaRemplacement.partial();
z.strictObject refuse les clés inconnues : impossible d'injecter un champ proprietaire. L'e-mail est nettoyé et mis en minuscules avant d'être validé par z.email(), la syntaxe de Zod 4 (référence de l'API Zod), pour qu'Ada@Example.com et ada@example.com désignent le même compte. Le minimum de 15 caractères suit la fiche Authentication de l'OWASP et la norme NIST SP 800-63B, révision 4 (section 3.1.1.2) pour un mot de passe utilisé sans second facteur ; le maximum doit rester d'au moins 64 caractères. Le middleware src/middleware/validation.js range le résultat nettoyé dans req.donnees :
import * as z from 'zod';
export function exigerJson(req, res, next) {
if (!req.is('application/json')) {
return res.status(415).json({ erreur: 'Le corps doit être en application/json' });
}
next();
}
export function valider(schema) {
return (req, res, next) => {
const resultat = schema.safeParse(req.body);
if (!resultat.success) {
return res.status(422).json({
erreur: 'Données invalides',
details: z.flattenError(resultat.error),
});
}
req.donnees = resultat.data;
next();
};
}
safeParse() renvoie success puis data ou error sans lever d'exception. z.flattenError() produit deux listes, selon la page de Zod sur le formatage des erreurs : fieldErrors, champ par champ, et formErrors, pour les erreurs à la racine de l'objet. C'est là qu'arrive une clé inconnue refusée par z.strictObject, d'où l'envoi des deux listes au client.
Le contrôle du Content-Type sert aussi Express 5 : req.body y vaut undefined quand le corps n'a pas été analysé, là où Express 4 renvoyait {}. Sans lui, une requête en text/plain recevrait un 422 trompeur. Une requête sans corps du tout reçoit elle aussi un 415, car req.is() renvoie null quand il n'y a pas de corps, selon la référence de l'objet requête : c'est voulu, ces routes exigent un objet JSON.
---
Étape 6 : centraliser la gestion des erreurs
Express 5 règle le principal défaut d'Express 4. Selon la page sur la gestion des erreurs, une route qui renvoie une promesse rejetée appelle automatiquement next(erreur) : une exception dans une fonction async atteint le gestionnaire sans try/catch. Ce gestionnaire prend quatre arguments et se déclare après les routes. Voici src/middleware/erreurs.js :
const messages = {
400: 'Requête mal formée',
413: 'Corps de requête trop volumineux',
415: 'Encodage ou jeu de caractères non pris en charge',
};
export function routeInconnue(req, res) {
res.status(404).json({ erreur: 'Ressource introuvable' });
}
export function gestionErreurs(err, req, res, next) {
if (res.headersSent) return next(err);
const statut = messages[err.status] ? err.status : 500;
if (statut === 500) console.error(err);
res.status(statut).json({ erreur: messages[statut] ?? 'Erreur interne du serveur' });
}
Ces trois codes viennent du parseur JSON, dont la documentation attribue le statut 400 à un corps illisible, 413 à un corps trop gros et 415 à un encodage ou un jeu de caractères non pris en charge. Tout le reste devient un 500 générique, le détail restant dans les journaux du serveur : c'est la règle de l'OWASP, qui interdit d'envoyer au client une pile d'appels ou un indice technique. Si la réponse a déjà commencé (res.headersSent), Express recommande de déléguer à son gestionnaire par défaut.
---
Étape 7 : ajouter l'authentification JWT
L'API délivre à la connexion un jeton JWT signé, que le client renvoie dans l'en-tête Authorization: Bearer. Il faut d'abord stocker les mots de passe de façon sûre.
Hacher les mots de passe avec scrypt
La fiche de l'OWASP sur le stockage des mots de passe recommande Argon2id, puis scrypt avec un coût minimal de 2^17, une taille de bloc de 8 et un parallélisme de 1. Node.js fournit scrypt nativement. Voici src/motdepasse.js :
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
const scryptAsync = promisify(scrypt);
const PARAMS = { N: 2 ** 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
const LONGUEUR = 64;
export async function hacher(motDePasse) {
const sel = randomBytes(16);
const cle = await scryptAsync(motDePasse, sel, LONGUEUR, PARAMS);
return `${sel.toString('hex')}:${cle.toString('hex')}`;
}
export async function verifier(motDePasse, stocke) {
const [sel, attendu] = stocke.split(':').map((h) => Buffer.from(h, 'hex'));
const cle = await scryptAsync(motDePasse, sel, LONGUEUR, PARAMS);
return timingSafeEqual(cle, attendu);
}
maxmem n'est pas décoratif : la documentation du module crypto le fixe par défaut à 32 Mio et lève une erreur quand 128 × N × r le dépasse, soit 128 Mio avec les paramètres de l'OWASP. Elle recommande aussi un sel aléatoire d'au moins 16 octets. timingSafeEqual compare en temps constant.
Ce coût est voulu, mais il se paie : chaque inscription ou connexion mobilise environ 128 Mio de mémoire. De plus, crypto.scrypt() s'exécute dans le pool de threads de libuv, limité à 4 threads par défaut selon la documentation de UV_THREADPOOL_SIZE, un pool partagé avec les accès disque. D'où la limitation du nombre de tentatives ci-dessous, et une variable UV_THREADPOOL_SIZE à augmenter au lancement du processus si les connexions sont nombreuses.
Signer et vérifier les jetons
Le middleware src/middleware/auth.js crée les jetons et protège les routes :
import jwt from 'jsonwebtoken';
const optionsJwt = { issuer: 'api-taches', audience: 'api-taches' };
export function creerJeton(utilisateurId) {
return jwt.sign({}, process.env.JWT_SECRET, {
...optionsJwt,
algorithm: 'HS256',
expiresIn: '15m',
subject: String(utilisateurId),
});
}
export function exigerAuth(req, res, next) {
const [type, jeton] = (req.get('Authorization') ?? '').split(' ');
let charge;
try {
if (type !== 'Bearer' || !jeton) throw new Error('jeton absent');
charge = jwt.verify(jeton, process.env.JWT_SECRET, { ...optionsJwt, algorithms: ['HS256'] });
} catch {
res.set('WWW-Authenticate', 'Bearer');
return res.status(401).json({ erreur: 'Authentification requise ou jeton expiré' });
}
req.utilisateurId = Number(charge.sub);
next();
}
Chaque option répond à l'OWASP. La fiche REST Security interdit les jetons non signés (alg: none) et demande de vérifier iss, aud et exp. La fiche JSON Web Token met en garde contre les algorithmes non fixés et les secrets faibles, qu'un attaquant peut deviner hors ligne. La RFC 7518 (section 3.2) impose une clé d'au moins 256 bits pour HS256, ce que vérifie server.js au démarrage. D'où algorithms: ['HS256'], le secret aléatoire de 32 octets et une durée de vie courte (15 minutes) qui limite l'usage d'un jeton volé.
Selon le README de jsonwebtoken, jwt.verify sans rappel renvoie la charge utile si signature, expiration, audience et émetteur sont valides, et lève une erreur sinon.
Limiter les tentatives
La page sécurité en production d'Express recommande de bloquer les tentatives d'authentification répétées, avec le paquet rate-limiter-flexible. Voici src/middleware/limiteur.js, qui accepte 5 tentatives par minute et par adresse IP :
import { RateLimiterMemory } from 'rate-limiter-flexible';
const limiteur = new RateLimiterMemory({ points: 5, duration: 60 });
export async function limiterAuth(req, res, next) {
try {
await limiteur.consume(req.ip);
} catch (refus) {
if (refus instanceof Error) throw refus;
res.set('Retry-After', String(Math.ceil(refus.msBeforeNext / 1000)));
return res.status(429).json({ erreur: 'Trop de tentatives, réessayez plus tard' });
}
next();
}
Selon le README de rate-limiter-flexible, consume() rejette sa promesse avec un objet contenant msBeforeNext quand les points sont épuisés, du moins « s'il n'y a pas d'erreur » (notre traduction). Le code traite donc à part une éventuelle Error, renvoyée au gestionnaire d'erreurs, et utilise l'objet pour l'en-tête Retry-After du code 429. Derrière un proxy inverse, req.ip n'est la vraie adresse du client que si TRUST_PROXY est réglé (étape 2). Le compteur vit en mémoire : avec plusieurs instances, il faut un stockage partagé comme Redis, que le paquet prend en charge.
Les routes d'inscription et de connexion
Reste src/routes/auth.js :
import { Router } from 'express';
import { db } from '../db.js';
import { hacher, verifier } from '../motdepasse.js';
import { creerJeton } from '../middleware/auth.js';
import { limiterAuth } from '../middleware/limiteur.js';
import { exigerJson, valider } from '../middleware/validation.js';
import { schemaIdentifiants } from '../schemas.js';
export const routerAuth = Router();
routerAuth.use(limiterAuth);
routerAuth.post('/inscription', exigerJson, valider(schemaIdentifiants), async (req, res) => {
const { email, motDePasse } = req.donnees;
const hash = await hacher(motDePasse);
// Aucun await entre la vérification et l'insertion : rien ne peut s'intercaler.
if (db.prepare('SELECT id FROM utilisateurs WHERE email = ?').get(email)) {
return res.status(409).json({ erreur: 'Un compte existe déjà pour cet e-mail' });
}
const { lastInsertRowid } = db
.prepare('INSERT INTO utilisateurs (email, hash) VALUES (?, ?)')
.run(email, hash);
res.status(201).json({ id: Number(lastInsertRowid), email });
});
routerAuth.post('/connexion', exigerJson, valider(schemaIdentifiants), async (req, res) => {
const { email, motDePasse } = req.donnees;
const utilisateur = db.prepare('SELECT id, hash FROM utilisateurs WHERE email = ?').get(email);
if (!utilisateur || !(await verifier(motDePasse, utilisateur.hash))) {
res.set('WWW-Authenticate', 'Bearer');
return res.status(401).json({ erreur: 'Identifiants invalides' });
}
res.json({ jeton: creerJeton(utilisateur.id) });
});
L'ordre de l'inscription compte. Le hachage, asynchrone, a lieu avant la vérification de l'e-mail : la lecture et l'insertion s'enchaînent alors sans await, et comme node:sqlite est synchrone, deux inscriptions simultanées au même e-mail ne peuvent pas passer toutes deux la vérification dans un même processus. Avec plusieurs processus, la contrainte UNIQUE reste le dernier rempart et produirait un 500.
Essayer l'API à la main
Lancez npm run dev, puis enchaînez ces appels avec curl. Les réponses en commentaire sont celles que le code ci-dessus est censé produire ; nous ne les avons pas relevées en exécutant le code.
curl -i -X POST http://localhost:3000/api/v1/auth/inscription \
-H "Content-Type: application/json" \
-d '{"email":"ada@example.com","motDePasse":"une phrase de passe longue"}'
# Attendu : 201 Created, {"id":1,"email":"ada@example.com"}
curl -s -X POST http://localhost:3000/api/v1/auth/connexion \
-H "Content-Type: application/json" \
-d '{"email":"ada@example.com","motDePasse":"une phrase de passe longue"}'
# Attendu (corps) : {"jeton":"eyJ..."}
curl -i -X POST http://localhost:3000/api/v1/taches \
-H "Content-Type: application/json" \
-H "Authorization: Bearer COLLEZ_LE_JETON_ICI" \
-d '{"titre":"Écrire les tests"}'
# Attendu : 201 Created, en-tête Location: /api/v1/taches/1,
# {"id":1,"titre":"Écrire les tests","terminee":false}
Ce code a encore des limites assumées, à traiter avant la production :
- La révocation. Un JWT reste valide jusqu'à son expiration. L'OWASP propose une liste de jetons révoqués pour la déconnexion anticipée.
- Le blocage par compte. Express conseille de compter aussi les échecs par nom d'utilisateur, pas seulement par adresse IP ; notre limiteur ne le fait pas.
- L'énumération des comptes. Le 409 de l'inscription et le temps de réponse de la connexion révèlent si un e-mail est inscrit.
- Le navigateur. Pour un front-end web, une session en cookie httpOnly et secure évite d'exposer le jeton au JavaScript.
Pour vos utilisateurs finaux, les passkeys sont une alternative sérieuse aux mots de passe. Pour le contexte des menaces, voyez notre panorama des cyberattaques les plus fréquentes.
---
Étape 8 : tester l'API avec le lanceur de Node.js
Le lanceur de tests node:test est stable depuis Node.js 20 selon sa documentation. Avec fetch, intégré lui aussi, il suffit pour des tests d'intégration sans Jest ni Supertest. Voici test/api.test.js :
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { once } from 'node:events';
process.env.DB_PATH = ':memory:';
process.env.JWT_SECRET = 'cle-de-test-uniquement-0123456789abcdef0123456789';
const { app } = await import('../src/app.js');
let serveur;
let base;
before(async () => {
serveur = app.listen(0);
await once(serveur, 'listening');
base = `http://127.0.0.1:${serveur.address().port}/api/v1`;
});
after(() => serveur.close());
function envoyer(chemin, corps, jeton) {
const headers = { 'Content-Type': 'application/json' };
if (jeton) headers.Authorization = `Bearer ${jeton}`;
return fetch(`${base}${chemin}`, { method: 'POST', headers, body: JSON.stringify(corps) });
}
test('refuse la liste des tâches sans jeton', async () => {
const reponse = await fetch(`${base}/taches`);
assert.equal(reponse.status, 401);
});
test('inscrit, connecte puis crée une tâche', async () => {
const identifiants = { email: 'ada@example.com', motDePasse: 'une phrase de passe longue' };
assert.equal((await envoyer('/auth/inscription', identifiants)).status, 201);
const { jeton } = await (await envoyer('/auth/connexion', identifiants)).json();
const creation = await envoyer('/taches', { titre: 'Écrire les tests' }, jeton);
assert.equal(creation.status, 201);
assert.deepEqual(await creation.json(), { id: 1, titre: 'Écrire les tests', terminee: false });
const invalide = await envoyer('/taches', { titre: '' }, jeton);
assert.equal(invalide.status, 422);
});
L'import dynamique de app.js est volontaire : les import statiques s'exécutent avant le reste du module, donc avant que DB_PATH ne désigne une base en mémoire. app.listen(0) choisit un port libre. Lancez le tout avec npm test. Postman ou Insomnia restent utiles pour explorer l'API à la main, mais ne remplacent pas des tests rejoués à chaque modification.
---
Étape 9 : préparer le déploiement
Une API REST Node.js prête pour la production repose sur quelques réglages, détaillés par la page performances en production d'Express :
- NODE_ENV=production. Les messages d'erreur deviennent moins bavards. La page affirme que ce seul réglage triplerait les performances, une mesure de l'éditeur.
- Un redémarrage automatique. Un gestionnaire de processus ou un service systemd relance l'application.
- Un proxy inverse. Nginx ou HAProxy gère TLS, la compression et la répartition de charge.
- Des journaux adaptés. console.log est synchrone vers un terminal ou un fichier : la page conseille une bibliothèque comme Pino.
Le HTTPS est obligatoire : l'OWASP exige que les services REST n'exposent que des points d'accès HTTPS, pour protéger mots de passe et jetons. Lancez aussi npm audit régulièrement, comme le rappelle la page sécurité d'Express.
Pour l'hébergement, le conteneur est la voie la plus portable : voyez notre guide Docker pour débutants, puis notre comparatif de l'hébergement cloud pour développeurs. Automatisez ensuite tests et mises en ligne avec notre guide pour automatiser ses déploiements en CI/CD.
Attention, la base SQLite est un fichier : sur une plateforme dont le disque est effacé à chaque déploiement, vos données disparaissent. Vérifiez la persistance du disque ou passez à PostgreSQL. Et sur plusieurs instances, les processus ne partagent pas leur mémoire, rappelle Express : voyez alors notre introduction à Kubernetes.
---
Ce que nous n'avons pas pu vérifier
Ce tutoriel est une synthèse documentaire. Restent incertains :
- L'exécution du code. Il suit la documentation officielle de chaque outil au 24 octobre 2026, mais nous ne l'avons ni exécuté ni testé. Signalez-nous toute erreur.
- Le passage de Node.js 26 en LTS. Le 28 octobre 2026 est la date du calendrier officiel ; elle peut glisser.
- Le détail d'Express 5.3.0. Nous avons relevé la version et sa date, pas l'ensemble de ses changements.
- Le facteur trois de NODE_ENV=production. C'est une affirmation d'Express, sans protocole publié sur la page.
- Les réponses attendues des appels curl. Elles sont déduites du code, pas relevées lors d'une exécution.
- Les réglages du limiteur. Cinq tentatives par minute est un exemple : ni Express ni l'OWASP ne fixent de seuil, et nous n'avons pas mesuré l'effet de scrypt sur un serveur réel.
- Des chiffres d'usage d'Express plus récents. L'enquête Stack Overflow 2026 ne classe pas les frameworks web.
- Les offres gratuites des hébergeurs. La version précédente en citait ; non revérifiées, elles ont été retirées.
---
Questions fréquentes
Quelle version de Node.js utiliser pour une API REST en 2026 ?
Une version LTS. Au 24 octobre 2026, c'est Node.js 24, en version 24.22.1, selon la page des versions de Node.js. Node.js 26 doit devenir LTS le 28 octobre 2026. Node.js 20 est en fin de vie : ne démarrez pas de projet dessus.
Faut-il passer d'Express 4 à Express 5 ?
Pour un nouveau projet, oui : Express 5 est la version servie par défaut sur npm et gère seule les erreurs des fonctions async. Pour un projet existant, le guide de migration liste les ruptures, surtout la syntaxe des chemins, et propose un outil de migration automatique.
Quelle différence entre REST et GraphQL ?
Une API REST expose une URL par ressource et s'appuie sur les verbes et codes de statut HTTP (voir le glossaire de MDN). GraphQL passe en général par un point d'accès unique où le client décrit les données voulues. REST reste plus simple pour débuter.
Faut-il utiliser TypeScript pour une API Express ?
Ce n'est pas obligatoire pour apprendre, mais utile dès que le projet grandit. TypeScript est utilisé par 43,8 % des répondants de l'enquête Stack Overflow 2026. Avec Zod, vous pouvez déjà dériver les types de vos schémas, ce qui facilite le passage.
---
À 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. Synthèse documentaire fondée exclusivement sur des sources publiques consultées le 24 octobre 2026 : documentation de Node.js (versions 24 et 26) et son calendrier de versions, documentation et notes de version d'Express, documentations de Zod, jsonwebtoken, Helmet et body-parser, registre npm, fiches de l'OWASP, références HTTP de MDN, enquêtes Stack Overflow 2025 et 2026. Le code n'a pas été exécuté pour cet article. Aucun lien n'est affilié et aucune relation commerciale ne lie l'auteur aux éditeurs mentionnés.
Une erreur ou une commande périmée ? Écrivez à contact@neurautech.com : l'article sera corrigé et la date de mise à jour modifiée.
Dernière mise à jour : 24 octobre 2026. Première publication : 25 mai 2026.
Image : Markus Spiske (Unsplash License).
Node.js October 2026 Security Release: Finding Every Node You Actually Run
---
title: Node.js October 2026 Security Release: Finding Every Node You Actually Run
published: true
date: 2026-10-22 09:00:00 UTC
tags: Linux,Nodejs,Security,pm2
canonical_url: devops-daily.com
---
On 21 October 2026 the Node.js project released 22.23.4, 24.22.1 and 26.12.1. The headline fix is CVE-2026-111497, an out-of-bounds write in the Brotli decoder that the advisory says "permits remote code execution". The same release fixes two TLS certificate hostname-check bypasses, a DNS bug that truncates names at a NUL character, and updates the bundled CA store. End-of-life lines get no fix.
Patching Node is a one-line version bump. Finding every Node your servers actually run is the work. When we patched a small production box on release day, six Node apps under pm2 installed with nvm, installing the new version was the easy part. Then we found pm2 entries with the old binary written into them, a systemd unit with the old PATH hard-coded, child processes we had not thought about, and cron jobs on two other boxes that pointed at the old install by absolute path. None of them moved when the default version changed.
This post covers what to do now, who is exposed (including services that call fetch() on a URL a user controls), the upgrade steps for nvm, pm2, systemd and containers with the traps we hit, how to verify with /proc, and a read-only audit script with its real output from one of our machines.
What to do now
1. Upgrade every Node to 22.23.4, 24.22.1 or 26.12.1 or later. Node 20, 25 and older are end-of-life and get no fixed release. Move them to 22, 24 or 26.
2. List every place Node runs or will start : running processes, pm2 entries (live and saved), the pm2 daemon, systemd units, crontabs and helper scripts, and containers. Changing the nvm default moves none of them.
3. Move each one with the steps below, then check the real binary of every process in /proc. Process names do not help: on our build machine pgrep node found 0 of 5 Node processes.
4. Rebuild images with --pull and redeploy. The official node:22, node:24 and node:26 tags have the fix. node:20, node:25 and node:18 will not get it.
5. If a service cannot be upgraded today and accepts Brotli data from strangers, close that path in front of it (details below). For outbound fetch() the fix is the upgrade; until then, turning off features that fetch user-supplied URLs is the way to close that path.
6. Run the audit script on every host and keep the output.
Prerequisites
- Shell access to the hosts that run Node, as the user that owns the Node processes. Root is not needed.
- Python 3.8 or newer for the audit script. It uses only the standard library and only reads.
- The docker or podman CLI on hosts that run Node in containers.
What was fixed
The advisory lists ten vulnerabilities. The four rated high:
| CVE | What the advisory calls it | Lines |
| --- | --- | --- |
| CVE-2026-111497 | Brotli decoder out-of-bounds write permits remote code execution | 22, 24, 26 |
| CVE-2026-105483 | TLS certificate hostname bypass when the IP is written in fullwidth digits | 24, 26 |
| CVE-2026-106069 | TLS certificate hostname bypass when the host loosely resolves to an IP address | 22, 24, 26 |
| CVE-2026-105482 | dns.resolve truncates names containing a NUL character | 22, 24, 26 |
The medium ones are a keep-alive socket that processes a response written before the request was sent (CVE-2026-105481), DNS SANs that can authenticate IP endpoints written in a non-canonical IPv4 form (CVE-2026-106072), and crypto.randomUUID() and randomInt() caches saved into startup snapshots and single executable applications, so that every process repeats the same values (CVE-2026-106074). The low ones are two Permission Model bypasses and a ZIP header issue on 26.x.
The release also updates the bundled root certificates to NSS 3.131.1 and makes Node reject certificate chains that contain blocked public keys. The advisory lists this as the "ca-cert (eMudhra Intermediate CA Compromise)" update. The bundled undici, c-ares and OpenSSL (22 and 24) are updated too.
Two things the advisory does not give you yet. There is no technical detail on any of the bugs, and on the morning of 22 October all ten CVE IDs were still in the RESERVED state at CVE.org, with no NVD record. So there is no CVSS score; "high" is the Node.js project's rating.
The timeline in UTC, from the release metadata:
- 21 Oct, 10:32 to 10:36 : the fixed binaries on nodejs.org/dist are dated in this window. The GitHub releases were published at 10:38.
- 10:48 : the update to the official Docker images was committed.
- about 11:35 : the Debian-based official images were built. Docker Hub shows the amd64 and arm64 images as pushed at about 14:40.
- 19:27 : the Brotli project released 1.2.1, 1.1.1 and 1.0.10, each with "CVE-2026-111497: out-of-bounds write permits remote code execution" in the notes. So the bug is in the Brotli decoder itself, and Node shipped the upstream fix as a cherry-picked commit.
- 22:58 to 00:08 : the Alpine-based official images were built, about 12 to 13.5 hours after the release.
Who is exposed
Brotli: any Node process that decodes Brotli someone else chose
The decoder is compiled into the node binary and sits behind node:zlib. Every way into it counts, and three are common:
1. Your code decompresses Brotli directly : zlib.brotliDecompress(), brotliDecompressSync() or createBrotliDecompress() on uploads, archives, cache entries or messages.
2. Your service fetches URLs. fetch() in Node is the bundled undici, and it decodes responses that say Content-Encoding: br with zlib.createBrotliDecompress. It goes by the response's header, whatever the request asked for, so setting Accept-Encoding: identity does not keep Brotli away from a hostile server. axios in Node does the same unless you pass decompress: false. Link previews, webhook testers, feed readers, "import from URL" features and AI agents that browse are all services that fetch a URL a user chose.
3. Your service accepts compressed request bodies. body-parser 2 decompresses br request bodies that it parses, and its inflate option defaults to true. Express 5 ships body-parser 2, so express.json() on Express 5 decodes Brotli bodies sent with a JSON content type, from any client. Express 4 uses body-parser 1.x, which handles only gzip and deflate.
Reaching the decoder is not the same as being exploitable, and the advisory does not say what input triggers the bug. These are the paths to upgrade or close first. Because the decoder is in the binary, no npm update fixes it. Upgrading the undici package in your package.json, for example, still decodes through the old Node's zlib.
If you cannot upgrade a service today, the interim options close only the paths you control:
// Express 5: refuse compressed request bodies (415) until Node is upgraded.
// This also refuses gzip and deflate request bodies.
app.use(express.json({ inflate: false }));
# In front of any Node service: refuse Brotli request bodies.
if ($http_content_encoding ~* "br") {
return 415;
}
Distribution builds are different. Debian's nodejs package (we checked the trixie build of 20.19.2) is built with --shared-brotli against the system libbrotli, so on Debian the Brotli fix comes from the distribution's brotli package, not from the Node version number. At the time we checked, neither Debian's security tracker nor Ubuntu's notices listed CVE-2026-111497 yet. The audit script reports such builds as unknown rather than guessing.
TLS hostname checks and DNS
When Node connects over TLS it checks the server's certificate against the host you asked for. Both high-severity TLS bugs are in that check when the host is, or loosely resolves to, an IP address written in a non-standard form: fullwidth digits (24 and 26 only) or a form that "loosely resolves to an IP address" (all lines). The fix makes Node reject non-canonical IP addresses. The advisory gives no more detail than that. Our reading is that the services to look at first are the ones where someone else chooses the host string: webhook senders, URL fetchers and integrations that connect to customer endpoints.
The DNS bug truncated names at a NUL byte in dns.resolve*(), and the fix rejects such input. Code that resolves names it got from users is where this matters.
End-of-life lines
"End-of-Life versions are always affected when a security release occurs," says the advisory. Node 20 reached end of life on 30 April 2026 and Node 25 on 1 June 2026. Neither gets a fixed release, and the official node:20 and node:25 image tags stay on 20.20.2 and 25.9.0.
Why changing the default moves nothing
nvm installs the new version next to the old one, which is what you want: the old one stays for rollback. nvm alias default 24.22.1 then changes what new shells get. Nothing that is already running reads that alias, and nothing that stored a path to the old install reads it either.
1. nvm install 24.22.1 next to the old version
2. nvm alias default new shells get it
3. What runs the new Node? check /proc, not config
Outcomes:
- New shells, and pm2 apps restarted with --update-env from one
- pm2 daemon, cluster workers, absolute interpreters, systemd unit, cron, images: still old
On the production box, each of these needed its own step:
- pm2 entries with an absolute interpreter. Several entries had the old Node binary written into them as the interpreter, and the ones started as npm or npx had their script path under the old version's bin directory. Changing the default did not move them. They had to be deleted and recreated with the new interpreter.
- pm2 entries with a bare node interpreter took the new binary only after pm2 restart <name> --update-env from a shell where the new Node came first in PATH.
- The pm2 systemd unit that pm2 startup wrote had the old version's PATH and the old pm2 path hard-coded. After editing it, pm2 update moved the pm2 daemon itself, which restarts every app at once. The sites on that box were down for under a minute while the Next.js apps started.
- Child processes. Checking /proc/<pid>/exe for every process turned up chains such as npx to tsx to node, and npm start to next-server. Each process in a chain has its own binary.
- Cron jobs and helper scripts on two other boxes had absolute paths to the old Node.
What pm2 really does on an upgrade
To check each of those behaviors on its own, lab/pm2-pin-lab.sh in the repo starts four throwaway apps in a separate PM2_HOME from a shell where Node v24.14.0 comes first in PATH: bare (pm2 start server.js), via-npm (pm2 start npm -- start, where the start script is node server.js), abs (--interpreter set to the old binary) and clustered (-i 1, cluster mode). Then it puts v24.22.1 first in PATH, as a changed nvm default does, and records the real binary of every process after each step. A plain pm2 restart left all four on v24.14.0. This is the state after pm2 restart <name> --update-env for each app:
pm2-pin-lab.sh, step B2
# excerpt of results/pm2-lab-20261022T063002Z.txt (2026-10-22T06:30:02Z, aarch64, pm2 6.0.14)
# old: v24.14.0, new: v24.22.1 first in PATH; a plain restart left all four apps on v24.14.0
$ lab/pm2-pin-lab.sh <v24.14.0 bin> <v24.22.1 bin>
$ pm2 restart bare --update-env
$ pm2 restart via-npm --update-env
$ pm2 restart abs --update-env
$ pm2 restart clustered --update-env
== B2. pm2 restart --update-env from the new shell
daemon pid 1937570 node v24.14.0 <v24.14.0>/bin/node
bare interpreter=node script=<app>/server.js
saved PATH finds node in <v24.22.1>/bin
pid 1938284 node v24.22.1 <v24.22.1>/bin/node server.js
via-npm interpreter=<v24.14.0>/bin/node script=<v24.14.0>/bin/npm
saved PATH finds node in <v24.22.1>/bin
pid 1938375 node v24.14.0 <v24.14.0>/bin/node npm start
pid 1938462 dash (not node) /usr/bin/dash sh -c node server.js
pid 1938468 node v24.22.1 <v24.22.1>/bin/node node server.js
abs interpreter=<v24.14.0>/bin/node script=<app>/server.js
saved PATH finds node in <v24.22.1>/bin
pid 1938455 node v24.14.0 <v24.14.0>/bin/node server.js
clustered interpreter=node script=<app>/server.js (cluster mode)
saved PATH finds node in <v24.22.1>/bin
pid 1938518 node v24.14.0 <v24.14.0>/bin/node server.js
bare moved. via-npm is split: npm itself still runs on the old Node because pm2 stored the old binary as its interpreter, while its child node server.js got the new one from PATH. abs did not move. clustered did not move either, even though its saved PATH now finds v24.22.1: in cluster mode the daemon forks the workers with its own Node binary, so they move only when the daemon moves. All four steps, from results/pm2-lab-20261022T063002Z.txt:
| App | After restart | After restart --update-env | After delete and recreate | After pm2 update |
| --- | --- | --- | --- | --- |
| bare (bare node) | old | new | not recreated: new | new |
| via-npm (npm path stored) | old | npm old, app new | new | new |
| abs (absolute interpreter) | old | old | new | new |
| clustered (cluster mode) | old | old | not recreated: old | new |
| pm2 daemon | old | old | old | new |
pm2 update restarted every app in one command: all four PIDs changed. On the production box that was the step where the sites went down. In the lab the command took 2.3 seconds; that measures the command, not how long the apps were unavailable, and real apps take as long as they take to start.
Upgrade steps: nvm, pm2 and systemd
Run these as the user that owns the pm2 daemon.
# 1. Install next to the old version and bring global packages (pm2) along.
nvm install 24.22.1 --reinstall-packages-from=24.14.0
nvm alias default 24.22.1
# 2. Work from a shell where the new Node comes first in PATH.
nvm use 24.22.1
command -v node && node --version
# 3. See what each pm2 entry is pinned to.
pm2 jlist | python3 -c 'import json, sys
for a in json.load(sys.stdin):
e = a["pm2_env"]
print(a["name"], e.get("exec_mode"), e.get("exec_interpreter"), e.get("pm_exec_path"), sep=" ")'
Then move each entry according to what step 3 shows:
# Fork mode, interpreter "node": pick up the new PATH.
pm2 restart api --update-env
# Interpreter is an absolute path to the old node, or the script is
# .../v24.14.0/bin/npm or npx: delete and start it again from this shell
# (or from your ecosystem file with the same name and arguments).
pm2 delete web
pm2 start npm --name web -- start
# Save the list that a reboot brings back.
pm2 save
# Regenerate the startup unit: the old one hard-codes the old PATH and pm2 path.
pm2 unstartup # prints a sudo command; run it
pm2 startup # prints a sudo command; run it
# Move the daemon, and every cluster-mode app with it.
# This restarts ALL apps at once: do it in a quiet window.
pm2 update
The pm2 docs ask for the unstartup and startup pair after every Node upgrade. The new unit is pinned again, this time to the new version. That is fine, as long as something checks it at the next upgrade.
Two details are easy to miss. pm2 save writes the saved list, including each entry's environment, so if you save before --update-env the reboot copy keeps the old PATH. And the daemon only reads the systemd unit when systemd starts it. If someone started pm2 by hand, the fixed unit applies at the next boot, not now.
Cron jobs and helper scripts
Cron runs with a minimal PATH, so jobs that need Node usually name it by absolute path, and that path names one install. The audit script below reads only your own crontab -l, so search the system crontabs and your scripts on every host too:
# Paths inside a versioned nvm, fnm, n, volta or asdf install
sudo grep -rnE '/(\.nvm/versions/node|node-versions|n/versions/node|\.volta/tools/image/node|installs/nodejs)/v?[0-9]+\.[0-9]+\.[0-9]+' \
/etc/crontab /etc/cron.d /var/spool/cron /etc/systemd/system /usr/local/bin ~/bin 2>/dev/null
Update the path in each match. A job that points at a version you later uninstall stops running, which at least fails loudly; one that points at the old version you kept for rollback keeps running vulnerable and says nothing.
Containers
A container keeps the Node it was built with. Rebuild with --pull so the builder fetches the new base image instead of a cached one, deploy by the new tag or digest, and check what is running:
docker build --pull -t registry.example.com/app:2026-10-22 .
docker ps --format '{{.ID}} {{.Names}}' | while read -r id name; do
printf '%-30s %s\n' "$name" "$(docker exec -e NODE_OPTIONS= "$id" node --version 2>&1)"
done
node --version inside a container reports the node on the container's PATH. If an image carries more than one Node, docker exec <id> readlink /proc/1/exe shows which binary the container's main process runs (when the image has readlink).
The repo's registry_scan.py read the Node version of 24 official node tags for amd64 and arm64 from registry metadata only (manifest and image config, no layers) at 06:23 UTC on 22 October. Every 22, 24 and 26 tag we sampled, and the latest, lts and alpine aliases, shipped the fixed version. How long they took:
Official node images: hours from the release to build and to push
| | Value | Series |
| --- | --- | --- |
| Debian-based amd64: built | 1 h | built |
| Debian-based amd64: pushed | 4.1 h | pushed |
| Debian-based arm64: built | 1 h | built |
| Debian-based arm64: pushed | 4.1 h | pushed |
| Alpine amd64: built | 12.4 h | built |
| Alpine amd64: pushed | 13.1 h | pushed |
| Alpine arm64: built | 13.5 h | built |
| Alpine arm64: pushed | 16.1 h | pushed |
_Reference: fixed binaries on nodejs.org/dist by 10:36 UTC on 21 Oct 2026. Built: the image config's created time. Pushed: Docker Hub's last_pushed time for that platform image, which is metadata, not proof of when a tag first served it. Latest value in each group of tags. Source: results/registry-20261022T062338Z-summary.txt._
If you rebuilt an Alpine-based image on the afternoon of 21 October, check it again: by this metadata the fixed Alpine images were built 12 to 13.5 hours after the release. The end-of-life tags will stay where they are: node:18 (18.20.8, built March 2025), node:20 (20.20.2, built April 2026) and node:25 (25.9.0, built April and May 2026).
Verify with /proc, not with names
pm2 renames its daemon to PM2 v6.0.14: God Daemon and Next.js renames its server to next-server, and on our build machine the plain node processes showed up as MainThread. Those names are what pgrep matches and what top shows. On that machine pgrep -c node returned 0 while five processes ran a node binary (results/pgrep-vs-proc-20261022T063334Z.txt). The kernel's link to the executable does not lie:
# Every process whose binary is node, with the binary it really runs (root sees all users)
for d in /proc/[0-9]*; do
exe=$(readlink "$d/exe" 2>/dev/null) || continue
case "${exe% (deleted)}" in */node|*/nodejs) ;; *) continue ;; esac
printf '%-8s %-70s %s\n' "${d#/proc/}" "$exe" "$(tr '\0' ' ' < "$d/cmdline" | cut -c1-50)"
done
# The version of one of them, read from the binary without running it.
# Official builds compile in their headers URL. Do not run another user's
# binary with --version as root: that runs their code as root.
sudo grep -a -o -m1 -E 'node-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?-headers\.tar\.gz' /proc/12345/exe
# node-v24.14.0-headers.tar.gz
# Its children, which have their own binaries
ps -o pid,args --forest -g "$(ps -o sid= -p 12345 | tr -d ' ')"
An exe that ends in (deleted) means the file was replaced on disk after the process started, so the process still runs the old code until it restarts. For more on that pattern, and on proving a runtime is patched when the version string can mislead, see PHP2Shell: How to Check That Every PHP You Run Is Actually Patched.
Run the audit script
The-DevOps-Daily/node-runtime-audit on GitHub
node_runtime_audit.py is one file of standard-library Python. Its sources are /proc, the output of pm2 jlist and crontab -l, systemd unit files, and node --version inside running containers; besides those it only runs systemctl show for pm2 units and docker or podman ps and inspect, and looks at file metadata. For every running process it reads the real binary through /proc/<pid>/exe and the version compiled into that file, which works for deleted binaries and for processes inside containers. By default it never runs a binary it finds on the host, and it does not open the Node binaries that pm2 entries, crontabs and units name: their version comes from a running process that uses the same file, or from the path as a hint, where a path that names an unfixed version counts as vulnerable and one that names a fixed version stays unknown. Files are pinned with O_PATH and checked before they are read, so a planted FIFO or device cannot stall it. Command lines and container names are printed with control characters escaped. Run it as the user that owns the Node processes; root is not needed.
This is the real output on the Raspberry Pi we build on, run as the user that owns the Node processes, with --redact, which hides user names and app names. By the time of this run, the fnm default, the crontab and the pm2 systemd unit had already been moved to v24.22.1:
node_runtime_audit.py on the Pi
# 2026-10-22T07:10:33Z Raspberry Pi (Debian 12, aarch64), Node via fnm, pm2 6.0.14, run as the user that owns the Node processes
$ python3 node_runtime_audit.py --redact
node-runtime-audit: Node.js security release of 2026-10-21, fixed releases v22.23.4, v24.22.1, v26.12.1
lines without a fix (end-of-life) count as vulnerable
note: covers the processes, crontab and pm2 of USER; run it as each user that owns Node processes
Running Node processes (real binary from /proc/<pid>/exe)
[vulnerable] pid 1883303 v24.14.0 USER /home/USER/.local/share/fnm/node-versions/v24.14.0/installation/bin/node
cmd: PM2 v6.0.14: God Daemon (/home/USER/.pm2)
parents: systemd(1)
why: v24.14.0 < v24.22.1
[vulnerable] pid 1883371 v24.14.0 USER /home/USER/.local/share/fnm/node-versions/v24.14.0/installation/bin/node
cmd: node /home/USER/projects/app-1/node_modules/.bin/next dev
parents: bun(1883370) <- node(1883303) <- systemd(1)
why: v24.14.0 < v24.22.1
[vulnerable] pid 1883390 v24.14.0 USER /home/USER/.local/share/fnm/node-versions/v24.14.0/installation/bin/node
cmd: next-server (v16.2.6)
parents: node(1883371) <- bun(1883370) <- node(1883303) <- systemd(1)
why: v24.14.0 < v24.22.1
[vulnerable] pid 1884634 v24.14.0 USER /home/USER/.local/share/fnm/node-versions/v24.14.0/installation/bin/node
cmd: node /home/USER/projects/app-1/.next/dev/build/postcss.js 38963
parents: node(1883390) <- node(1883371) <- bun(1883370) <- node(1883303)
why: v24.14.0 < v24.22.1
183 processes of other users not inspected: run the script as their owner
pm2 live list (pm2 jlist, PM2_HOME=/home/USER/.pm2)
[unknown] pm2 CLI /home/USER/.bun/bin/pm2 is writable by group or others (mode 777): not run, live list skipped
[vulnerable] daemon pid 1883303 v24.14.0 /home/USER/.local/share/fnm/node-versions/v24.14.0/installation/bin/node
Installed Node versions (version managers)
fnm in /home/USER/.local/share/fnm
installed: v24.14.0 (running), v24.14.1 (from path), v24.22.1 (from path)
default alias -> v24.22.1 (from path)
`node` on this shell's PATH -> v24.22.1 (from path) /home/USER/.local/share/fnm/node-versions/v24.22.1/installation/bin/node
for information only: these do not change the exit code
Crontab: absolute Node paths
[unknown] crontab -l (USER):33
/home/USER/.local/share/fnm/node-versions/v24.22.1/installation/bin/node -> v24.22.1 (from path) (pinned to one install)
why: from path: v24.22.1 (a hint, not proof: binaries outside /proc are not read)
[unknown] crontab -l (USER):53
/home/USER/.local/share/fnm/node-versions/v24.22.1/installation/bin/node -> v24.22.1 (from path) (pinned to one install)
why: from path: v24.22.1 (a hint, not proof: binaries outside /proc are not read)
systemd units: absolute Node paths
[unknown] /etc/systemd/system/pm2-USER.service:12
/home/USER/.local/share/fnm/node-versions/v24.22.1/installation/bin -> v24.22.1 (from path) (pinned to one install)
why: from path: v24.22.1 (a hint, not proof: binaries outside /proc are not read)
unit: pm2-USER.service ActiveState=inactive MainPID=0
note: the unit is not what runs pm2 now; its PATH applies at the next boot
Containers
no docker or podman CLI found
result: VULNERABLE: something runs or will start an unfixed Node (exit 1)
$ echo $?
1
The fnm default, the shell's node, both cron jobs and the systemd unit point at the v24.22.1 install. The script reports the cron and unit paths as unknown rather than patched, because no running process uses that binary and it does not open it. The four processes that run are v24.14.0. The pm2 daemon is not running under the systemd unit (ActiveState=inactive), so the unit we had fixed is not what runs it now. The one pm2 app is started by bun, and the Node processes under it run the app's node_modules/.bin/next script. A separate check (scripts/pm2-path-evidence.py, results/pm2-path-20261022T070504Z.txt) shows that the process the pm2 daemon started and the saved pm2 list both have the v24.14.0 install first on PATH, which is where those Node processes come from. If the app is resurrected from that saved list after a reboot, it gets the same PATH; we did not reboot to confirm. The fix here is pm2 restart <app> --update-env from a fixed shell, then pm2 save, then moving the daemon.
The run found one more thing: the pm2 CLI on that machine is world-writable (mode 777). The script will not run a CLI that someone else could have changed, so it skipped the live pm2 list and reported it as unknown. Check the permissions of your Node installs and global CLIs while you are in there.
The exit code is 1 when anything runs or will start a vulnerable Node, otherwise 2 when anything could not be checked, and 0 when it found nothing vulnerable or unknown within what it covers, including no Node at all. Unknown never counts as patched: a pre-release build, a distribution build linked to the system Brotli, a binary without an embedded version, a unit it could not read, a failed pm2 jlist, crontab -l or docker ps, a config path that only names a fixed version, or a cron path to a Node that no longer exists all give 2. It only sees what the user running it can see, so run it as each user that owns Node processes. To run it across hosts without copying it first:
for h in deploy@web1 deploy@web2 deploy@web3; do
ssh "$h" 'python3 - --json' < node_runtime_audit.py > "node-${h#*@}.json"
echo "$h exit $?"
done
The tests build fake hosts with the cases a version check gets wrong: a fake node that would leave a marker file if the script ever ran it, a binary replaced while it runs, a container binary, a Debian-style build, a pm2 entry whose config is fixed but whose process was never restarted, npm pinned to the old install while its child gets the new one, a cluster-mode app, an old binary in a directory named after a fixed version, end-of-life and pre-release versions, a cron job on a removed version, a shell wrapper that mentions a fixed version, units owned by other users, and FIFOs, devices, hard links and symlinks planted where a file is expected. One test starts a real process, deletes its binary and checks that the script still reads the running file.
Rollback
Keep the old version installed until every check passes. Rolling back is the same work in reverse: nvm alias default 24.14.0, then the same --update-env, delete-and-recreate, pm2 save, startup unit and pm2 update steps from a shell where the old version comes first. For containers, redeploy the previous image digest. A rollback puts the vulnerable version back, so treat it as a few hours to fix a regression, not as a resting state.
Once the audit shows nothing on the old version, nvm uninstall 24.14.0. Anything still pinned to it will then fail to start instead of quietly running the old code, and the audit script reports those paths as missing.
What we could not check
- The bugs themselves. The advisory gives no technical detail, and we did not test whether any code path reaches the Brotli flaw. The exposure section is based on which code calls the decoder.
- Scores. The CVE records were still reserved when we checked, so there is no CVSS score.
- Distribution packages. Debian and Ubuntu had not published anything for CVE-2026-111497 when we checked. Check your distribution's nodejs and brotli advisories.
- Containers on a real host. The Pi has no Docker, so the container checks are covered by the test fixtures only. The registry scan read real image metadata.
- The downtime number."Under a minute" comes from the production upgrade. The lab's pm2 update took 2.3 seconds because its apps do nothing.
- Bare node in cron jobs and units. The script checks absolute paths and PATH= lines; it does not resolve a bare node through the job's PATH. It reads only your own crontab -l, not the system crontabs or the saved pm2 list, and it does not follow EnvironmentFile=.
- Other platforms. The registry scan covers linux/amd64 and linux/arm64 only. The script needs Linux and /proc, and does not cover Kubernetes, stopped containers, or Node binaries renamed to something other than node (single executable applications, Electron).
Checklist
- <input disabled type="checkbox"> Every host: Node 22.23.4, 24.22.1 or 26.12.1 or later installed; Node 20, 25 and older replaced
- <input disabled type="checkbox"> Every pm2 entry: absolute interpreters and npm or npx script paths recreated, bare node entries restarted with --update-env, then pm2 save
- <input disabled type="checkbox"> pm2 startup unit regenerated (pm2 unstartup, pm2 startup) and the daemon moved with pm2 update in a quiet window
- <input disabled type="checkbox"> Cluster-mode apps checked after the daemon moved
- <input disabled type="checkbox"> Crontabs, systemd units and helper scripts searched for absolute paths to the old install
- <input disabled type="checkbox"> Images rebuilt with --pull (Alpine-based ones checked after the late rebuild) and redeployed by new tag or digest
- <input disabled type="checkbox"> Services that fetch user-supplied URLs or accept compressed bodies upgraded first; interim measures where they cannot be
- <input disabled type="checkbox"> Every Node process checked by /proc/<pid>/exe, not by name, including child processes
- <input disabled type="checkbox"> Node installs and global CLIs (pm2, npm) writable only by their owner
- <input disabled type="checkbox"> Audit script output saved per host, and the old version uninstalled once it is clean
Every fact in this post is listed with its source in the repo's SOURCES.md.
_Originally published at devops-daily.com._
5 reactions
Mentions
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. !Two paths lead to Node's Brotli decoder Two paths lead to the decoder. The responses your server receives, and the bodies it accepts. You wrote neither. 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. Dossier DevSecOps et IA Continuer sur ce sujet Voir le dossier complet, puis deux retours directement liés et le service associé. --- 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
Créer une API REST Node.js avec Express en 2026 : le tutoriel pas à pas L'essentiel en une minute Créer une API REST Node.js en 2026, c'est d'abord choisir les bonnes versions : Node.js 24 est la LTS la plus récente au 24 octobre 2026, et Express 5.3.0 est sortie le 9 octobre. La première version de cet article reposait sur Node.js 20, arrivé en fin de vie le 30 avril 2026 : tout le code a été réécrit. Ce tutoriel construit une petite API de gestion de tâches, de l'initialisation au déploiement. Il s'appuie sur la documentation de Node.js et d'Express, sur MDN pour HTTP et sur l'OWASP pour la sécurité, consultées le 24 octobre 2026. Le code suit ces documentations, mais nous ne l'avons pas exécuté. > Points clés > > - Visez Node.js 24 LTS aujourd'hui, ou Node.js 26 à partir du 28 octobre 2026, date à laquelle il doit passer en LTS selon le calendrier officiel. Express 5 exige Node.js 18 au minimum. > - Express 5 transmet automatiquement les erreurs des fonctions async au gestionnaire d'erreurs : plus besoin d'envelopper chaque route dans un try/catch. > - Le module node:sqlite, intégré à Node.js, suffit pour apprendre sans dépendance, mais il est encore au stade « release candidate » et synchrone : passez à PostgreSQL en production. > - Pour l'authentification, l'OWASP demande de refuser alg: none, de vérifier iss, aud et exp, et de hacher les mots de passe avec Argon2id, ou à défaut scrypt. > - Le lanceur de tests intégré à Node.js, stable depuis la version 20, et fetch suffisent pour tester une API REST Node.js sans framework de test. --- Les principes REST et les codes de statut REST n'est pas une norme mais un ensemble de contraintes d'architecture. MDN précise qu'un débutant peut voir une API REST comme un service HTTP appelable avec les outils web standard (glossaire MDN, REST, mis à jour le 11 juillet 2025). En pratique : chaque ressource a une URL (/api/v1/taches/42), le verbe HTTP dit ce qu'on en fait, le code de statut dit comment ça s'est passé. | Verbe | Usage dans notre API | Idempotent | Code en cas de succès | |---|---|---|---| | GET | Lire la liste ou une tâche | Oui | 200 | | POST | Créer une tâche | Non | 201 | | PUT | Remplacer une tâche entière | Oui | 200 | | PATCH | Modifier une partie d'une tâche | Non garanti | 200 | | DELETE | Supprimer une tâche | Oui | 204 | Selon MDN, les méthodes sûres, PUT et DELETE sont idempotentes, alors que POST et PATCH ne le sont pas forcément (glossaire MDN, Idempotent) : deux PUT identiques laissent la ressource dans le même état, deux POST créent deux tâches. Les codes de statut forment cinq classes : informations (1xx), succès (2xx), redirections (3xx), erreurs du client (4xx) et du serveur (5xx), selon la référence MDN des codes de statut. Voici ceux de notre API : | Code | Sens selon MDN | Quand notre API le renvoie | |---|---|---| | 201 Created | Une nouvelle ressource a été créée | Après un POST réussi | | 204 No Content | Pas de contenu à envoyer | Après une suppression | | 400 Bad Request | Requête incompréhensible, syntaxe invalide | JSON mal formé | | 401 Unauthorized | Le client doit s'authentifier | Jeton absent, invalide ou expiré | | 404 Not Found | Ressource introuvable | Tâche inexistante ou d'un autre utilisateur | | 409 Conflict | Conflit avec l'état du serveur | E-mail déjà inscrit | | 413 Content Too Large | Corps de requête plus grand que la limite du serveur | Corps de plus de 10 ko | | 415 Unsupported Media Type | Format de données non supporté | Corps absent ou qui n'est pas du JSON | | 422 Unprocessable Content | Requête bien formée mais erreurs sémantiques | Données refusées par la validation | | 429 Too Many Requests | Trop de requêtes en un temps donné | Trop de tentatives d'inscription ou de connexion | | 500 Internal Server Error | Situation que le serveur ne sait pas traiter | Bug ou panne imprévue | La fiche REST Security de l'OWASP tranche le piège classique : 401 quand l'authentification manque ou échoue, 403 quand elle a réussi mais que l'utilisateur n'a pas les droits. Pour la tâche d'un autre utilisateur, notre API répond 404, ce qui évite de confirmer qu'elle existe. --- Pourquoi une API REST Node.js avec Express en 2026 Node.js reste la technologie web la plus citée par les développeurs, et Express le framework serveur le plus utilisé de cet écosystème. Dans l'enquête Stack Overflow 2025, 48,7 % des 23 678 répondants à la question sur les technologies web déclarent avoir beaucoup travaillé avec Node.js sur l'année écoulée, et 19,9 % avec Express. NestJS est à 6,7 % et Fastify à 2,9 %. !Graphique en barres : usage de technologies serveur dans l'enquête Stack Overflow 2025, Node.js 48,7 %, Express 19,9 %, ASP.NET Core 19,7 %, FastAPI 14,8 %, Spring Boot 14,7 %, Flask 14,4 %, Django 12,6 %, NestJS 6,7 %, Fastify 2,9 % L'édition 2026, menée du 23 juin au 5 août 2026 selon sa méthodologie, ne publie pas de classement des frameworks web : ces chiffres de 2025 restent les plus récents de cette source. Elle confirme que JavaScript est le langage le plus utilisé, par 62,0 % des 14 099 répondants à la question (Stack Overflow 2026). Express n'est ni le plus rapide ni le plus structurant, mais sa simplicité en fait un bon outil pour apprendre : un routeur, des middlewares, rien d'imposé. Côté Python, l'équivalent serait Flask ou FastAPI, présentés dans notre guide pour apprendre Python en 2026. --- Étape 1 : installer Node.js et initialiser le projet Installez une version LTS : la page des versions de Node.js réserve la production aux versions LTS, actives ou en maintenance. | Composant | Version au 24 octobre 2026 | Statut | |---|---|---| | Node.js 24 « Krypton » | 24.22.1, publiée le 20 octobre 2026 | LTS, en maintenance depuis le 20 octobre, fin prévue le 30 avril 2028 | | Node.js 26 | 26.12.1, publiée le 21 octobre 2026 | Current, passage en LTS prévu le 28 octobre 2026 | | Node.js 22 « Jod » | 22.23.4 | LTS de maintenance, fin prévue le 30 avril 2027 | | Node.js 20 | 20.20.2 | Fin de vie | | Express 5 | 5.3.0, publiée le 9 octobre 2026 | Version par défaut sur npm, Node.js 18 minimum | Sources : le calendrier officiel des versions, les notes de version d'Express et sa page de support. À partir de Node.js 27, chaque version majeure deviendra LTS, sur un cycle annuel. Créez ensuite le projet : node --version mkdir api-taches && cd api-taches npm init -y npm install express zod jsonwebtoken helmet rate-limiter-flexible Au 24 octobre 2026, le registre npm sert Express 5.3.0, Zod 4.6.7, jsonwebtoken 9.0.3, Helmet 8.3.0 et rate-limiter-flexible 11.2.3. Complétez package.json ainsi : { "name": "api-taches", "version": "1.0.0", "type": "module", "engines": { "node": ">=24" }, "scripts": { "dev": "node --watch --env-file=.env src/server.js", "start": "node --env-file-if-exists=.env src/server.js", "test": "node --test" }, "dependencies": { "express": "^5.3.0", "helmet": "^8.3.0", "jsonwebtoken": "^9.0.3", "rate-limiter-flexible": "^11.2.3", "zod": "^4.6.7" } } Plus besoin de nodemon ni de dotenv : selon la documentation de la ligne de commande, --watch est stable depuis Node.js 22, et --env-file comme --env-file-if-exists ne sont plus expérimentaux depuis Node.js 24.10 (journal des modifications de la 24.10.0). Le script start charge .env s'il existe, sans échouer en production où les variables sont injectées par l'hébergeur. Créez enfin un fichier .env, exclu de Git : PORT=3000 DB_PATH=data.db JWT_SECRET=collez-ici-une-valeur-aleatoire Pour générer le secret, node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))" produit 256 bits aléatoires, la taille minimale qu'impose la RFC 7518, section 3.2 pour une clé HS256 ; sans cette valeur, server.js refuse de démarrer. Pour l'éditeur, voyez notre sélection d'extensions VS Code. --- Étape 2 : créer le serveur Express 5 L'application et son démarrage vivent dans deux fichiers, pour que les tests importent l'application sans ouvrir de port. L'arborescence finale : api-taches/ package.json .env src/ app.js application Express server.js démarrage db.js base SQLite schemas.js schémas Zod motdepasse.js hachage scrypt middleware/ validation, auth, limiteur, erreurs routes/ auth.js, taches.js test/ api.test.js Le fichier src/app.js assemble les middlewares et les routeurs : import express from 'express'; import helmet from 'helmet'; import { routerAuth } from './routes/auth.js'; import { routerTaches } from './routes/taches.js'; import { routeInconnue, gestionErreurs } from './middleware/erreurs.js'; export const app = express(); if (process.env.TRUST_PROXY) { app.set('trust proxy', Number(process.env.TRUST_PROXY)); } app.use(helmet()); app.use(express.json({ limit: '10kb' })); app.get('/health', (req, res) => { res.json({ statut: 'ok' }); }); app.use('/api/v1/auth', routerAuth); app.use('/api/v1/taches', routerTaches); app.use(routeInconnue); app.use(gestionErreurs); Helmet ajoute des en-têtes de sécurité et retire X-Powered-By, comme le recommande la page sécurité en production d'Express. express.json() limite le corps à 100 ko par défaut selon la référence de l'API Express 5 : 10 ko suffisent pour une tâche. Le préfixe /api/v1 permettra une version 2 sans casser les clients. Derrière un proxy inverse, TRUST_PROXY=1 indique à Express de lire l'adresse du client dans X-Forwarded-For à un saut de distance, comme le décrit la page Express derrière un proxy ; sans proxy, laissez la variable vide, sinon un client pourrait falsifier son adresse. Le fichier src/server.js démarre le serveur : import { app } from './app.js'; if (Buffer.byteLength(process.env.JWT_SECRET ?? '') < 32) { throw new Error('JWT_SECRET doit contenir au moins 32 octets (256 bits)'); } const port = Number(process.env.PORT ?? 3000); app.listen(port, (error) => { if (error) throw error; console.log(`API à l'écoute sur http://localhost:${port}`); }); Le paramètre error est une nouveauté d'Express 5 : selon le guide de migration, app.listen appelle ce rappel en cas d'erreur du serveur, comme un port déjà occupé. --- Étape 3 : stocker les données dans SQLite Node.js embarque SQLite depuis la version 22.5, dans le module node:sqlite, sans option à activer depuis les versions 22.13 et 23.4. La documentation de Node.js 24 le classe « release candidate » (stabilité 1.2) depuis la version 24.15, publiée le 15 avril 2026 (journal des modifications de la 24.15.0). Pour un tutoriel, c'est le plus simple : zéro dépendance, zéro serveur. import { DatabaseSync } from 'node:sqlite'; export const db = new DatabaseSync(process.env.DB_PATH ?? 'data.db'); db.exec(` CREATE TABLE IF NOT EXISTS utilisateurs ( id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE, hash TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS taches ( id INTEGER PRIMARY KEY, titre TEXT NOT NULL, terminee INTEGER NOT NULL DEFAULT 0, proprietaire INTEGER NOT NULL REFERENCES utilisateurs(id) ); `); Les clés étrangères sont actives par défaut, selon la même page. Toutes les requêtes passent par db.prepare() avec des paramètres ? : les valeurs sont liées à part du texte SQL, ce qui bloque l'injection SQL. Deux limites. Sous Node.js 26, la classe s'appelle Database et DatabaseSync devient un alias déprécié dans la documentation seulement (DEP0210). Surtout, l'API est synchrone : chaque requête bloque la boucle d'événements, alors que la page performances en production d'Express déconseille les fonctions synchrones. !Graphique en sucettes : bases de données candidates pour une API REST Node.js, les plus utilisées selon l'enquête Stack Overflow 2026, PostgreSQL 57,9 %, SQLite 42,1 %, MySQL 35,1 %, Redis 28,3 %, Microsoft SQL Server 27,1 %, MariaDB 20,3 %, MongoDB 19,0 % Pour une API REST Node.js en production avec du trafic concurrent, préférez PostgreSQL, la base la plus utilisée : 57,9 % des 12 061 répondants concernés de l'enquête Stack Overflow 2026, devant SQLite (42,1 %) et MySQL (35,1 %) ; MongoDB est à 19,0 %. Il faudra un pilote asynchrone et des await, mais les routes gardent la même forme. --- Étape 4 : écrire les routes et les verbes HTTP Les routes traduisent le tableau des verbes en code. Toutes passent par exigerAuth : chaque utilisateur ne voit que ses tâches. Les middlewares utilisés sont écrits aux étapes 5 et 7. Voici src/routes/taches.js : import { Router } from 'express'; import { db } from '../db.js'; import { exigerAuth } from '../middleware/auth.js'; import { exigerJson, valider } from '../middleware/validation.js'; import { schemaCreation, schemaRemplacement, schemaModification } from '../schemas.js'; export const routerTaches = Router(); routerTaches.use(exigerAuth); const versJson = (t) => ({ id: t.id, titre: t.titre, terminee: t.terminee === 1 }); const introuvable = (res) => res.status(404).json({ erreur: 'Tâche introuvable' }); function lireId(req) { const id = Number(req.params.id); return Number.isSafeInteger(id) && id > 0 ? id : null; } function trouver(id, proprietaire) { return db .prepare('SELECT id, titre, terminee FROM taches WHERE id = ? AND proprietaire = ?') .get(id, proprietaire); } routerTaches.get('/', (req, res) => { const taches = db .prepare('SELECT id, titre, terminee FROM taches WHERE proprietaire = ? ORDER BY id') .all(req.utilisateurId); res.json(taches.map(versJson)); }); routerTaches.get('/:id', (req, res) => { const id = lireId(req); const tache = id && trouver(id, req.utilisateurId); if (!tache) return introuvable(res); res.json(versJson(tache)); }); routerTaches.post('/', exigerJson, valider(schemaCreation), (req, res) => { const { titre, terminee = false } = req.donnees; const { lastInsertRowid } = db .prepare('INSERT INTO taches (titre, terminee, proprietaire) VALUES (?, ?, ?)') .run(titre, terminee ? 1 : 0, req.utilisateurId); const id = Number(lastInsertRowid); res.status(201).location(`${req.baseUrl}/${id}`).json({ id, titre, terminee }); }); function mettreAJour(req, res) { const id = lireId(req); const actuelle = id && trouver(id, req.utilisateurId); if (!actuelle) return introuvable(res); const tache = { ...versJson(actuelle), ...req.donnees }; db.prepare('UPDATE taches SET titre = ?, terminee = ? WHERE id = ?') .run(tache.titre, tache.terminee ? 1 : 0, id); res.json(tache); } routerTaches.put('/:id', exigerJson, valider(schemaRemplacement), mettreAJour); routerTaches.patch('/:id', exigerJson, valider(schemaModification), mettreAJour); routerTaches.delete('/:id', (req, res) => { const id = lireId(req); if (!id) return introuvable(res); const { changes } = db .prepare('DELETE FROM taches WHERE id = ? AND proprietaire = ?') .run(id, req.utilisateurId); if (Number(changes) === 0) return introuvable(res); res.sendStatus(204); }); Le guide de migration vers Express 5 retire res.json(objet, 201) au profit de res.status(201).json(objet), et res.send(204) au profit de res.sendStatus(204). L'en-tête Location du POST donne au client l'URL de la ressource créée. La syntaxe des chemins a aussi changé : un joker doit être nommé (/*splat au lieu de /*) et un paramètre optionnel s'écrit entre accolades. Pour migrer un projet Express 4, le guide propose npx codemod@latest @expressjs/v5-migration-recipe. --- Étape 5 : valider les données avec Zod Ne faites jamais confiance au corps d'une requête. La fiche REST de l'OWASP demande de valider longueur, plage, format et type de chaque entrée, et de refuser en 415 un Content-Type inattendu. Les schémas vont dans src/schemas.js : import * as z from 'zod'; const titre = z.string().trim().min(1).max(200); export const schemaIdentifiants = z.strictObject({ email: z.string().trim().toLowerCase().pipe(z.email()), motDePasse: z.string().min(15).max(128), }); export const schemaCreation = z.strictObject({ titre, terminee: z.boolean().optional() }); export const schemaRemplacement = z.strictObject({ titre, terminee: z.boolean() }); export const schemaModification = schemaRemplacement.partial(); z.strictObject refuse les clés inconnues : impossible d'injecter un champ proprietaire. L'e-mail est nettoyé et mis en minuscules avant d'être validé par z.email(), la syntaxe de Zod 4 (référence de l'API Zod), pour qu'Ada@Example.com et ada@example.com désignent le même compte. Le minimum de 15 caractères suit la fiche Authentication de l'OWASP et la norme NIST SP 800-63B, révision 4 (section 3.1.1.2) pour un mot de passe utilisé sans second facteur ; le maximum doit rester d'au moins 64 caractères. Le middleware src/middleware/validation.js range le résultat nettoyé dans req.donnees : import * as z from 'zod'; export function exigerJson(req, res, next) { if (!req.is('application/json')) { return res.status(415).json({ erreur: 'Le corps doit être en application/json' }); } next(); } export function valider(schema) { return (req, res, next) => { const resultat = schema.safeParse(req.body); if (!resultat.success) { return res.status(422).json({ erreur: 'Données invalides', details: z.flattenError(resultat.error), }); } req.donnees = resultat.data; next(); }; } safeParse() renvoie success puis data ou error sans lever d'exception. z.flattenError() produit deux listes, selon la page de Zod sur le formatage des erreurs : fieldErrors, champ par champ, et formErrors, pour les erreurs à la racine de l'objet. C'est là qu'arrive une clé inconnue refusée par z.strictObject, d'où l'envoi des deux listes au client. Le contrôle du Content-Type sert aussi Express 5 : req.body y vaut undefined quand le corps n'a pas été analysé, là où Express 4 renvoyait {}. Sans lui, une requête en text/plain recevrait un 422 trompeur. Une requête sans corps du tout reçoit elle aussi un 415, car req.is() renvoie null quand il n'y a pas de corps, selon la référence de l'objet requête : c'est voulu, ces routes exigent un objet JSON. --- Étape 6 : centraliser la gestion des erreurs Express 5 règle le principal défaut d'Express 4. Selon la page sur la gestion des erreurs, une route qui renvoie une promesse rejetée appelle automatiquement next(erreur) : une exception dans une fonction async atteint le gestionnaire sans try/catch. Ce gestionnaire prend quatre arguments et se déclare après les routes. Voici src/middleware/erreurs.js : const messages = { 400: 'Requête mal formée', 413: 'Corps de requête trop volumineux', 415: 'Encodage ou jeu de caractères non pris en charge', }; export function routeInconnue(req, res) { res.status(404).json({ erreur: 'Ressource introuvable' }); } export function gestionErreurs(err, req, res, next) { if (res.headersSent) return next(err); const statut = messages[err.status] ? err.status : 500; if (statut === 500) console.error(err); res.status(statut).json({ erreur: messages[statut] ?? 'Erreur interne du serveur' }); } Ces trois codes viennent du parseur JSON, dont la documentation attribue le statut 400 à un corps illisible, 413 à un corps trop gros et 415 à un encodage ou un jeu de caractères non pris en charge. Tout le reste devient un 500 générique, le détail restant dans les journaux du serveur : c'est la règle de l'OWASP, qui interdit d'envoyer au client une pile d'appels ou un indice technique. Si la réponse a déjà commencé (res.headersSent), Express recommande de déléguer à son gestionnaire par défaut. --- Étape 7 : ajouter l'authentification JWT L'API délivre à la connexion un jeton JWT signé, que le client renvoie dans l'en-tête Authorization: Bearer. Il faut d'abord stocker les mots de passe de façon sûre. Hacher les mots de passe avec scrypt La fiche de l'OWASP sur le stockage des mots de passe recommande Argon2id, puis scrypt avec un coût minimal de 2^17, une taille de bloc de 8 et un parallélisme de 1. Node.js fournit scrypt nativement. Voici src/motdepasse.js : import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto'; import { promisify } from 'node:util'; const scryptAsync = promisify(scrypt); const PARAMS = { N: 2 ** 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 }; const LONGUEUR = 64; export async function hacher(motDePasse) { const sel = randomBytes(16); const cle = await scryptAsync(motDePasse, sel, LONGUEUR, PARAMS); return `${sel.toString('hex')}:${cle.toString('hex')}`; } export async function verifier(motDePasse, stocke) { const [sel, attendu] = stocke.split(':').map((h) => Buffer.from(h, 'hex')); const cle = await scryptAsync(motDePasse, sel, LONGUEUR, PARAMS); return timingSafeEqual(cle, attendu); } maxmem n'est pas décoratif : la documentation du module crypto le fixe par défaut à 32 Mio et lève une erreur quand 128 × N × r le dépasse, soit 128 Mio avec les paramètres de l'OWASP. Elle recommande aussi un sel aléatoire d'au moins 16 octets. timingSafeEqual compare en temps constant. Ce coût est voulu, mais il se paie : chaque inscription ou connexion mobilise environ 128 Mio de mémoire. De plus, crypto.scrypt() s'exécute dans le pool de threads de libuv, limité à 4 threads par défaut selon la documentation de UV_THREADPOOL_SIZE, un pool partagé avec les accès disque. D'où la limitation du nombre de tentatives ci-dessous, et une variable UV_THREADPOOL_SIZE à augmenter au lancement du processus si les connexions sont nombreuses. Signer et vérifier les jetons Le middleware src/middleware/auth.js crée les jetons et protège les routes : import jwt from 'jsonwebtoken'; const optionsJwt = { issuer: 'api-taches', audience: 'api-taches' }; export function creerJeton(utilisateurId) { return jwt.sign({}, process.env.JWT_SECRET, { ...optionsJwt, algorithm: 'HS256', expiresIn: '15m', subject: String(utilisateurId), }); } export function exigerAuth(req, res, next) { const [type, jeton] = (req.get('Authorization') ?? '').split(' '); let charge; try { if (type !== 'Bearer' || !jeton) throw new Error('jeton absent'); charge = jwt.verify(jeton, process.env.JWT_SECRET, { ...optionsJwt, algorithms: ['HS256'] }); } catch { res.set('WWW-Authenticate', 'Bearer'); return res.status(401).json({ erreur: 'Authentification requise ou jeton expiré' }); } req.utilisateurId = Number(charge.sub); next(); } Chaque option répond à l'OWASP. La fiche REST Security interdit les jetons non signés (alg: none) et demande de vérifier iss, aud et exp. La fiche JSON Web Token met en garde contre les algorithmes non fixés et les secrets faibles, qu'un attaquant peut deviner hors ligne. La RFC 7518 (section 3.2) impose une clé d'au moins 256 bits pour HS256, ce que vérifie server.js au démarrage. D'où algorithms: ['HS256'], le secret aléatoire de 32 octets et une durée de vie courte (15 minutes) qui limite l'usage d'un jeton volé. Selon le README de jsonwebtoken, jwt.verify sans rappel renvoie la charge utile si signature, expiration, audience et émetteur sont valides, et lève une erreur sinon. Limiter les tentatives La page sécurité en production d'Express recommande de bloquer les tentatives d'authentification répétées, avec le paquet rate-limiter-flexible. Voici src/middleware/limiteur.js, qui accepte 5 tentatives par minute et par adresse IP : import { RateLimiterMemory } from 'rate-limiter-flexible'; const limiteur = new RateLimiterMemory({ points: 5, duration: 60 }); export async function limiterAuth(req, res, next) { try { await limiteur.consume(req.ip); } catch (refus) { if (refus instanceof Error) throw refus; res.set('Retry-After', String(Math.ceil(refus.msBeforeNext / 1000))); return res.status(429).json({ erreur: 'Trop de tentatives, réessayez plus tard' }); } next(); } Selon le README de rate-limiter-flexible, consume() rejette sa promesse avec un objet contenant msBeforeNext quand les points sont épuisés, du moins « s'il n'y a pas d'erreur » (notre traduction). Le code traite donc à part une éventuelle Error, renvoyée au gestionnaire d'erreurs, et utilise l'objet pour l'en-tête Retry-After du code 429. Derrière un proxy inverse, req.ip n'est la vraie adresse du client que si TRUST_PROXY est réglé (étape 2). Le compteur vit en mémoire : avec plusieurs instances, il faut un stockage partagé comme Redis, que le paquet prend en charge. Les routes d'inscription et de connexion Reste src/routes/auth.js : import { Router } from 'express'; import { db } from '../db.js'; import { hacher, verifier } from '../motdepasse.js'; import { creerJeton } from '../middleware/auth.js'; import { limiterAuth } from '../middleware/limiteur.js'; import { exigerJson, valider } from '../middleware/validation.js'; import { schemaIdentifiants } from '../schemas.js'; export const routerAuth = Router(); routerAuth.use(limiterAuth); routerAuth.post('/inscription', exigerJson, valider(schemaIdentifiants), async (req, res) => { const { email, motDePasse } = req.donnees; const hash = await hacher(motDePasse); // Aucun await entre la vérification et l'insertion : rien ne peut s'intercaler. if (db.prepare('SELECT id FROM utilisateurs WHERE email = ?').get(email)) { return res.status(409).json({ erreur: 'Un compte existe déjà pour cet e-mail' }); } const { lastInsertRowid } = db .prepare('INSERT INTO utilisateurs (email, hash) VALUES (?, ?)') .run(email, hash); res.status(201).json({ id: Number(lastInsertRowid), email }); }); routerAuth.post('/connexion', exigerJson, valider(schemaIdentifiants), async (req, res) => { const { email, motDePasse } = req.donnees; const utilisateur = db.prepare('SELECT id, hash FROM utilisateurs WHERE email = ?').get(email); if (!utilisateur || !(await verifier(motDePasse, utilisateur.hash))) { res.set('WWW-Authenticate', 'Bearer'); return res.status(401).json({ erreur: 'Identifiants invalides' }); } res.json({ jeton: creerJeton(utilisateur.id) }); }); L'ordre de l'inscription compte. Le hachage, asynchrone, a lieu avant la vérification de l'e-mail : la lecture et l'insertion s'enchaînent alors sans await, et comme node:sqlite est synchrone, deux inscriptions simultanées au même e-mail ne peuvent pas passer toutes deux la vérification dans un même processus. Avec plusieurs processus, la contrainte UNIQUE reste le dernier rempart et produirait un 500. Essayer l'API à la main Lancez npm run dev, puis enchaînez ces appels avec curl. Les réponses en commentaire sont celles que le code ci-dessus est censé produire ; nous ne les avons pas relevées en exécutant le code. curl -i -X POST http://localhost:3000/api/v1/auth/inscription \ -H "Content-Type: application/json" \ -d '{"email":"ada@example.com","motDePasse":"une phrase de passe longue"}' # Attendu : 201 Created, {"id":1,"email":"ada@example.com"} curl -s -X POST http://localhost:3000/api/v1/auth/connexion \ -H "Content-Type: application/json" \ -d '{"email":"ada@example.com","motDePasse":"une phrase de passe longue"}' # Attendu (corps) : {"jeton":"eyJ..."} curl -i -X POST http://localhost:3000/api/v1/taches \ -H "Content-Type: application/json" \ -H "Authorization: Bearer COLLEZ_LE_JETON_ICI" \ -d '{"titre":"Écrire les tests"}' # Attendu : 201 Created, en-tête Location: /api/v1/taches/1, # {"id":1,"titre":"Écrire les tests","terminee":false} Ce code a encore des limites assumées, à traiter avant la production : - La révocation. Un JWT reste valide jusqu'à son expiration. L'OWASP propose une liste de jetons révoqués pour la déconnexion anticipée. - Le blocage par compte. Express conseille de compter aussi les échecs par nom d'utilisateur, pas seulement par adresse IP ; notre limiteur ne le fait pas. - L'énumération des comptes. Le 409 de l'inscription et le temps de réponse de la connexion révèlent si un e-mail est inscrit. - Le navigateur. Pour un front-end web, une session en cookie httpOnly et secure évite d'exposer le jeton au JavaScript. Pour vos utilisateurs finaux, les passkeys sont une alternative sérieuse aux mots de passe. Pour le contexte des menaces, voyez notre panorama des cyberattaques les plus fréquentes. --- Étape 8 : tester l'API avec le lanceur de Node.js Le lanceur de tests node:test est stable depuis Node.js 20 selon sa documentation. Avec fetch, intégré lui aussi, il suffit pour des tests d'intégration sans Jest ni Supertest. Voici test/api.test.js : import { test, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { once } from 'node:events'; process.env.DB_PATH = ':memory:'; process.env.JWT_SECRET = 'cle-de-test-uniquement-0123456789abcdef0123456789'; const { app } = await import('../src/app.js'); let serveur; let base; before(async () => { serveur = app.listen(0); await once(serveur, 'listening'); base = `http://127.0.0.1:${serveur.address().port}/api/v1`; }); after(() => serveur.close()); function envoyer(chemin, corps, jeton) { const headers = { 'Content-Type': 'application/json' }; if (jeton) headers.Authorization = `Bearer ${jeton}`; return fetch(`${base}${chemin}`, { method: 'POST', headers, body: JSON.stringify(corps) }); } test('refuse la liste des tâches sans jeton', async () => { const reponse = await fetch(`${base}/taches`); assert.equal(reponse.status, 401); }); test('inscrit, connecte puis crée une tâche', async () => { const identifiants = { email: 'ada@example.com', motDePasse: 'une phrase de passe longue' }; assert.equal((await envoyer('/auth/inscription', identifiants)).status, 201); const { jeton } = await (await envoyer('/auth/connexion', identifiants)).json(); const creation = await envoyer('/taches', { titre: 'Écrire les tests' }, jeton); assert.equal(creation.status, 201); assert.deepEqual(await creation.json(), { id: 1, titre: 'Écrire les tests', terminee: false }); const invalide = await envoyer('/taches', { titre: '' }, jeton); assert.equal(invalide.status, 422); }); L'import dynamique de app.js est volontaire : les import statiques s'exécutent avant le reste du module, donc avant que DB_PATH ne désigne une base en mémoire. app.listen(0) choisit un port libre. Lancez le tout avec npm test. Postman ou Insomnia restent utiles pour explorer l'API à la main, mais ne remplacent pas des tests rejoués à chaque modification. --- Étape 9 : préparer le déploiement Une API REST Node.js prête pour la production repose sur quelques réglages, détaillés par la page performances en production d'Express : - NODE_ENV=production. Les messages d'erreur deviennent moins bavards. La page affirme que ce seul réglage triplerait les performances, une mesure de l'éditeur. - Un redémarrage automatique. Un gestionnaire de processus ou un service systemd relance l'application. - Un proxy inverse. Nginx ou HAProxy gère TLS, la compression et la répartition de charge. - Des journaux adaptés. console.log est synchrone vers un terminal ou un fichier : la page conseille une bibliothèque comme Pino. Le HTTPS est obligatoire : l'OWASP exige que les services REST n'exposent que des points d'accès HTTPS, pour protéger mots de passe et jetons. Lancez aussi npm audit régulièrement, comme le rappelle la page sécurité d'Express. Pour l'hébergement, le conteneur est la voie la plus portable : voyez notre guide Docker pour débutants, puis notre comparatif de l'hébergement cloud pour développeurs. Automatisez ensuite tests et mises en ligne avec notre guide pour automatiser ses déploiements en CI/CD. Attention, la base SQLite est un fichier : sur une plateforme dont le disque est effacé à chaque déploiement, vos données disparaissent. Vérifiez la persistance du disque ou passez à PostgreSQL. Et sur plusieurs instances, les processus ne partagent pas leur mémoire, rappelle Express : voyez alors notre introduction à Kubernetes. --- Ce que nous n'avons pas pu vérifier Ce tutoriel est une synthèse documentaire. Restent incertains : - L'exécution du code. Il suit la documentation officielle de chaque outil au 24 octobre 2026, mais nous ne l'avons ni exécuté ni testé. Signalez-nous toute erreur. - Le passage de Node.js 26 en LTS. Le 28 octobre 2026 est la date du calendrier officiel ; elle peut glisser. - Le détail d'Express 5.3.0. Nous avons relevé la version et sa date, pas l'ensemble de ses changements. - Le facteur trois de NODE_ENV=production. C'est une affirmation d'Express, sans protocole publié sur la page. - Les réponses attendues des appels curl. Elles sont déduites du code, pas relevées lors d'une exécution. - Les réglages du limiteur. Cinq tentatives par minute est un exemple : ni Express ni l'OWASP ne fixent de seuil, et nous n'avons pas mesuré l'effet de scrypt sur un serveur réel. - Des chiffres d'usage d'Express plus récents. L'enquête Stack Overflow 2026 ne classe pas les frameworks web. - Les offres gratuites des hébergeurs. La version précédente en citait ; non revérifiées, elles ont été retirées. --- Questions fréquentes Quelle version de Node.js utiliser pour une API REST en 2026 ? Une version LTS. Au 24 octobre 2026, c'est Node.js 24, en version 24.22.1, selon la page des versions de Node.js. Node.js 26 doit devenir LTS le 28 octobre 2026. Node.js 20 est en fin de vie : ne démarrez pas de projet dessus. Faut-il passer d'Express 4 à Express 5 ? Pour un nouveau projet, oui : Express 5 est la version servie par défaut sur npm et gère seule les erreurs des fonctions async. Pour un projet existant, le guide de migration liste les ruptures, surtout la syntaxe des chemins, et propose un outil de migration automatique. Quelle différence entre REST et GraphQL ? Une API REST expose une URL par ressource et s'appuie sur les verbes et codes de statut HTTP (voir le glossaire de MDN). GraphQL passe en général par un point d'accès unique où le client décrit les données voulues. REST reste plus simple pour débuter. Faut-il utiliser TypeScript pour une API Express ? Ce n'est pas obligatoire pour apprendre, mais utile dès que le projet grandit. TypeScript est utilisé par 43,8 % des répondants de l'enquête Stack Overflow 2026. Avec Zod, vous pouvez déjà dériver les types de vos schémas, ce qui facilite le passage. --- À 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. Synthèse documentaire fondée exclusivement sur des sources publiques consultées le 24 octobre 2026 : documentation de Node.js (versions 24 et 26) et son calendrier de versions, documentation et notes de version d'Express, documentations de Zod, jsonwebtoken, Helmet et body-parser, registre npm, fiches de l'OWASP, références HTTP de MDN, enquêtes Stack Overflow 2025 et 2026. Le code n'a pas été exécuté pour cet article. Aucun lien n'est affilié et aucune relation commerciale ne lie l'auteur aux éditeurs mentionnés. Une erreur ou une commande périmée ? Écrivez à contact@neurautech.com : l'article sera corrigé et la date de mise à jour modifiée. Dernière mise à jour : 24 octobre 2026. Première publication : 25 mai 2026. Image : Markus Spiske (Unsplash License).
【要対応】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 リリーススケジュール
Node.js October 2026 Security Release: Finding Every Node You Actually Run --- title: Node.js October 2026 Security Release: Finding Every Node You Actually Run published: true date: 2026-10-22 09:00:00 UTC tags: Linux,Nodejs,Security,pm2 canonical_url: devops-daily.com --- On 21 October 2026 the Node.js project released 22.23.4, 24.22.1 and 26.12.1. The headline fix is CVE-2026-111497, an out-of-bounds write in the Brotli decoder that the advisory says "permits remote code execution". The same release fixes two TLS certificate hostname-check bypasses, a DNS bug that truncates names at a NUL character, and updates the bundled CA store. End-of-life lines get no fix. Patching Node is a one-line version bump. Finding every Node your servers actually run is the work. When we patched a small production box on release day, six Node apps under pm2 installed with nvm, installing the new version was the easy part. Then we found pm2 entries with the old binary written into them, a systemd unit with the old PATH hard-coded, child processes we had not thought about, and cron jobs on two other boxes that pointed at the old install by absolute path. None of them moved when the default version changed. This post covers what to do now, who is exposed (including services that call fetch() on a URL a user controls), the upgrade steps for nvm, pm2, systemd and containers with the traps we hit, how to verify with /proc, and a read-only audit script with its real output from one of our machines. What to do now 1. Upgrade every Node to 22.23.4, 24.22.1 or 26.12.1 or later. Node 20, 25 and older are end-of-life and get no fixed release. Move them to 22, 24 or 26. 2. List every place Node runs or will start : running processes, pm2 entries (live and saved), the pm2 daemon, systemd units, crontabs and helper scripts, and containers. Changing the nvm default moves none of them. 3. Move each one with the steps below, then check the real binary of every process in /proc. Process names do not help: on our build machine pgrep node found 0 of 5 Node processes. 4. Rebuild images with --pull and redeploy. The official node:22, node:24 and node:26 tags have the fix. node:20, node:25 and node:18 will not get it. 5. If a service cannot be upgraded today and accepts Brotli data from strangers, close that path in front of it (details below). For outbound fetch() the fix is the upgrade; until then, turning off features that fetch user-supplied URLs is the way to close that path. 6. Run the audit script on every host and keep the output. Prerequisites - Shell access to the hosts that run Node, as the user that owns the Node processes. Root is not needed. - Python 3.8 or newer for the audit script. It uses only the standard library and only reads. - The docker or podman CLI on hosts that run Node in containers. What was fixed The advisory lists ten vulnerabilities. The four rated high: | CVE | What the advisory calls it | Lines | | --- | --- | --- | | CVE-2026-111497 | Brotli decoder out-of-bounds write permits remote code execution | 22, 24, 26 | | CVE-2026-105483 | TLS certificate hostname bypass when the IP is written in fullwidth digits | 24, 26 | | CVE-2026-106069 | TLS certificate hostname bypass when the host loosely resolves to an IP address | 22, 24, 26 | | CVE-2026-105482 | dns.resolve truncates names containing a NUL character | 22, 24, 26 | The medium ones are a keep-alive socket that processes a response written before the request was sent (CVE-2026-105481), DNS SANs that can authenticate IP endpoints written in a non-canonical IPv4 form (CVE-2026-106072), and crypto.randomUUID() and randomInt() caches saved into startup snapshots and single executable applications, so that every process repeats the same values (CVE-2026-106074). The low ones are two Permission Model bypasses and a ZIP header issue on 26.x. The release also updates the bundled root certificates to NSS 3.131.1 and makes Node reject certificate chains that contain blocked public keys. The advisory lists this as the "ca-cert (eMudhra Intermediate CA Compromise)" update. The bundled undici, c-ares and OpenSSL (22 and 24) are updated too. Two things the advisory does not give you yet. There is no technical detail on any of the bugs, and on the morning of 22 October all ten CVE IDs were still in the RESERVED state at CVE.org, with no NVD record. So there is no CVSS score; "high" is the Node.js project's rating. The timeline in UTC, from the release metadata: - 21 Oct, 10:32 to 10:36 : the fixed binaries on nodejs.org/dist are dated in this window. The GitHub releases were published at 10:38. - 10:48 : the update to the official Docker images was committed. - about 11:35 : the Debian-based official images were built. Docker Hub shows the amd64 and arm64 images as pushed at about 14:40. - 19:27 : the Brotli project released 1.2.1, 1.1.1 and 1.0.10, each with "CVE-2026-111497: out-of-bounds write permits remote code execution" in the notes. So the bug is in the Brotli decoder itself, and Node shipped the upstream fix as a cherry-picked commit. - 22:58 to 00:08 : the Alpine-based official images were built, about 12 to 13.5 hours after the release. Who is exposed Brotli: any Node process that decodes Brotli someone else chose The decoder is compiled into the node binary and sits behind node:zlib. Every way into it counts, and three are common: 1. Your code decompresses Brotli directly : zlib.brotliDecompress(), brotliDecompressSync() or createBrotliDecompress() on uploads, archives, cache entries or messages. 2. Your service fetches URLs. fetch() in Node is the bundled undici, and it decodes responses that say Content-Encoding: br with zlib.createBrotliDecompress. It goes by the response's header, whatever the request asked for, so setting Accept-Encoding: identity does not keep Brotli away from a hostile server. axios in Node does the same unless you pass decompress: false. Link previews, webhook testers, feed readers, "import from URL" features and AI agents that browse are all services that fetch a URL a user chose. 3. Your service accepts compressed request bodies. body-parser 2 decompresses br request bodies that it parses, and its inflate option defaults to true. Express 5 ships body-parser 2, so express.json() on Express 5 decodes Brotli bodies sent with a JSON content type, from any client. Express 4 uses body-parser 1.x, which handles only gzip and deflate. Reaching the decoder is not the same as being exploitable, and the advisory does not say what input triggers the bug. These are the paths to upgrade or close first. Because the decoder is in the binary, no npm update fixes it. Upgrading the undici package in your package.json, for example, still decodes through the old Node's zlib. If you cannot upgrade a service today, the interim options close only the paths you control: // Express 5: refuse compressed request bodies (415) until Node is upgraded. // This also refuses gzip and deflate request bodies. app.use(express.json({ inflate: false })); # In front of any Node service: refuse Brotli request bodies. if ($http_content_encoding ~* "br") { return 415; } Distribution builds are different. Debian's nodejs package (we checked the trixie build of 20.19.2) is built with --shared-brotli against the system libbrotli, so on Debian the Brotli fix comes from the distribution's brotli package, not from the Node version number. At the time we checked, neither Debian's security tracker nor Ubuntu's notices listed CVE-2026-111497 yet. The audit script reports such builds as unknown rather than guessing. TLS hostname checks and DNS When Node connects over TLS it checks the server's certificate against the host you asked for. Both high-severity TLS bugs are in that check when the host is, or loosely resolves to, an IP address written in a non-standard form: fullwidth digits (24 and 26 only) or a form that "loosely resolves to an IP address" (all lines). The fix makes Node reject non-canonical IP addresses. The advisory gives no more detail than that. Our reading is that the services to look at first are the ones where someone else chooses the host string: webhook senders, URL fetchers and integrations that connect to customer endpoints. The DNS bug truncated names at a NUL byte in dns.resolve*(), and the fix rejects such input. Code that resolves names it got from users is where this matters. End-of-life lines "End-of-Life versions are always affected when a security release occurs," says the advisory. Node 20 reached end of life on 30 April 2026 and Node 25 on 1 June 2026. Neither gets a fixed release, and the official node:20 and node:25 image tags stay on 20.20.2 and 25.9.0. Why changing the default moves nothing nvm installs the new version next to the old one, which is what you want: the old one stays for rollback. nvm alias default 24.22.1 then changes what new shells get. Nothing that is already running reads that alias, and nothing that stored a path to the old install reads it either. 1. nvm install 24.22.1 next to the old version 2. nvm alias default new shells get it 3. What runs the new Node? check /proc, not config Outcomes: - New shells, and pm2 apps restarted with --update-env from one - pm2 daemon, cluster workers, absolute interpreters, systemd unit, cron, images: still old On the production box, each of these needed its own step: - pm2 entries with an absolute interpreter. Several entries had the old Node binary written into them as the interpreter, and the ones started as npm or npx had their script path under the old version's bin directory. Changing the default did not move them. They had to be deleted and recreated with the new interpreter. - pm2 entries with a bare node interpreter took the new binary only after pm2 restart <name> --update-env from a shell where the new Node came first in PATH. - The pm2 systemd unit that pm2 startup wrote had the old version's PATH and the old pm2 path hard-coded. After editing it, pm2 update moved the pm2 daemon itself, which restarts every app at once. The sites on that box were down for under a minute while the Next.js apps started. - Child processes. Checking /proc/<pid>/exe for every process turned up chains such as npx to tsx to node, and npm start to next-server. Each process in a chain has its own binary. - Cron jobs and helper scripts on two other boxes had absolute paths to the old Node. What pm2 really does on an upgrade To check each of those behaviors on its own, lab/pm2-pin-lab.sh in the repo starts four throwaway apps in a separate PM2_HOME from a shell where Node v24.14.0 comes first in PATH: bare (pm2 start server.js), via-npm (pm2 start npm -- start, where the start script is node server.js), abs (--interpreter set to the old binary) and clustered (-i 1, cluster mode). Then it puts v24.22.1 first in PATH, as a changed nvm default does, and records the real binary of every process after each step. A plain pm2 restart left all four on v24.14.0. This is the state after pm2 restart <name> --update-env for each app: pm2-pin-lab.sh, step B2 # excerpt of results/pm2-lab-20261022T063002Z.txt (2026-10-22T06:30:02Z, aarch64, pm2 6.0.14) # old: v24.14.0, new: v24.22.1 first in PATH; a plain restart left all four apps on v24.14.0 $ lab/pm2-pin-lab.sh <v24.14.0 bin> <v24.22.1 bin> $ pm2 restart bare --update-env $ pm2 restart via-npm --update-env $ pm2 restart abs --update-env $ pm2 restart clustered --update-env == B2. pm2 restart --update-env from the new shell daemon pid 1937570 node v24.14.0 <v24.14.0>/bin/node bare interpreter=node script=<app>/server.js saved PATH finds node in <v24.22.1>/bin pid 1938284 node v24.22.1 <v24.22.1>/bin/node server.js via-npm interpreter=<v24.14.0>/bin/node script=<v24.14.0>/bin/npm saved PATH finds node in <v24.22.1>/bin pid 1938375 node v24.14.0 <v24.14.0>/bin/node npm start pid 1938462 dash (not node) /usr/bin/dash sh -c node server.js pid 1938468 node v24.22.1 <v24.22.1>/bin/node node server.js abs interpreter=<v24.14.0>/bin/node script=<app>/server.js saved PATH finds node in <v24.22.1>/bin pid 1938455 node v24.14.0 <v24.14.0>/bin/node server.js clustered interpreter=node script=<app>/server.js (cluster mode) saved PATH finds node in <v24.22.1>/bin pid 1938518 node v24.14.0 <v24.14.0>/bin/node server.js bare moved. via-npm is split: npm itself still runs on the old Node because pm2 stored the old binary as its interpreter, while its child node server.js got the new one from PATH. abs did not move. clustered did not move either, even though its saved PATH now finds v24.22.1: in cluster mode the daemon forks the workers with its own Node binary, so they move only when the daemon moves. All four steps, from results/pm2-lab-20261022T063002Z.txt: | App | After restart | After restart --update-env | After delete and recreate | After pm2 update | | --- | --- | --- | --- | --- | | bare (bare node) | old | new | not recreated: new | new | | via-npm (npm path stored) | old | npm old, app new | new | new | | abs (absolute interpreter) | old | old | new | new | | clustered (cluster mode) | old | old | not recreated: old | new | | pm2 daemon | old | old | old | new | pm2 update restarted every app in one command: all four PIDs changed. On the production box that was the step where the sites went down. In the lab the command took 2.3 seconds; that measures the command, not how long the apps were unavailable, and real apps take as long as they take to start. Upgrade steps: nvm, pm2 and systemd Run these as the user that owns the pm2 daemon. # 1. Install next to the old version and bring global packages (pm2) along. nvm install 24.22.1 --reinstall-packages-from=24.14.0 nvm alias default 24.22.1 # 2. Work from a shell where the new Node comes first in PATH. nvm use 24.22.1 command -v node && node --version # 3. See what each pm2 entry is pinned to. pm2 jlist | python3 -c 'import json, sys for a in json.load(sys.stdin): e = a["pm2_env"] print(a["name"], e.get("exec_mode"), e.get("exec_interpreter"), e.get("pm_exec_path"), sep=" ")' Then move each entry according to what step 3 shows: # Fork mode, interpreter "node": pick up the new PATH. pm2 restart api --update-env # Interpreter is an absolute path to the old node, or the script is # .../v24.14.0/bin/npm or npx: delete and start it again from this shell # (or from your ecosystem file with the same name and arguments). pm2 delete web pm2 start npm --name web -- start # Save the list that a reboot brings back. pm2 save # Regenerate the startup unit: the old one hard-codes the old PATH and pm2 path. pm2 unstartup # prints a sudo command; run it pm2 startup # prints a sudo command; run it # Move the daemon, and every cluster-mode app with it. # This restarts ALL apps at once: do it in a quiet window. pm2 update The pm2 docs ask for the unstartup and startup pair after every Node upgrade. The new unit is pinned again, this time to the new version. That is fine, as long as something checks it at the next upgrade. Two details are easy to miss. pm2 save writes the saved list, including each entry's environment, so if you save before --update-env the reboot copy keeps the old PATH. And the daemon only reads the systemd unit when systemd starts it. If someone started pm2 by hand, the fixed unit applies at the next boot, not now. Cron jobs and helper scripts Cron runs with a minimal PATH, so jobs that need Node usually name it by absolute path, and that path names one install. The audit script below reads only your own crontab -l, so search the system crontabs and your scripts on every host too: # Paths inside a versioned nvm, fnm, n, volta or asdf install sudo grep -rnE '/(\.nvm/versions/node|node-versions|n/versions/node|\.volta/tools/image/node|installs/nodejs)/v?[0-9]+\.[0-9]+\.[0-9]+' \ /etc/crontab /etc/cron.d /var/spool/cron /etc/systemd/system /usr/local/bin ~/bin 2>/dev/null Update the path in each match. A job that points at a version you later uninstall stops running, which at least fails loudly; one that points at the old version you kept for rollback keeps running vulnerable and says nothing. Containers A container keeps the Node it was built with. Rebuild with --pull so the builder fetches the new base image instead of a cached one, deploy by the new tag or digest, and check what is running: docker build --pull -t registry.example.com/app:2026-10-22 . docker ps --format '{{.ID}} {{.Names}}' | while read -r id name; do printf '%-30s %s\n' "$name" "$(docker exec -e NODE_OPTIONS= "$id" node --version 2>&1)" done node --version inside a container reports the node on the container's PATH. If an image carries more than one Node, docker exec <id> readlink /proc/1/exe shows which binary the container's main process runs (when the image has readlink). The repo's registry_scan.py read the Node version of 24 official node tags for amd64 and arm64 from registry metadata only (manifest and image config, no layers) at 06:23 UTC on 22 October. Every 22, 24 and 26 tag we sampled, and the latest, lts and alpine aliases, shipped the fixed version. How long they took: Official node images: hours from the release to build and to push | | Value | Series | | --- | --- | --- | | Debian-based amd64: built | 1 h | built | | Debian-based amd64: pushed | 4.1 h | pushed | | Debian-based arm64: built | 1 h | built | | Debian-based arm64: pushed | 4.1 h | pushed | | Alpine amd64: built | 12.4 h | built | | Alpine amd64: pushed | 13.1 h | pushed | | Alpine arm64: built | 13.5 h | built | | Alpine arm64: pushed | 16.1 h | pushed | _Reference: fixed binaries on nodejs.org/dist by 10:36 UTC on 21 Oct 2026. Built: the image config's created time. Pushed: Docker Hub's last_pushed time for that platform image, which is metadata, not proof of when a tag first served it. Latest value in each group of tags. Source: results/registry-20261022T062338Z-summary.txt._ If you rebuilt an Alpine-based image on the afternoon of 21 October, check it again: by this metadata the fixed Alpine images were built 12 to 13.5 hours after the release. The end-of-life tags will stay where they are: node:18 (18.20.8, built March 2025), node:20 (20.20.2, built April 2026) and node:25 (25.9.0, built April and May 2026). Verify with /proc, not with names pm2 renames its daemon to PM2 v6.0.14: God Daemon and Next.js renames its server to next-server, and on our build machine the plain node processes showed up as MainThread. Those names are what pgrep matches and what top shows. On that machine pgrep -c node returned 0 while five processes ran a node binary (results/pgrep-vs-proc-20261022T063334Z.txt). The kernel's link to the executable does not lie: # Every process whose binary is node, with the binary it really runs (root sees all users) for d in /proc/[0-9]*; do exe=$(readlink "$d/exe" 2>/dev/null) || continue case "${exe% (deleted)}" in */node|*/nodejs) ;; *) continue ;; esac printf '%-8s %-70s %s\n' "${d#/proc/}" "$exe" "$(tr '\0' ' ' < "$d/cmdline" | cut -c1-50)" done # The version of one of them, read from the binary without running it. # Official builds compile in their headers URL. Do not run another user's # binary with --version as root: that runs their code as root. sudo grep -a -o -m1 -E 'node-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?-headers\.tar\.gz' /proc/12345/exe # node-v24.14.0-headers.tar.gz # Its children, which have their own binaries ps -o pid,args --forest -g "$(ps -o sid= -p 12345 | tr -d ' ')" An exe that ends in (deleted) means the file was replaced on disk after the process started, so the process still runs the old code until it restarts. For more on that pattern, and on proving a runtime is patched when the version string can mislead, see PHP2Shell: How to Check That Every PHP You Run Is Actually Patched. Run the audit script The-DevOps-Daily/node-runtime-audit on GitHub node_runtime_audit.py is one file of standard-library Python. Its sources are /proc, the output of pm2 jlist and crontab -l, systemd unit files, and node --version inside running containers; besides those it only runs systemctl show for pm2 units and docker or podman ps and inspect, and looks at file metadata. For every running process it reads the real binary through /proc/<pid>/exe and the version compiled into that file, which works for deleted binaries and for processes inside containers. By default it never runs a binary it finds on the host, and it does not open the Node binaries that pm2 entries, crontabs and units name: their version comes from a running process that uses the same file, or from the path as a hint, where a path that names an unfixed version counts as vulnerable and one that names a fixed version stays unknown. Files are pinned with O_PATH and checked before they are read, so a planted FIFO or device cannot stall it. Command lines and container names are printed with control characters escaped. Run it as the user that owns the Node processes; root is not needed. This is the real output on the Raspberry Pi we build on, run as the user that owns the Node processes, with --redact, which hides user names and app names. By the time of this run, the fnm default, the crontab and the pm2 systemd unit had already been moved to v24.22.1: node_runtime_audit.py on the Pi # 2026-10-22T07:10:33Z Raspberry Pi (Debian 12, aarch64), Node via fnm, pm2 6.0.14, run as the user that owns the Node processes $ python3 node_runtime_audit.py --redact node-runtime-audit: Node.js security release of 2026-10-21, fixed releases v22.23.4, v24.22.1, v26.12.1 lines without a fix (end-of-life) count as vulnerable note: covers the processes, crontab and pm2 of USER; run it as each user that owns Node processes Running Node processes (real binary from /proc/<pid>/exe) [vulnerable] pid 1883303 v24.14.0 USER /home/USER/.local/share/fnm/node-versions/v24.14.0/installation/bin/node cmd: PM2 v6.0.14: God Daemon (/home/USER/.pm2) parents: systemd(1) why: v24.14.0 < v24.22.1 [vulnerable] pid 1883371 v24.14.0 USER /home/USER/.local/share/fnm/node-versions/v24.14.0/installation/bin/node cmd: node /home/USER/projects/app-1/node_modules/.bin/next dev parents: bun(1883370) <- node(1883303) <- systemd(1) why: v24.14.0 < v24.22.1 [vulnerable] pid 1883390 v24.14.0 USER /home/USER/.local/share/fnm/node-versions/v24.14.0/installation/bin/node cmd: next-server (v16.2.6) parents: node(1883371) <- bun(1883370) <- node(1883303) <- systemd(1) why: v24.14.0 < v24.22.1 [vulnerable] pid 1884634 v24.14.0 USER /home/USER/.local/share/fnm/node-versions/v24.14.0/installation/bin/node cmd: node /home/USER/projects/app-1/.next/dev/build/postcss.js 38963 parents: node(1883390) <- node(1883371) <- bun(1883370) <- node(1883303) why: v24.14.0 < v24.22.1 183 processes of other users not inspected: run the script as their owner pm2 live list (pm2 jlist, PM2_HOME=/home/USER/.pm2) [unknown] pm2 CLI /home/USER/.bun/bin/pm2 is writable by group or others (mode 777): not run, live list skipped [vulnerable] daemon pid 1883303 v24.14.0 /home/USER/.local/share/fnm/node-versions/v24.14.0/installation/bin/node Installed Node versions (version managers) fnm in /home/USER/.local/share/fnm installed: v24.14.0 (running), v24.14.1 (from path), v24.22.1 (from path) default alias -> v24.22.1 (from path) `node` on this shell's PATH -> v24.22.1 (from path) /home/USER/.local/share/fnm/node-versions/v24.22.1/installation/bin/node for information only: these do not change the exit code Crontab: absolute Node paths [unknown] crontab -l (USER):33 /home/USER/.local/share/fnm/node-versions/v24.22.1/installation/bin/node -> v24.22.1 (from path) (pinned to one install) why: from path: v24.22.1 (a hint, not proof: binaries outside /proc are not read) [unknown] crontab -l (USER):53 /home/USER/.local/share/fnm/node-versions/v24.22.1/installation/bin/node -> v24.22.1 (from path) (pinned to one install) why: from path: v24.22.1 (a hint, not proof: binaries outside /proc are not read) systemd units: absolute Node paths [unknown] /etc/systemd/system/pm2-USER.service:12 /home/USER/.local/share/fnm/node-versions/v24.22.1/installation/bin -> v24.22.1 (from path) (pinned to one install) why: from path: v24.22.1 (a hint, not proof: binaries outside /proc are not read) unit: pm2-USER.service ActiveState=inactive MainPID=0 note: the unit is not what runs pm2 now; its PATH applies at the next boot Containers no docker or podman CLI found result: VULNERABLE: something runs or will start an unfixed Node (exit 1) $ echo $? 1 The fnm default, the shell's node, both cron jobs and the systemd unit point at the v24.22.1 install. The script reports the cron and unit paths as unknown rather than patched, because no running process uses that binary and it does not open it. The four processes that run are v24.14.0. The pm2 daemon is not running under the systemd unit (ActiveState=inactive), so the unit we had fixed is not what runs it now. The one pm2 app is started by bun, and the Node processes under it run the app's node_modules/.bin/next script. A separate check (scripts/pm2-path-evidence.py, results/pm2-path-20261022T070504Z.txt) shows that the process the pm2 daemon started and the saved pm2 list both have the v24.14.0 install first on PATH, which is where those Node processes come from. If the app is resurrected from that saved list after a reboot, it gets the same PATH; we did not reboot to confirm. The fix here is pm2 restart <app> --update-env from a fixed shell, then pm2 save, then moving the daemon. The run found one more thing: the pm2 CLI on that machine is world-writable (mode 777). The script will not run a CLI that someone else could have changed, so it skipped the live pm2 list and reported it as unknown. Check the permissions of your Node installs and global CLIs while you are in there. The exit code is 1 when anything runs or will start a vulnerable Node, otherwise 2 when anything could not be checked, and 0 when it found nothing vulnerable or unknown within what it covers, including no Node at all. Unknown never counts as patched: a pre-release build, a distribution build linked to the system Brotli, a binary without an embedded version, a unit it could not read, a failed pm2 jlist, crontab -l or docker ps, a config path that only names a fixed version, or a cron path to a Node that no longer exists all give 2. It only sees what the user running it can see, so run it as each user that owns Node processes. To run it across hosts without copying it first: for h in deploy@web1 deploy@web2 deploy@web3; do ssh "$h" 'python3 - --json' < node_runtime_audit.py > "node-${h#*@}.json" echo "$h exit $?" done The tests build fake hosts with the cases a version check gets wrong: a fake node that would leave a marker file if the script ever ran it, a binary replaced while it runs, a container binary, a Debian-style build, a pm2 entry whose config is fixed but whose process was never restarted, npm pinned to the old install while its child gets the new one, a cluster-mode app, an old binary in a directory named after a fixed version, end-of-life and pre-release versions, a cron job on a removed version, a shell wrapper that mentions a fixed version, units owned by other users, and FIFOs, devices, hard links and symlinks planted where a file is expected. One test starts a real process, deletes its binary and checks that the script still reads the running file. Rollback Keep the old version installed until every check passes. Rolling back is the same work in reverse: nvm alias default 24.14.0, then the same --update-env, delete-and-recreate, pm2 save, startup unit and pm2 update steps from a shell where the old version comes first. For containers, redeploy the previous image digest. A rollback puts the vulnerable version back, so treat it as a few hours to fix a regression, not as a resting state. Once the audit shows nothing on the old version, nvm uninstall 24.14.0. Anything still pinned to it will then fail to start instead of quietly running the old code, and the audit script reports those paths as missing. What we could not check - The bugs themselves. The advisory gives no technical detail, and we did not test whether any code path reaches the Brotli flaw. The exposure section is based on which code calls the decoder. - Scores. The CVE records were still reserved when we checked, so there is no CVSS score. - Distribution packages. Debian and Ubuntu had not published anything for CVE-2026-111497 when we checked. Check your distribution's nodejs and brotli advisories. - Containers on a real host. The Pi has no Docker, so the container checks are covered by the test fixtures only. The registry scan read real image metadata. - The downtime number."Under a minute" comes from the production upgrade. The lab's pm2 update took 2.3 seconds because its apps do nothing. - Bare node in cron jobs and units. The script checks absolute paths and PATH= lines; it does not resolve a bare node through the job's PATH. It reads only your own crontab -l, not the system crontabs or the saved pm2 list, and it does not follow EnvironmentFile=. - Other platforms. The registry scan covers linux/amd64 and linux/arm64 only. The script needs Linux and /proc, and does not cover Kubernetes, stopped containers, or Node binaries renamed to something other than node (single executable applications, Electron). Checklist - <input disabled type="checkbox"> Every host: Node 22.23.4, 24.22.1 or 26.12.1 or later installed; Node 20, 25 and older replaced - <input disabled type="checkbox"> Every pm2 entry: absolute interpreters and npm or npx script paths recreated, bare node entries restarted with --update-env, then pm2 save - <input disabled type="checkbox"> pm2 startup unit regenerated (pm2 unstartup, pm2 startup) and the daemon moved with pm2 update in a quiet window - <input disabled type="checkbox"> Cluster-mode apps checked after the daemon moved - <input disabled type="checkbox"> Crontabs, systemd units and helper scripts searched for absolute paths to the old install - <input disabled type="checkbox"> Images rebuilt with --pull (Alpine-based ones checked after the late rebuild) and redeployed by new tag or digest - <input disabled type="checkbox"> Services that fetch user-supplied URLs or accept compressed bodies upgraded first; interim measures where they cannot be - <input disabled type="checkbox"> Every Node process checked by /proc/<pid>/exe, not by name, including child processes - <input disabled type="checkbox"> Node installs and global CLIs (pm2, npm) writable only by their owner - <input disabled type="checkbox"> Audit script output saved per host, and the old version uninstalled once it is clean Every fact in this post is listed with its source in the repo's SOURCES.md. _Originally published at devops-daily.com._