L’OWASP (Open Web Application Security Project) publie sa liste des 10 risques de sécurité les plus critiques pour les applications web. La version 2025, officiellement désignée OWASP Top 10:2025, marque une rupture nette : les défaillances de la chaîne d’approvisionnement logicielle (supply chain) entrent pour la première fois dans le top 3, directement liées à l’explosion des attaques sur les dépendances npm. Pour une API Node.js/Express, ignorer ce référentiel expose vos utilisateurs à des fuites de données, des prises de contrôle de comptes et des injections de code arbitraire.

Ce tutoriel vous guide à travers 12 étapes concrètes pour implémenter chaque contre-mesure dans une application Express.js réelle. Vous couvrirez les 10 catégories de l’OWASP Top 10:2025, avec des extraits de code testés et prêts à déployer en production. Durée estimée : 45 minutes. Niveau : intermédiaire.

Prérequis et environnement de développement

Avant de commencer, vérifiez que votre environnement est correctement configuré. Ce tutoriel cible Node.js 22 LTS, la version activement maintenue en 2026, qui corrige notamment la CVE-2025-23087 (contrôles de sécurité insuffisants) présente dans les versions Node.js jusqu’à v17.x. Les versions en fin de vie (End of Life) ne reçoivent plus de correctifs de sécurité : ne les utilisez pas en production.

OutilVersion recommandéeRôle dans la sécurité
Node.js22.x LTSRuntime principal, patches sécurité actifs
npm10.xGestionnaire de paquets avec audit intégré
Express.js4.x ou 5.xFramework web HTTP
Helmet.js8.2.0En-têtes de sécurité HTTP automatiques
express-validator7.xValidation et assainissement des entrées
express-rate-limit7.xProtection contre le brute force
bcrypt5.xHachage sécurisé des mots de passe
jsonwebtoken9.xAuthentification JWT signée
hpp0.2.xProtection contre la pollution HTTP
morgan1.xJournalisation des requêtes HTTP

Assurez-vous d’avoir accès à un terminal avec les droits d’exécution npm. Un compte MongoDB Atlas (gratuit) ou une instance PostgreSQL locale complète l’environnement pour les démonstrations d’injection des étapes 4 et 5.

Vue d’ensemble de l’OWASP Top 10:2025

La liste OWASP Top 10:2025 reflète les tendances de sécurité observées dans les milliers d’applications auditées ces deux dernières années. Par rapport à la version 2021, deux changements majeurs ressortent : la Security Misconfiguration monte de la 5e à la 2e place, et les Software Supply Chain Failures apparaissent en position A03. Ce dernier point touche directement les développeurs Node.js, qui s’appuient en moyenne sur plusieurs centaines de dépendances transitives dans leurs projets.

RangCatégorie OWASP Top 10:2025Évolution vs 2021Impact principal dans Node.js
A01Contrôle d’accès défaillantStable (A01 en 2021)Élévation de privilèges, IDOR
A02Mauvaise configuration de sécuritéHausse (était A05)En-têtes manquants, stack traces exposées
A03Défaillances de la chaîne logicielleNouveauBackdoors via dépendances npm
A04Échecs cryptographiquesBaisse (était A02)Mots de passe en clair, chiffrement faible
A05InjectionBaisse (était A03)SQL, NoSQL, commandes shell
A06Conception non sécuriséeStableValidation absente, logique métier exploitable
A07Échecs d’authentificationStableBrute force, credential stuffing
A08Défaillances d’intégrité logicielleStableWebhooks non signés, paquets altérés
A09Échecs de journalisation et d’alerteStableAttaques non détectées, absence de logs
A10Gestion défaillante des exceptionsNouveau (remplace SSRF)Stack traces exposées, crashs non gérés

Étape 1 : Initialiser le projet et installer les dépendances de sécurité

Créez un nouveau répertoire de projet et initialisez npm. L’installation de toutes les dépendances de sécurité en une seule commande évite les oublis fréquents lors de la mise en place d’un projet Express.

mkdir secure-api && cd secure-api
npm init -y

# Dépendances de production
npm install express helmet express-rate-limit express-validator \
  bcrypt jsonwebtoken mongoose dotenv hpp morgan cors

# Dépendances de développement
npm install --save-dev nodemon jest supertest eslint-plugin-security

Créez la structure de base du projet avant d’écrire la moindre ligne de code métier :

secure-api/
├── src/
│   ├── app.js               # Configuration Express principale
│   ├── server.js            # Point d'entrée, démarrage HTTP
│   ├── config/
│   │   └── validateEnv.js   # Vérification des variables d'env au démarrage
│   ├── middleware/
│   │   ├── auth.js          # Authentification JWT + autorisation par rôle
│   │   ├── rateLimits.js    # Limiteurs de requêtes
│   │   ├── validate.js      # Règles express-validator
│   │   └── errorHandler.js  # Gestionnaire d'erreurs global
│   ├── routes/
│   │   ├── users.js
│   │   └── auth.js
│   └── models/
│       └── User.js
├── logs/
├── .env
├── .env.example
└── package.json

Dans le fichier .env, définissez les variables d’environnement sensibles. Ne commitez jamais ce fichier dans Git : ajoutez .env à .gitignore immédiatement. Utilisez .env.example pour documenter les variables requises sans leurs valeurs réelles.

# .env.example (commiter ce fichier, PAS .env)
NODE_ENV=production
PORT=3000
JWT_SECRET=remplacez-par-une-cle-aleatoire-de-64-caracteres-minimum
MONGODB_URI=mongodb+srv://user:[email protected]/secure-api
BCRYPT_ROUNDS=12
WEBHOOK_SECRET=remplacez-par-un-secret-webhooks

Étape 2 : A02 Mauvaise configuration, corriger avec Helmet.js 8.2.0

La mauvaise configuration de sécurité est désormais la deuxième menace la plus répandue selon l’OWASP. Dans un projet Express.js par défaut, plusieurs failles de configuration sont présentes dès l’installation : l’en-tête X-Powered-By: Express expose la technologie utilisée, les erreurs retournent des stack traces complètes, et aucun en-tête de sécurité n’est configuré.

Helmet.js 8.2.0 est la solution standard pour configurer automatiquement les en-têtes de sécurité HTTP. Une seule ligne de code active un ensemble de protections que la majorité des développeurs oublieraient de configurer manuellement. Helmet configure les en-têtes suivants par défaut :

  • Content-Security-Policy : restreint les sources autorisées pour les scripts, styles et frames, ce qui neutralise la majorité des attaques XSS.
  • X-Content-Type-Options: nosniff : empêche le navigateur de deviner le type MIME et d’exécuter du code contenu dans des fichiers image ou texte.
  • X-Frame-Options: SAMEORIGIN : protège contre le clickjacking en empêchant l’affichage de la page dans un iframe externe.
  • Strict-Transport-Security : force HTTPS pendant un an (31 536 000 secondes), même si l’utilisateur tape HTTP manuellement.
  • X-DNS-Prefetch-Control: off : désactive la prélecture DNS non autorisée par le navigateur.
  • Referrer-Policy: no-referrer : évite la fuite d’URL dans l’en-tête Referer lors des navigations entre sites.
  • Cross-Origin-Opener-Policy: same-origin : isole le contexte de navigation entre onglets d’origines différentes.

À noter : Helmet désactive intentionnellement l’en-tête X-XSS-Protection car cet en-tête hérité, présent dans les anciens navigateurs, peut paradoxalement introduire de nouvelles vulnérabilités XSS au lieu d’en protéger.

// src/app.js
const express = require('express');
const helmet = require('helmet');
const hpp = require('hpp');
const cors = require('cors');
const morgan = require('morgan');
require('dotenv').config();

const app = express();

// Protection des en-têtes HTTP (OWASP A02)
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "data:"],
      connectSrc: ["'self'"],
      fontSrc: ["'self'"],
      objectSrc: ["'none'"],
      frameAncestors: ["'none'"],
      upgradeInsecureRequests: [],
    },
  },
  hsts: {
    maxAge: 31536000,
    includeSubDomains: true,
    preload: true,
  },
}));

// Configuration CORS avec liste blanche
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '').split(',').filter(Boolean);
app.use(cors({
  origin: (origin, callback) => {
    if (!origin || allowedOrigins.includes(origin)) return callback(null, true);
    callback(new Error('Origine non autorisée par CORS'));
  },
  credentials: true,
}));

// Protection contre la pollution HTTP des paramètres (OWASP A02)
app.use(hpp());

// Journalisation des requêtes (OWASP A09)
if (process.env.NODE_ENV !== 'test') {
  app.use(morgan('combined'));
}

// Limite la taille des corps de requête (protection DoS + A05)
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true, limit: '10kb' }));

// Supprimer l'en-tête X-Powered-By (OWASP A02)
app.disable('x-powered-by');

module.exports = app;

Le module hpp (HTTP Parameter Pollution) protège contre les attaques où un attaquant envoie plusieurs fois le même paramètre dans une requête. Express conserve par défaut le dernier paramètre, mais certaines bibliothèques de validation exposent les deux, créant un comportement imprévisible. La limite de 10 Ko sur le corps des requêtes empêche les attaques DoS par envoi de corps JSON volumineux, que le OWASP Node.js Security Cheat Sheet recommande explicitement.

Étape 3 : A01 Contrôle d’accès, implémenter l’autorisation par rôle et la protection IDOR

Le contrôle d’accès défaillant reste en première position depuis 2021. La vulnérabilité IDOR (Insecure Direct Object Reference) en est l’exemple le plus fréquent : un utilisateur modifie l’identifiant dans l’URL pour accéder aux données d’un autre utilisateur. En France, une faille IDOR exposée sur des services publics en 2026 a mis en danger 31 millions de comptes, rappelant que ce type d’erreur n’est pas théorique. La protection passe par trois mesures distinctes : vérification de l’identité (authentification), vérification des droits (autorisation), et validation que la ressource demandée appartient à l’utilisateur authentifié.

// src/middleware/auth.js
const jwt = require('jsonwebtoken');

// Middleware d'authentification JWT
const authenticate = (req, res, next) => {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Token manquant ou mal formé' });
  }

  const token = authHeader.split(' ')[1];
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET);
    req.user = payload;
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expiré', code: 'TOKEN_EXPIRED' });
    }
    return res.status(401).json({ error: 'Token invalide' });
  }
};

// Middleware d'autorisation par rôle (OWASP A01)
const authorize = (...roles) => (req, res, next) => {
  if (!req.user || !roles.includes(req.user.role)) {
    return res.status(403).json({ error: 'Accès interdit' });
  }
  next();
};

// Protection anti-IDOR : vérifie que la ressource appartient à l'utilisateur
const ownResource = (req, res, next) => {
  const resourceId = req.params.id;
  if (req.user.role !== 'admin' && resourceId !== req.user.id.toString()) {
    return res.status(403).json({ error: 'Accès à cette ressource non autorisé' });
  }
  next();
};

module.exports = { authenticate, authorize, ownResource };
// src/routes/users.js
const router = require('express').Router();
const { authenticate, authorize, ownResource } = require('../middleware/auth');
const { idRules, handleValidationErrors } = require('../middleware/validate');
const UserController = require('../controllers/UserController');

// Lecture : authentification + vérification de propriété (anti-IDOR)
router.get('/users/:id',
  authenticate,
  idRules,
  handleValidationErrors,
  ownResource,
  UserController.getById
);

// Modification : authentification + anti-IDOR
router.patch('/users/:id',
  authenticate,
  idRules,
  handleValidationErrors,
  ownResource,
  UserController.update
);

// Suppression : admin uniquement
router.delete('/users/:id',
  authenticate,
  authorize('admin'),
  idRules,
  handleValidationErrors,
  UserController.delete
);

// Liste des utilisateurs : admin uniquement
router.get('/users',
  authenticate,
  authorize('admin'),
  UserController.list
);

module.exports = router;

Une erreur fréquente consiste à sécuriser uniquement les routes GET tout en laissant les routes PATCH ou DELETE sans contrôle d’accès. Appliquez systématiquement les middlewares d’authentification et d’autorisation sur chaque route qui lit ou modifie une ressource. Pour en savoir plus sur les jetons JWT, consultez notre guide sur l’authentification JWT en Node.js.

Étape 4 : A05 Injection SQL et NoSQL, requêtes paramétrées obligatoires

L’injection reste la cinquième menace de l’OWASP Top 10:2025. Dans un contexte Node.js, trois types d’injection sont particulièrement fréquents : l’injection SQL dans les bases relationnelles (PostgreSQL, MySQL), l’injection NoSQL dans MongoDB, et l’injection de commandes shell via child_process.exec. L’OWASP Node.js Security Cheat Sheet identifie eval() et child_process.exec() comme les deux fonctions les plus dangereuses à éviter avec des données utilisateur.

Injection SQL avec PostgreSQL et le module pg

La règle absolue : ne jamais concaténer directement les données utilisateur dans une requête SQL. Utilisez toujours les requêtes paramétrées, qui transmettent les paramètres séparément de la requête, rendant toute injection impossible.

const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL, ssl: { rejectUnauthorized: true } });

// DANGEREUX : injection SQL possible
// const badQuery = `SELECT * FROM users WHERE email = '${req.body.email}'`;
// Un attaquant envoie : ' OR '1'='1 -- pour lire tous les comptes

// CORRECT : requête paramétrée, le paramètre est échappé automatiquement
const getUserByEmail = async (email) => {
  const result = await pool.query(
    'SELECT id, email, role FROM users WHERE email = $1',
    [email]
  );
  return result.rows[0] || null;
};

// CORRECT avec Sequelize ORM : les placeholders sont générés automatiquement
const { Op } = require('sequelize');
const user = await User.findOne({
  where: { email: req.body.email },
  attributes: ['id', 'email', 'role'], // Limiter les colonnes retournées
});

// DANGEREUX : child_process.exec avec entrée utilisateur
// const { exec } = require('child_process');
// exec(`ping -c 1 ${req.body.host}`, ...); // Injection possible : ; rm -rf /

// CORRECT : execFile avec tableau d'arguments (pas de shell invoqué)
const { execFile } = require('child_process');
execFile('ping', ['-c', '1', sanitizedHost], { timeout: 5000 }, callback);

Injection NoSQL dans MongoDB

L’injection NoSQL exploite le fait que MongoDB accepte des objets JSON comme valeurs de requête. Un attaquant qui envoie {"$gt": ""} à la place d’un mot de passe fait interpréter la valeur comme un opérateur MongoDB, ce qui peut contourner une vérification d’authentification.

// DANGEREUX : injection NoSQL
// Body envoyé : {"username": "admin", "password": {"$gt": ""}}
// MongoDB interprète password comme : {$gt: ""} ce qui est toujours vrai
const badLogin = await User.findOne({
  username: req.body.username,
  password: req.body.password, // Si c'est un objet, la requête est altérée
});

// CORRECT : forcer le type string avant toute requête
const username = String(req.body.username || '').slice(0, 50).trim();
const rawPassword = String(req.body.password || '').slice(0, 128);

// Trouver l'utilisateur par son nom, puis comparer le mot de passe avec bcrypt
const user = await User.findOne({ username }).select('+password');
if (!user) {
  // Délai constant pour éviter les attaques de timing (user existant vs inexistant)
  await bcrypt.compare(rawPassword, '$2b$12$invalidhashfortimingprotection00000000000000');
  return res.status(401).json({ error: 'Identifiants invalides' });
}

const valid = await bcrypt.compare(rawPassword, user.password);
if (!valid) return res.status(401).json({ error: 'Identifiants invalides' });

Pour la gestion sécurisée des mots de passe avec bcrypt, notre tutoriel sur bcrypt en Node.js détaille les facteurs de coût recommandés en 2026 et les bonnes pratiques de comparaison sécurisée.

Étape 5 : A06 Conception non sécurisée, valider toutes les entrées avec express-validator

La conception non sécurisée (A06) se manifeste souvent par l’absence de validation des entrées utilisateur. Express-validator offre une API fluente pour définir des règles de validation directement au niveau des routes, séparant clairement la logique de validation du code métier. La validation doit être effectuée sur le serveur, indépendamment de toute validation côté client, qui peut être contournée.

// src/middleware/validate.js
const { body, param, validationResult } = require('express-validator');

// Règles de validation pour l'inscription
const registerRules = [
  body('email')
    .isEmail()
    .normalizeEmail()     // Normalise les alias ([email protected] -> [email protected])
    .withMessage('Email invalide'),
  body('password')
    .isLength({ min: 12, max: 128 })
    .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/)
    .withMessage('Mot de passe : min 12 caractères avec maj, min, chiffre et symbole'),
  body('username')
    .trim()
    .escape()             // Remplace < > & " par entités HTML (protection XSS stocké)
    .isLength({ min: 3, max: 50 })
    .matches(/^[a-zA-Z0-9_-]+$/)
    .withMessage("Nom d'utilisateur invalide : lettres, chiffres, - et _ uniquement"),
];

// Règles pour les paramètres d'URL (protection anti-IDOR)
const idRules = [
  param('id')
    .isMongoId()
    .withMessage('ID invalide'),
];

// Règles pour la mise à jour du profil
const updateProfileRules = [
  body('email').optional().isEmail().normalizeEmail(),
  body('username').optional().trim().escape().isLength({ min: 3, max: 50 }),
];

// Middleware qui retourne les erreurs de validation en 422
const handleValidationErrors = (req, res, next) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(422).json({
      error: 'Données invalides',
      details: errors.array().map(e => ({ field: e.path, message: e.msg })),
    });
  }
  next();
};

module.exports = { registerRules, idRules, updateProfileRules, handleValidationErrors };

La méthode .escape() remplace les caractères HTML spéciaux par leurs entités, ce qui neutralise les attaques XSS stockées dans la base de données. La méthode .normalizeEmail() empêche un même utilisateur de créer plusieurs comptes avec des variantes de son adresse Gmail ([email protected], [email protected], [email protected] sont normalisés en une seule adresse).

Étape 6 : A07 Authentification défaillante, rate limiting et protection brute force

Les échecs d’authentification (A07) couvrent les attaques par force brute, le credential stuffing (injection de listes de mots de passe volés) et les failles de réinitialisation de mot de passe. Une API Express sans limitation de requêtes peut traiter des milliers de tentatives de connexion par seconde depuis une seule adresse IP, rendant toute attaque par force brute triviale en quelques heures.

// src/middleware/rateLimits.js
const rateLimit = require('express-rate-limit');

// Limite globale pour toutes les routes
const globalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // Fenêtre de 15 minutes
  max: 100,                  // 100 requêtes par IP
  standardHeaders: true,     // Headers RateLimit-* dans la réponse
  legacyHeaders: false,
  message: { error: 'Trop de requêtes, réessayez dans 15 minutes' },
});

// Limite stricte pour les routes d'authentification
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,                       // 5 tentatives de connexion par 15 minutes
  skipSuccessfulRequests: true,  // Ne pas compter les connexions réussies
  message: { error: 'Compte temporairement bloqué, réessayez dans 15 minutes' },
});

// Limite pour la réinitialisation de mot de passe
const resetLimiter = rateLimit({
  windowMs: 60 * 60 * 1000, // Fenêtre d'1 heure
  max: 3,
  message: { error: 'Limite de réinitialisation atteinte, réessayez dans 1 heure' },
});

module.exports = { globalLimiter, authLimiter, resetLimiter };
// src/app.js - Application des limiteurs
const { globalLimiter, authLimiter, resetLimiter } = require('./middleware/rateLimits');

// Appliquer derrière un proxy : lire l'IP depuis X-Forwarded-For
app.set('trust proxy', 1); // 1 = confiance à un seul niveau de proxy (Nginx, Cloudflare)

app.use(globalLimiter);
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/forgot-password', resetLimiter);

// Génération de token JWT sécurisé
const generateTokens = (user) => {
  const accessToken = jwt.sign(
    { id: user._id, role: user.role },
    process.env.JWT_SECRET,
    { expiresIn: '15m', algorithm: 'HS256' }  // Token court : 15 minutes
  );

  const refreshToken = jwt.sign(
    { id: user._id, type: 'refresh' },
    process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET,
    { expiresIn: '7d', algorithm: 'HS256' }
  );

  return { accessToken, refreshToken };
};

Le délai constant via bcrypt.compare (même quand l’utilisateur n’existe pas) est une protection critique contre les attaques par timing. Sans elle, un attaquant peut distinguer un compte existant d’un compte inexistant en mesurant le temps de réponse de l’API, puis concentrer ses tentatives sur les comptes confirmés. Notre tutoriel sur HMAC-SHA256 en Node.js explique en détail ces attaques par timing.

Étape 7 : A03 Chaîne logicielle, auditer et geler les dépendances npm

Les défaillances de la chaîne d’approvisionnement logicielle (A03) constituent la grande nouveauté de l’OWASP Top 10:2025. Dans l’écosystème npm, cette menace est directement liée aux attaques de typosquatting (paquets malveillants imitant des noms populaires) et de compromission de mainteneurs de paquets légitimes. Les mesures de protection couvrent trois niveaux : auditer les dépendances existantes, geler les versions pour éviter les mises à jour automatiques imprévues, et intégrer les vérifications dans le pipeline CI/CD.

# Auditer toutes les dépendances (vulnérabilités connues dans la NVD)
npm audit

# Corriger automatiquement les vulnérabilités non critiques
npm audit fix

# En CI/CD : bloquer si vulnérabilité haute ou critique
npm audit --audit-level=high --omit=dev

# npm ci : installe exactement les versions du package-lock.json
# Échoue si package-lock.json et package.json sont désynchronisés
npm ci

# Vérifier les versions disponibles et les mises à jour
npm outdated

# Scanner les secrets accidentellement commités dans Git
npx trufflesecurity/trufflehog git file://. --only-verified 2>/dev/null || true

# Vérifier l'intégrité d'un paquet avec son hash sha512 publié sur npm
npm view express dist-tags
npm view [email protected] dist.integrity
// package.json : vérifications automatiques
{
  "scripts": {
    "audit:ci": "npm audit --audit-level=high --omit=dev",
    "start": "node src/server.js",
    "dev": "nodemon src/server.js",
    "test": "jest --forceExit",
    "lint:security": "eslint --plugin security src/"
  },
  "engines": {
    "node": ">=20.0.0",
    "npm": ">=9.0.0"
  }
}

La commande npm ci est préférable à npm install en CI/CD : elle lit strictement le fichier package-lock.json et échoue si ce fichier est désynchronisé avec package.json, empêchant une mise à jour silencieuse vers une version compromise. Committez toujours package-lock.json dans votre dépôt Git.

Étape 8 : A04 Échecs cryptographiques, protéger les données sensibles en transit et au repos

Les échecs cryptographiques (A04) surviennent quand des données sensibles circulent en clair, sont chiffrées avec des algorithmes obsolètes (MD5 pour les mots de passe, SHA-1 pour les signatures) ou quand les clés cryptographiques sont stockées en dur dans le code source. Pour une API Node.js, cela concerne les mots de passe, les tokens de session, les données personnelles et les connexions aux services externes.

// src/models/User.js - Stockage sécurisé des mots de passe
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');

const userSchema = new mongoose.Schema({
  email: {
    type: String,
    required: true,
    unique: true,
    lowercase: true,
    trim: true,
  },
  password: {
    type: String,
    required: true,
    select: false, // Jamais retourné par défaut dans les requêtes
  },
  role: {
    type: String,
    enum: ['user', 'moderator', 'admin'],
    default: 'user',
  },
  failedLoginAttempts: { type: Number, default: 0 },
  lockedUntil: { type: Date },
  createdAt: { type: Date, default: Date.now },
});

// Hacher le mot de passe avant chaque sauvegarde (OWASP A04)
userSchema.pre('save', async function (next) {
  if (!this.isModified('password')) return next();
  // 12 rounds = environ 400ms sur CPU moderne, bon équilibre sécurité/performance
  const rounds = parseInt(process.env.BCRYPT_ROUNDS, 10) || 12;
  this.password = await bcrypt.hash(this.password, rounds);
  next();
});

// Ne jamais sérialiser les champs sensibles dans les réponses JSON
userSchema.methods.toJSON = function () {
  const obj = this.toObject();
  delete obj.password;
  delete obj.failedLoginAttempts;
  delete obj.lockedUntil;
  delete obj.__v;
  return obj;
};

module.exports = mongoose.model('User', userSchema);

Pour le transport des données, configurez MongoDB avec TLS en production. Ajoutez ssl=true&tlsAllowInvalidCertificates=false dans l’URI de connexion ou configurez l’option tls: true, tlsAllowInvalidCertificates: false dans les options Mongoose. Ne transmettez jamais des données sensibles en paramètre d’URL GET car elles apparaissent dans les logs de serveur, les logs de proxy et l’historique du navigateur.

Étape 9 : A08 Intégrité des données, vérifier les signatures des webhooks

Les défaillances d’intégrité (A08) couvrent les scénarios où du code ou des données sont altérés sans que l’application ne le détecte. Côté Node.js, cela inclut la désérialisation de données non signées, l’absence de vérification des webhooks entrants (GitHub, Stripe, PayPal) et le chargement de scripts via CDN sans attribut integrity (Subresource Integrity).

// src/middleware/webhookVerify.js
// Vérification de la signature d'un webhook GitHub ou Stripe
const crypto = require('crypto');

// Middleware de capture du body raw (avant JSON.parse)
const captureRawBody = (req, res, next) => {
  let data = '';
  req.on('data', (chunk) => { data += chunk; });
  req.on('end', () => {
    req.rawBody = data;
    try {
      req.body = JSON.parse(data);
    } catch {
      req.body = {};
    }
    next();
  });
};

// Vérification de la signature HMAC-SHA256
const verifyWebhookSignature = (secretEnvVar) => (req, res, next) => {
  const signature = req.headers['x-hub-signature-256'] || req.headers['stripe-signature'];
  if (!signature) {
    return res.status(401).json({ error: 'Signature webhook manquante' });
  }

  const secret = process.env[secretEnvVar];
  if (!secret) {
    return res.status(500).json({ error: 'Configuration webhook manquante' });
  }

  const hmac = crypto.createHmac('sha256', secret);
  const digest = 'sha256=' + hmac.update(req.rawBody).digest('hex');

  const sigBuffer = Buffer.from(signature.slice(0, 200));
  const digestBuffer = Buffer.from(digest);

  // Comparaison en temps constant : empêche les attaques de timing
  if (sigBuffer.length !== digestBuffer.length ||
      !crypto.timingSafeEqual(sigBuffer, digestBuffer)) {
    return res.status(401).json({ error: 'Signature webhook invalide' });
  }

  next();
};

module.exports = { captureRawBody, verifyWebhookSignature };

// Utilisation dans les routes :
// app.use('/webhooks/github', captureRawBody, verifyWebhookSignature('GITHUB_WEBHOOK_SECRET'));
// app.use('/webhooks/stripe', captureRawBody, verifyWebhookSignature('STRIPE_WEBHOOK_SECRET'));

La fonction crypto.timingSafeEqual() est indispensable pour la comparaison de signatures. Une comparaison classique avec === s’arrête au premier caractère différent, permettant à un attaquant de deviner la signature caractère par caractère en mesurant les temps de réponse (attaque par oracle de timing). Notre tutoriel sur HMAC-SHA256 en Node.js couvre ces attaques en détail.

Étape 10 : A09 Journalisation, configurer des logs structurés sans données sensibles

Les échecs de journalisation (A09) ont une conséquence directe sur la capacité à détecter et à répondre aux incidents. Sans logs structurés, les attaques passent inaperçues pendant des semaines. La règle fondamentale : journaliser les événements de sécurité clés (connexions réussies et échouées, accès refusés, modifications de données sensibles) sans jamais inclure dans les logs des données sensibles (mots de passe, tokens JWT, numéros de carte).

// src/utils/logger.js
const winston = require('winston');

const sensitiveFields = ['password', 'token', 'accessToken', 'refreshToken',
  'cardNumber', 'cvv', 'secret', 'apiKey'];

// Filtre qui masque les champs sensibles dans les logs
const maskSensitiveData = winston.format((info) => {
  if (info.body) {
    const masked = { ...info.body };
    sensitiveFields.forEach(f => { if (f in masked) masked[f] = '[MASQUÉ]'; });
    info.body = masked;
  }
  return info;
});

const logger = winston.createLogger({
  level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
  format: winston.format.combine(
    maskSensitiveData(),
    winston.format.timestamp({ format: 'YYYY-MM-DDTHH:mm:ssZ' }),
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  defaultMeta: { service: 'secure-api', env: process.env.NODE_ENV },
  transports: [
    new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
    new winston.transports.File({ filename: 'logs/security.log', level: 'warn' }),
    new winston.transports.File({ filename: 'logs/combined.log' }),
  ],
});

if (process.env.NODE_ENV !== 'production') {
  logger.add(new winston.transports.Console({ format: winston.format.simple() }));
}

// Journaliser les événements de sécurité avec contexte
const logSecurityEvent = (event, userId, ip, details = {}) => {
  logger.warn({
    type: 'security_event',
    event,         // 'LOGIN_FAILED', 'ACCESS_DENIED', 'RATE_LIMIT_HIT', etc.
    userId: userId || 'anonymous',
    ip,
    timestamp: new Date().toISOString(),
    ...details,
  });
};

module.exports = { logger, logSecurityEvent };

Étape 11 : A10 Gestion des exceptions, masquer les stack traces en production

La gestion défaillante des conditions exceptionnelles (A10) est la nouveauté la plus importante de l’OWASP Top 10:2025. Dans Node.js, les stack traces non filtrées exposent la structure interne de l’application : chemins de fichiers absolus, versions des bibliothèques, noms de fonctions internes et même parfois des variables locales. Ces informations fournissent aux attaquants exactement ce dont ils ont besoin pour cibler leurs exploits.

// src/middleware/errorHandler.js
const { logger } = require('../utils/logger');

// Wrapper pour routes async (Express 4.x)
// Express 5.x gère nativement les promesses rejetées
const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

// Gestionnaire d'erreur global Express (4 paramètres obligatoires)
const errorHandler = (err, req, res, next) => {
  const isDev = process.env.NODE_ENV === 'development';

  // Journaliser les détails complets en interne
  logger.error({
    message: err.message,
    stack: err.stack,
    url: req.originalUrl,
    method: req.method,
    userId: req.user?.id || 'anonymous',
    ip: req.ip,
    status: err.status || 500,
  });

  // Erreurs de validation : exposer les détails (pas de risque)
  if (err.name === 'ValidationError') {
    return res.status(422).json({ error: 'Données invalides', details: err.message });
  }

  // En production : masquer TOUS les détails techniques
  if (!isDev) {
    return res.status(err.status || 500).json({
      error: 'Une erreur interne est survenue',
      requestId: req.id, // ID unique pour relier le log à la requête (facultatif)
    });
  }

  // En développement : retourner les détails pour le débogage
  res.status(err.status || 500).json({
    error: err.message,
    stack: err.stack.split('\n'),
  });
};

// Gérer les promesses rejetées non capturées (crash potentiel)
process.on('unhandledRejection', (reason) => {
  logger.error('unhandledRejection', { reason: String(reason) });
  // Laisser PM2 / Kubernetes redémarrer le processus proprement
  process.exit(1);
});

process.on('uncaughtException', (err) => {
  logger.error('uncaughtException', { message: err.message, stack: err.stack });
  process.exit(1);
});

module.exports = { asyncHandler, errorHandler };

Le gestionnaire d’erreur Express doit être déclaré en dernier dans la chaîne des middlewares, après toutes les routes. Un gestionnaire avec seulement 3 paramètres (req, res, next) n’est pas reconnu comme gestionnaire d’erreur par Express et sera ignoré. Les erreurs des routes async doivent passer par next(err) ou être wrappées dans asyncHandler.

Étape 12 : Audit final avec npm audit et OWASP ZAP

Une fois les 11 contre-mesures implémentées, effectuez un audit complet avant de déployer en production. Combinez les outils automatisés avec une revue manuelle des points sensibles.

# Audit complet des dépendances
npm audit --audit-level=high --omit=dev

# Vérifier les en-têtes HTTP de sécurité
curl -I https://votre-api.com/api/health

# Résultat attendu (tous ces en-têtes doivent être présents) :
# content-security-policy: default-src 'self'...
# x-content-type-options: nosniff
# x-frame-options: SAMEORIGIN
# strict-transport-security: max-age=31536000; includeSubDomains; preload
# referrer-policy: no-referrer
# cross-origin-opener-policy: same-origin
# (x-powered-by doit être ABSENT)

# Tester la limitation de débit sur la route de connexion
for i in {1..7}; do
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST https://votre-api.com/api/auth/login \
    -H "Content-Type: application/json" \
    -d '{"username":"test","password":"wrong"}')
  echo "Tentative $i : $STATUS"
done
# Attendu : 401 x5, puis 429 x2

# Tester la protection contre l'injection NoSQL
curl -s -X POST https://votre-api.com/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":{"$gt":""}}' | python3 -m json.tool
# Attendu : erreur 422 (données invalides) ou 401 (accès refusé)

Pour un audit plus approfondi, l’outil OWASP ZAP automatise les tests d’injection, de XSS et de contrôle d’accès. Exécutez-le en mode baseline dans votre pipeline CI/CD pour bloquer automatiquement les déploiements contenant des vulnérabilités détectables. Une validation d’environnement au démarrage est également indispensable :

// src/config/validateEnv.js
// À appeler en tout premier dans server.js, avant tout le reste
const requiredEnvVars = [
  'NODE_ENV', 'PORT', 'JWT_SECRET', 'MONGODB_URI', 'BCRYPT_ROUNDS'
];

const validateEnv = () => {
  const missing = requiredEnvVars.filter(v => !process.env[v]);
  if (missing.length > 0) {
    throw new Error(`Variables d'environnement manquantes : ${missing.join(', ')}`);
  }
  if (process.env.JWT_SECRET.length < 32) {
    throw new Error('JWT_SECRET doit faire au moins 32 caractères');
  }
  const rounds = parseInt(process.env.BCRYPT_ROUNDS, 10);
  if (isNaN(rounds) || rounds < 10) {
    throw new Error('BCRYPT_ROUNDS doit être un entier >= 10');
  }
};

// src/server.js
require('dotenv').config();
const validateEnv = require('./config/validateEnv');
validateEnv(); // Plante au démarrage si la config est incorrecte

const app = require('./app');
const { logger } = require('./utils/logger');
const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {
  logger.info(`API démarrée sur le port ${PORT} en mode ${process.env.NODE_ENV}`);
});

Pièges courants et erreurs à éviter

Après l’implémentation des 12 étapes, ces 8 erreurs sont les plus fréquentes lors des audits de code Node.js en production.

PiègeCatégorie OWASPConséquenceCorrection rapide
Secrets codés en dur dans le code source (const key = "abc123")A04Fuite via Git, accès permanent de l’attaquantVariables d’environnement + .gitignore
Utiliser eval() avec une entrée utilisateurA05Remote Code Execution (RCE) immédiateSupprimer eval() totalement de la codebase
Utiliser child_process.exec() avec des paramètres non validésA05Injection de commandes shellRemplacer par execFile() avec tableau d’arguments
Retourner des stack traces en productionA10Exposition de la structure interne de l’appGestionnaire d’erreur conditionnel par NODE_ENV
Oublier de protéger les routes PATCH/DELETE/PUTA01Élévation de privilèges, IDORAppliquer authenticate sur toutes les routes mutantes
JWT sans expiration (expiresIn absent)A07Tokens valides indéfiniment si compromisAjouter expiresIn: '15m' + mécanisme de refresh
Journaliser les mots de passe ou tokens JWTA09Données sensibles exposées dans les fichiers de logFiltre Winston sur les champs sensibles avant écriture
Comparaison de signatures HMAC avec ===A08Attaque de timing permettant de deviner la signatureUtiliser crypto.timingSafeEqual() systématiquement

Débogage et résolution des problèmes fréquents

Problème 1 : Helmet bloque les ressources CSS ou JS du frontend

Symptôme : La page se charge mais les styles ou scripts ne s’appliquent pas. La console du navigateur affiche Refused to load the script because it violates the Content Security Policy directive.

Solution : Ajustez la directive scriptSrc pour autoriser les domaines CDN que vous utilisez. N’utilisez jamais 'unsafe-inline' pour les scripts : cela annule la protection XSS de la CSP. Préférez les hashes ('sha256-...') ou les nonces pour les scripts inline légitimes.

Problème 2 : Le rate limiter bloque des utilisateurs légitimes derrière un proxy

Symptôme : Des utilisateurs derrière un proxy d’entreprise ou un réseau NAT partagé reçoivent des erreurs 429 alors qu’ils ont fait peu de requêtes personnellement.

Solution : Configurez app.set('trust proxy', 1) pour que express-rate-limit lise l’IP réelle depuis X-Forwarded-For. Si votre API est derrière Cloudflare, utilisez CF-Connecting-IP comme clé via le paramètre keyGenerator d’express-rate-limit.

Problème 3 : Les erreurs de validation retournent 500 au lieu de 422

Symptôme : Une requête avec un corps JSON invalide provoque une erreur 500 avec stack trace au lieu d’un message d’erreur 422.

Solution : Vérifiez que handleValidationErrors est bien inclus dans la chaîne de middlewares avant le handler de route. L’ordre des middlewares Express est crucial et déterministe.

Problème 4 : JWT expiré génère une erreur 500 au lieu de 401

Symptôme : Quand un token expire, l’API retourne 500 au lieu de 401, ce qui expose une stack trace.

Solution : Capturez spécifiquement TokenExpiredError dans le bloc catch de votre middleware d’authentification, comme montré dans l’étape 3 de ce tutoriel.

Problème 5 : npm audit signale des vulnérabilités dans les dev dependencies

Symptôme : npm audit signale des vulnérabilités dans jest, nodemon ou d’autres paquets de développement.

Solution : Ces paquets ne sont pas déployés en production. Utilisez npm audit --omit=dev pour n’auditer que les dépendances de production dans votre pipeline CI/CD. Les vulnérabilités de développement nécessitent une analyse de risque distincte.

Problème 6 : Injection NoSQL sans message d’erreur visible

Symptôme : L’authentification retourne un utilisateur inattendu sans erreur explicite dans les logs. Difficile à diagnostiquer sans tests de sécurité dédiés.

Solution : Forcez le type String() sur toutes les entrées avant qu’elles n’atteignent les requêtes Mongoose. Activez la validation de schéma stricte ({ strict: true }, activé par défaut dans Mongoose). Testez activement avec des payloads {"$gt": ""} dans vos suites de tests.

Problème 7 : CORS mal configuré annule les protections CSP

Symptôme : L’API répond avec Access-Control-Allow-Origin: * en production, ce qui permet à n’importe quel site de faire des requêtes authentifiées vers votre API.

Solution : Définissez une liste blanche d’origines explicites dans la configuration du module cors. N’utilisez jamais le wildcard * avec des cookies ou des tokens d’authentification (credentials: true).

Problème 8 : Variables d’environnement non chargées en production

Symptôme : process.env.JWT_SECRET est undefined en production, causant des erreurs de signature JWT ou des JWT signés avec une clé vide.

Solution : Ne dépendez pas de dotenv en production. Injectez les variables via votre plateforme d’hébergement (Heroku Config Vars, Kubernetes Secrets, AWS Systems Manager Parameter Store). La fonction validateEnv() de l’étape 12 plante le démarrage immédiatement si une variable est manquante.

Problème 9 : Gestionnaire d’erreur global ne capturant pas les routes async

Symptôme : Une exception dans une route async provoque un plantage du serveur ou une absence de réponse au client.

Solution : Wrappez chaque handler async avec asyncHandler (Express 4.x) ou migrez vers Express 5.x qui gère nativement les promesses rejetées. Configurez également process.on('unhandledRejection') pour capturer les rejets non gérés avant qu’ils ne plantent Node.js.

Problème 10 : Tests passant en local mais vulnérabilités détectées en production

Symptôme : Les tests Jest passent en développement, mais un audit de production révèle des vulnérabilités actives.

Solution : Exécutez vos tests de sécurité avec NODE_ENV=production. Le gestionnaire d’erreur, les middlewares Helmet et les limiteurs se comportent différemment selon l’environnement. Créez un environnement de staging qui reproduit fidèlement la configuration de production.

Conseils avancés pour une sécurité renforcée en production

Rotation automatique des secrets : Utilisez un gestionnaire de secrets comme HashiCorp Vault, AWS Secrets Manager ou Doppler CLI pour faire tourner automatiquement les clés JWT et les credentials de base de données sans redéploiement. Configurez une fenêtre de chevauchement pendant laquelle l’ancien et le nouveau secret sont tous les deux valides, pour éviter les déconnexions forcées des utilisateurs actifs pendant la rotation.

Subresource Integrity (SRI) : Si votre API sert des pages HTML avec des scripts CDN, ajoutez des attributs integrity sur toutes les balises <script> et <link>. L’attribut contient le hash SHA-384 du fichier et empêche l’exécution d’une version modifiée par une attaque man-in-the-middle ou un CDN compromis.

Analyse statique du code (SAST) : Intégrez eslint-plugin-security dans votre pipeline pour détecter automatiquement les usages dangereux de eval(), child_process.exec(), les expressions régulières exposées aux attaques ReDoS (Regular expression Denial of Service) et les accès non sécurisés aux fichiers système via des chemins construits dynamiquement.

Tokens de rafraîchissement avec rotation : À chaque renouvellement de token d’accès, invalidez le refresh token et émettez-en un nouveau. Si l’ancien refresh token est réutilisé (signe probable d’un vol), invalidez immédiatement tous les tokens de l’utilisateur concerné et forcez une reconnexion. Notre guide sur OAuth2 en Node.js détaille cette implémentation avec le flux Authorization Code.

Permissions-Policy pour désactiver les API navigateur inutilisées : Ajoutez l’en-tête Permissions-Policy pour désactiver les fonctionnalités du navigateur dont votre application n’a pas besoin : caméra, microphone, géolocalisation, paiements. Cela réduit la surface d’attaque en cas de XSS résiduel.

// En-têtes de sécurité supplémentaires (non couverts par Helmet par défaut)
app.use((req, res, next) => {
  res.setHeader(
    'Permissions-Policy',
    'camera=(), microphone=(), geolocation=(), payment=(), usb=(), bluetooth=()'
  );
  // ID de requête unique pour correler logs et réponses d'erreur
  req.id = crypto.randomUUID();
  res.setHeader('X-Request-ID', req.id);
  next();
});

Ressources de référence et documentation officielle

Les ressources officielles à consulter pour approfondir chaque catégorie de l’OWASP Top 10:2025 :

Couverture connexe

Pour compléter la sécurité de votre application Node.js, ces tutoriels couvrent les sujets complémentaires :

FAQ : OWASP Top 10 et sécurité Node.js en 2026

L’OWASP Top 10:2025 est-il officiellement sorti ou encore en draft ?

L’OWASP Top 10:2025 est la version officiellement publiée et désignée par l’OWASP comme la version actuelle. Elle remplace la liste 2021 et reflète les données de vulnérabilité collectées depuis la dernière publication. Vous pouvez retrouver la liste sur le site officiel owasp.org/www-project-top-ten/.

Helmet.js ralentit-il mon API Express de façon perceptible ?

Non. Helmet.js est un ensemble de middlewares légers qui manipulent uniquement les en-têtes HTTP des réponses. L’overhead mesuré est de quelques microsecondes par requête, imperceptible comparé aux opérations de base de données (dizaines de millisecondes) ou aux calculs bcrypt (300 à 500 ms). Le coût en performance est nul en pratique.

Dois-je implémenter toutes les catégories ou seulement les plus critiques ?

L’OWASP Top 10 ne représente pas une liste ordonnée par priorité absolue : toutes les catégories représentent des risques sérieux et documentés. Pour une API qui démarre, priorisez A01 (contrôle d’accès), A05 (injection) et A07 (authentification), car ces trois catégories causent la majorité des fuites de données. Ajoutez A02 (Helmet.js) et A04 (hachage des mots de passe) immédiatement après : leur implémentation prend moins d’une heure.

Comment tester activement la protection contre l’injection NoSQL ?

Envoyez manuellement des payloads d’injection dans vos requêtes de connexion : curl -X POST /api/auth/login -H "Content-Type: application/json" -d '{"username":"admin","password":{"$gt":""}}'. Si l’API retourne 422 (validation rejetée) ou 401 (mauvais identifiants), votre protection fonctionne correctement. Si elle retourne 200 ou un token valide, votre validation des entrées est insuffisante.

Quelle est la différence entre A01 (contrôle d’accès) et A07 (authentification) ?

L’authentification (A07) vérifie l’identité : “Qui êtes-vous ?” Le contrôle d’accès (A01) vérifie les droits : “Avez-vous le droit d’effectuer cette action sur cette ressource précise ?” Les deux sont distincts et doivent être implémentés séparément. Une API peut avoir une authentification parfaite mais un contrôle d’accès défaillant si elle ne vérifie pas que la ressource demandée appartient à l’utilisateur authentifié (vulnérabilité IDOR).

Comment gérer les faux positifs dans npm audit ?

Les faux positifs apparaissent souvent pour des dépendances de développement ou des vulnérabilités qui ne concernent pas votre usage réel d’un paquet (par exemple, une vulnérabilité dans la fonctionnalité CLI d’un module que vous n’utilisez qu’en API). Documentez chaque exception dans un fichier .nsprc avec la justification, et révisez ces exceptions à chaque mise à jour majeure du paquet concerné. En CI/CD, utilisez --omit=dev --audit-level=high pour un signal précis.

Ces protections s’appliquent-elles aussi à NestJS ou Fastify ?

Oui. L’OWASP Top 10 est indépendant du framework : les vulnérabilités s’appliquent à toute application web, qu’elle soit construite avec Express, NestJS, Fastify, Hapi ou Koa. NestJS dispose d’une intégration native de Helmet (app.use(helmet()) dans main.ts) et de guards pour l’autorisation. Fastify a son propre module @fastify/helmet pour les en-têtes de sécurité.