En juin 2026, l’ANSSI a émis un avis d’urgence sur des vulnérabilités critiques dans Node.js 22.x et 20.x, dont plusieurs exploitables via des entrées non validées. Node.js a également déployé des correctifs de sécurité le 17 juin 2026 pour les branches 26.x, 24.x et 22.x. Résultat : la validation des données reste le premier rempart contre les attaques par injection, XSS et déni de service dans toute application Node.js. Ce tutoriel couvre la mise en place complète d’une chaîne de validation avec express-validator 7, Joi 17 et Zod 3, en 12 étapes et 30 minutes.

Prérequis et versions

Avant de commencer, vérifiez que votre environnement correspond aux versions suivantes. Les exemples de ce tutoriel ont été testés sous Node.js 24.x LTS, sorti en 2026, et Express 5.x, qui apporte une gestion native des promesses dans les middlewares.

OutilVersion minimaleRôle
Node.js22.x LTSRuntime JavaScript côté serveur
npm10+Gestionnaire de paquets
Express5.xFramework web HTTP
express-validator7.xValidation et sanitisation des entrées
Joi17.xValidation par schéma déclaratif
Zod3.xValidation type-safe TypeScript/JS
express-mongo-sanitize2.xPrévention injection NoSQL
DOMPurify + jsdom3.x + 24.xSanitisation HTML côté serveur

Connaissance préalable requise : JavaScript ES2022+, les fondamentaux d’Express (routes, middlewares), et une compréhension de base des concepts HTTP (requêtes POST, corps JSON). Si vous débutez avec la sécurité Node.js, lisez d’abord notre article sur l’OWASP Top 10 Node.js pour un panorama des risques à couvrir.

Pourquoi la validation des données Node.js est critique en 2026

La validation des entrées est le premier rempart contre une large catégorie d’attaques : injection SQL, injection NoSQL, XSS stocké, débordement de mémoire par des payloads géants, et contournement de règles métier. Sans validation robuste, n’importe quel champ de formulaire devient une surface d’attaque.

Selon l’OWASP, les défauts de validation d’entrées alimentent directement les catégories A03 (Injection) et A07 (Identification failures) du Top 10 2025. En pratique, un champ email non validé dans une requête MongoDB peut retourner l’intégralité de la base si l’opérateur $where n’est pas bloqué. Une valeur age acceptée sans contrôle de type peut provoquer une erreur non gérée qui expose la stack trace au client.

Node.js traite nativement les corps de requête comme des chaînes ou des objets JSON bruts. Express ne valide rien par défaut. Toute la logique de contrôle doit donc être construite dans l’application, couche par couche. Les trois approches complémentaires couvertes ici sont : express-validator pour les validations par champ sur les routes Express, Joi pour les schémas complexes orientés objet, et Zod pour les projets TypeScript ou ceux qui veulent une inférence de type automatique.

L’ANSSI recommande depuis mars 2026 d’appliquer les correctifs Node.js dans les 48 heures suivant leur publication, notamment parce que plusieurs CVE récentes touchent le parsing des en-têtes HTTP et le traitement de payloads malformés. La validation applicative est le filet de sécurité qui intercepte les données malveillantes avant qu’elles n’atteignent la couche base de données. Node.js a corrigé huit vulnérabilités en janvier 2026, dont trois classées critiques, et a publié de nouvelles mises à jour de sécurité le 17 juin 2026 pour les branches 26.x, 24.x et 22.x.

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

Créez un nouveau dossier de projet, initialisez npm et installez toutes les bibliothèques de validation en une seule commande. Utilisez l’option --save-exact pour éviter les mises à jour de version non voulues en production.

mkdir node-validation-demo && cd node-validation-demo
npm init -y
npm install --save-exact \
  express@5 \
  express-validator@7 \
  joi@17 \
  zod@3 \
  express-mongo-sanitize@2 \
  dompurify@3 \
  jsdom@24 \
  multer@1 \
  helmet@8

Une fois l’installation terminée, vérifiez les versions installées avec npm list --depth=0. Votre package.json doit lister express 5.x et express-validator 7.x. Créez ensuite la structure de fichiers suivante pour ce tutoriel :

node-validation-demo/
├── src/
│   ├── app.js                   # Point d'entrée Express
│   ├── validators/
│   │   ├── user.validator.js    # Règles express-validator
│   │   ├── user.schema.joi.js   # Schéma Joi
│   │   └── user.schema.zod.js   # Schéma Zod
│   ├── middlewares/
│   │   ├── validate.js          # Middleware centralisé express-validator
│   │   ├── validate-schema.js   # Factory Joi / Zod
│   │   ├── sanitize-html.js     # Sanitisation XSS avec DOMPurify
│   │   └── upload.js            # Configuration Multer sécurisée
│   └── routes/
│       └── user.routes.js       # Routes Express
├── uploads/                     # Dossier pour les fichiers uploadés
└── package.json

Cette structure sépare clairement les responsabilités : les fichiers validators/ définissent les règles, les fichiers middlewares/ les appliquent et gèrent les erreurs, et les routes assemblent les deux. Cette séparation facilite les tests unitaires et la réutilisation des règles entre plusieurs routes.

Étape 2 : Configurer Express avec les middlewares de sécurité de base

Avant même d’ajouter la validation métier, configurez Express avec les middlewares de sécurité essentiels. Helmet configure automatiquement 15 en-têtes HTTP de sécurité en une seule ligne. La limite de taille du corps JSON à 10 Ko protège contre les attaques par déni de service via des payloads surdimensionnés.

// src/app.js
const express = require('express');
const helmet = require('helmet');
const mongoSanitize = require('express-mongo-sanitize');

const app = express();

// En-têtes de sécurité HTTP (CSP, HSTS, X-Frame-Options, etc.)
app.use(helmet());

// Parser JSON avec limite de taille stricte
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true, limit: '10kb' }));

// Supprime les opérateurs MongoDB des entrées ($where, $gt, $regex, etc.)
app.use(mongoSanitize({
  replaceWith: '_',
  onSanitize: ({ req, key }) => {
    console.warn(`[SÉCURITÉ] Tentative d'injection NoSQL bloquée : champ "${key}"`);
  }
}));

const userRoutes = require('./routes/user.routes');
app.use('/api/users', userRoutes);

module.exports = app;

La ligne express.json({ limit: '10kb' }) est fondamentale. Sans limite, un attaquant peut envoyer un payload JSON de plusieurs centaines de mégaoctets pour saturer la mémoire du processus Node.js. Helmet configure notamment l’en-tête X-Content-Type-Options: nosniff, qui empêche le navigateur d’interpréter des réponses JSON comme du HTML exécutable. Pour une configuration complète de Helmet, consultez notre guide sur les en-têtes de sécurité HTTP en Node.js.

Étape 3 : Valider les champs simples avec express-validator

express-validator est la bibliothèque de validation la plus utilisée dans l’écosystème Express, avec plus de 25 millions de téléchargements hebdomadaires sur npm. Sa version 7 introduit une API entièrement basée sur les promesses et une meilleure prise en charge des middlewares asynchrones d’Express 5.

Créez le fichier de règles de validation pour l’inscription d’un utilisateur :

// src/validators/user.validator.js
const { body } = require('express-validator');

const registerValidation = [
  body('email')
    .isEmail()
    .withMessage('Adresse e-mail invalide')
    .normalizeEmail()
    .isLength({ max: 254 })
    .withMessage('E-mail trop long (max 254 caractères RFC 5321)'),

  body('username')
    .isString()
    .withMessage("Le nom d'utilisateur doit être une chaîne")
    .trim()
    .escape()
    .isLength({ min: 3, max: 30 })
    .withMessage("Nom d'utilisateur : entre 3 et 30 caractères")
    .matches(/^[a-zA-Z0-9_-]+$/)
    .withMessage('Caractères autorisés : lettres, chiffres, _ et -'),

  body('age')
    .optional()
    .isInt({ min: 13, max: 120 })
    .withMessage("L'âge doit être un entier entre 13 et 120")
    .toInt(),

  body('password')
    .isLength({ min: 12 })
    .withMessage('Mot de passe : minimum 12 caractères')
    .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])/)
    .withMessage('Le mot de passe doit contenir majuscule, minuscule, chiffre et symbole'),
];

module.exports = { registerValidation };

Détaillons les méthodes clés. La méthode .normalizeEmail() convertit l’adresse en minuscules et supprime les alias courants (les points dans les adresses Gmail). La méthode .escape() remplace les caractères HTML dangereux (<, >, &, ") par leurs entités HTML. La méthode .toInt() caste la valeur en nombre entier JavaScript, évitant les comparaisons de type chaîne dans la logique métier.

La règle .matches(/^[a-zA-Z0-9_-]+$/) sur le nom d’utilisateur applique une liste blanche de caractères autorisés. C’est l’approche recommandée par l’OWASP : valider ce qui est accepté plutôt que de chercher à bloquer ce qui est interdit. Un attaquant peut toujours trouver un vecteur d’injection non anticipé ; une liste blanche réduit drastiquement la surface d’attaque.

Étape 4 : Créer le middleware de gestion des erreurs de validation

La validation ne sert à rien si les erreurs ne sont pas interceptées et renvoyées correctement au client. Créez un middleware réutilisable qui vérifie le résultat de validation après chaque règle et renvoie une réponse 400 structurée si des erreurs sont présentes.

// src/middlewares/validate.js
const { validationResult } = require('express-validator');

const validate = (req, res, next) => {
  const errors = validationResult(req);

  if (!errors.isEmpty()) {
    return res.status(400).json({
      status: 'error',
      message: 'Données invalides',
      errors: errors.array().map(err => ({
        field: err.path,
        message: err.msg,
        value: err.value
      }))
    });
  }

  next();
};

module.exports = validate;

Voici un exemple de réponse renvoyée quand le formulaire contient des erreurs :

{
  "status": "error",
  "message": "Données invalides",
  "errors": [
    {
      "field": "email",
      "message": "Adresse e-mail invalide",
      "value": "pas-un-email"
    },
    {
      "field": "password",
      "message": "Mot de passe : minimum 12 caractères",
      "value": "court"
    }
  ]
}

Cette réponse structurée permet au client (un front-end React ou Vue.js) d’afficher les erreurs par champ sans analyser un message générique. Notez que la clé value expose la valeur soumise : dans un formulaire public, vous pouvez supprimer ce champ pour éviter de renvoyer des données sensibles comme un mot de passe. En environnement de développement, il est utile de le conserver pour déboguer.

Connectez maintenant les règles et le middleware à une route Express :

// src/routes/user.routes.js
const express = require('express');
const { registerValidation } = require('../validators/user.validator');
const validate = require('../middlewares/validate');

const router = express.Router();

router.post('/register',
  registerValidation,  // Tableau de règles express-validator
  validate,            // Middleware qui intercepte les erreurs
  async (req, res) => {
    // Ici, req.body contient uniquement des données validées et sanitisées
    const { email, username, age, password } = req.body;

    // Logique métier : création de l'utilisateur en base de données
    // ...

    res.status(201).json({
      status: 'success',
      message: 'Utilisateur créé avec succès'
    });
  }
);

module.exports = router;

Étape 5 : Valider des schémas complexes avec Joi

Pour les structures JSON imbriquées (tableaux d’objets, schémas conditionnels, dépendances entre champs), Joi offre une expressivité supérieure à express-validator. Joi permet de définir un schéma complet en un seul objet et de valider l’intégralité du corps de la requête en une seule opération.

// src/validators/user.schema.joi.js
const Joi = require('joi');

const createOrderSchema = Joi.object({
  userId: Joi.string().uuid({ version: 'uuidv4' }).required(),

  items: Joi.array().items(
    Joi.object({
      productId: Joi.string().alphanum().min(8).max(24).required(),
      quantity: Joi.number().integer().min(1).max(99).required(),
      unitPrice: Joi.number().positive().precision(2).required()
    })
  ).min(1).max(50).required(),

  shippingAddress: Joi.object({
    street: Joi.string().max(100).required(),
    city: Joi.string().max(50).required(),
    postalCode: Joi.string()
      .pattern(/^\d{5}$/)
      .required()
      .messages({ 'string.pattern.base': 'Code postal français : 5 chiffres requis' }),
    country: Joi.string().valid('FR', 'BE', 'CH', 'LU').default('FR')
  }).required(),

  couponCode: Joi.string()
    .alphanum()
    .length(10)
    .optional()
    .when('items', {
      is: Joi.array().min(3),
      then: Joi.optional(),
      otherwise: Joi.forbidden()
    })
});

const validateOrder = (req, res, next) => {
  const { error, value } = createOrderSchema.validate(req.body, {
    abortEarly: false,    // Collecte toutes les erreurs, pas seulement la première
    stripUnknown: true,   // Supprime les champs non définis dans le schéma
    convert: true         // Convertit les types si possible (string vers number)
  });

  if (error) {
    return res.status(400).json({
      status: 'error',
      errors: error.details.map(d => ({
        field: d.path.join('.'),
        message: d.message
      }))
    });
  }

  req.body = value;
  next();
};

module.exports = { validateOrder, createOrderSchema };

L’option stripUnknown: true est fondamentale pour la sécurité. Elle supprime automatiquement tous les champs non déclarés dans le schéma Joi, ce qui empêche les attaques de mass assignment : un attaquant ne peut pas injecter un champ isAdmin: true si ce champ n’est pas présent dans le schéma. La règle .when() sur couponCode illustre les validations conditionnelles : un code promo n’est autorisé que si la commande contient au moins 3 articles, et est interdit (Joi.forbidden()) dans le cas contraire.

Étape 6 : Validation type-safe avec Zod

Zod est particulièrement adapté aux projets TypeScript ou aux projets JavaScript qui souhaitent bénéficier de l’inférence de type sans compiler TypeScript. La bibliothèque génère automatiquement les types TypeScript à partir du schéma de validation, éliminant la duplication entre les définitions de types et les règles de validation. Zod comptait plus de 22 millions de téléchargements hebdomadaires sur npm en 2026.

// src/validators/user.schema.zod.js
const { z } = require('zod');

const loginSchema = z.object({
  email: z
    .string({ required_error: "L'e-mail est obligatoire" })
    .email({ message: "Format d'e-mail invalide" })
    .toLowerCase()
    .trim(),

  password: z
    .string({ required_error: 'Le mot de passe est obligatoire' })
    .min(12, 'Minimum 12 caractères')
    .max(128, 'Maximum 128 caractères'),

  rememberMe: z.boolean().default(false),

  totpToken: z
    .string()
    .length(6, 'Le code TOTP doit contenir 6 chiffres')
    .regex(/^\d{6}$/, 'Code TOTP : 6 chiffres uniquement')
    .optional()
});

const validateLogin = (req, res, next) => {
  const result = loginSchema.safeParse(req.body);

  if (!result.success) {
    const errors = result.error.issues.map(issue => ({
      field: issue.path.join('.'),
      message: issue.message
    }));

    return res.status(400).json({ status: 'error', errors });
  }

  req.body = result.data;
  next();
};

module.exports = { validateLogin, loginSchema };

La méthode safeParse() de Zod ne lance jamais d’exception, contrairement à parse(). Elle retourne un objet { success: true, data } ou { success: false, error }, ce qui s’intègre proprement dans un flux async/await sans bloc try/catch supplémentaire. La transformation .toLowerCase().trim() est appliquée directement dans le schéma, garantissant que les données dans result.data sont déjà normalisées. La validation du code TOTP à 6 chiffres s’intègre naturellement dans le schéma de connexion, complémentant l’authentification à deux facteurs couverte dans notre article sur l’authentification à deux facteurs dans Node.js.

Étape 7 : Protéger contre les attaques XSS côté serveur

La méthode .escape() d’express-validator protège contre le XSS réfléchi en encodant les caractères HTML. Mais si votre application doit accepter et stocker du HTML (un éditeur de contenu riche, par exemple), vous avez besoin d’une sanitisation HTML complète, pas d’un simple encodage. DOMPurify avec jsdom permet de filtrer le HTML côté serveur en conservant les balises autorisées et en supprimant les scripts, gestionnaires d’événements et attributs dangereux.

// src/middlewares/sanitize-html.js
const { JSDOM } = require('jsdom');
const createDOMPurify = require('dompurify');

// Crée une instance DOMPurify avec un environnement DOM virtuel
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);

// Liste blanche stricte des balises HTML autorisées
const ALLOWED_TAGS = ['b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'ol', 'li', 'br'];
const ALLOWED_ATTR = ['href', 'title'];

const sanitizeHtmlContent = (content) => {
  if (typeof content !== 'string') return '';

  return DOMPurify.sanitize(content, {
    ALLOWED_TAGS,
    ALLOWED_ATTR,
    ALLOW_DATA_ATTR: false,   // Interdit data-* attributes
    FORBID_SCRIPTS: true,     // Bloque toute balise script
    FORBID_TAGS: ['style'],   // Bloque le CSS inline
  });
};

const sanitizeRichText = (req, res, next) => {
  const RICH_TEXT_FIELDS = ['bio', 'description', 'content'];

  RICH_TEXT_FIELDS.forEach(field => {
    if (req.body[field]) {
      req.body[field] = sanitizeHtmlContent(req.body[field]);
    }
  });

  next();
};

module.exports = { sanitizeRichText, sanitizeHtmlContent };

Exemple concret : un attaquant soumet la valeur suivante pour le champ bio :

// Entrée malveillante
"<p>Bonjour</p><script>document.cookie='stolen='+document.cookie</script><img src=x onerror=alert(1)>"

// Après sanitisation DOMPurify
"<p>Bonjour</p>"

DOMPurify supprime la balise <script> et l’attribut onerror de la balise <img>, tout en conservant le contenu HTML légitime. Sans cette sanitisation, le script serait stocké en base et exécuté dans le navigateur de chaque utilisateur consultant le profil, réalisant une attaque XSS stocké. Ce type de vulnérabilité figure régulièrement dans les rapports de bug bounty avec une sévérité CVSS entre 7 et 9.

Étape 8 : Prévenir les injections NoSQL avec MongoDB

Les injections NoSQL dans MongoDB exploitent les opérateurs de requête ($where, $gt, $regex) pour modifier la logique des requêtes. Un exemple classique : un attaquant envoie { "email": { "$gt": "" } } comme corps de requête de connexion, ce qui retourne le premier utilisateur de la base sans vérification de mot de passe. Cette attaque est triviale à mener et n’apparaît pas dans les logs applicatifs sans monitoring spécifique.

// Protection contre l'injection NoSQL avec validation de type
const { body } = require('express-validator');

const loginValidation = [
  body('email')
    .isString()           // Interdit les objets JSON comme valeur d'email
    .withMessage("L'e-mail doit être une chaîne de caractères")
    .isEmail()
    .withMessage("Format d'e-mail invalide")
    .normalizeEmail(),

  body('password')
    .isString()           // Interdit { "$regex": ".*" } comme mot de passe
    .withMessage('Le mot de passe doit être une chaîne')
    .isLength({ min: 1, max: 128 })
];

// Dans le contrôleur, utilisez un cast explicite en string
const loginUser = async (req, res) => {
  const { email, password } = req.body;

  // SÉCURISÉ : cast explicite avant la requête Mongoose
  const user = await User.findOne({
    email: String(email)  // Double protection contre l'injection NoSQL
  });

  // DANGEREUX : ne jamais passer req.body directement à MongoDB
  // const user = await User.findOne(req.body);
};

La combinaison des trois niveaux de protection offre une défense en profondeur : express-mongo-sanitize supprime les opérateurs au niveau du middleware global, la validation .isString() rejette les valeurs non-string au niveau de la route, et le cast explicite String(email) garantit le type au niveau du contrôleur. La protection CSRF couvre un vecteur d’attaque complémentaire et est détaillée dans notre guide sur la protection CSRF dans Node.js.

Étape 9 : Valider les fichiers uploadés avec Multer

Les uploads de fichiers constituent une surface d’attaque souvent sous-estimée. Sans validation, un attaquant peut uploader un fichier PHP déguisé en image JPEG, un exécutable malveillant, ou un fichier de 10 Go pour saturer le disque. Multer doit être configuré avec des filtres stricts sur le type MIME, la taille et le nom du fichier.

// src/middlewares/upload.js
const multer = require('multer');
const path = require('path');
const crypto = require('crypto');

const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 Mo maximum

const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, 'uploads/');
  },
  filename: (req, file, cb) => {
    // Nom de fichier aléatoire : évite les collisions et le path traversal
    const randomName = crypto.randomBytes(16).toString('hex');
    const ext = path.extname(file.originalname).toLowerCase();
    cb(null, `${randomName}${ext}`);
  }
});

const fileFilter = (req, file, cb) => {
  if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) {
    return cb(
      new Error(`Type de fichier interdit. Formats acceptés : JPEG, PNG, WebP`),
      false
    );
  }

  const ext = path.extname(file.originalname).toLowerCase();
  if (!['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) {
    return cb(new Error('Extension de fichier invalide'), false);
  }

  cb(null, true);
};

const upload = multer({
  storage,
  fileFilter,
  limits: {
    fileSize: MAX_FILE_SIZE,
    files: 1              // Maximum 1 fichier par requête
  }
});

module.exports = upload;

Le nommage aléatoire des fichiers avec crypto.randomBytes(16) est une protection contre les attaques par chemin de traversée (path traversal). Un fichier nommé ../../../../etc/passwd par l’attaquant sera renommé en a3f7c82d1e09b456.jpg avant d’être écrit sur le disque, empêchant toute écriture hors du dossier uploads/. En production, ajoutez une vérification des magic bytes avec la bibliothèque file-type pour confirmer le format réel du fichier, indépendamment du type MIME déclaré.

Étape 10 : Créer un middleware de validation centralisé et réutilisable

Plutôt que de répéter la logique de validation dans chaque route, créez une factory function qui génère un middleware de validation à partir d’un schéma Joi ou Zod. Cette approche centralise la gestion des erreurs et facilite les tests unitaires.

// src/middlewares/validate-schema.js

// Factory pour les schémas Joi (body, query, params)
const validateJoi = (schema, target = 'body') => {
  return (req, res, next) => {
    const { error, value } = schema.validate(req[target], {
      abortEarly: false,
      stripUnknown: true,
      convert: true
    });

    if (error) {
      return res.status(400).json({
        status: 'error',
        source: target,
        errors: error.details.map(d => ({
          field: d.path.join('.'),
          message: d.message.replace(/"/g, "'")
        }))
      });
    }

    req[target] = value;
    next();
  };
};

// Factory pour les schémas Zod
const validateZod = (schema, target = 'body') => {
  return (req, res, next) => {
    const result = schema.safeParse(req[target]);

    if (!result.success) {
      return res.status(400).json({
        status: 'error',
        source: target,
        errors: result.error.issues.map(issue => ({
          field: issue.path.join('.'),
          message: issue.message
        }))
      });
    }

    req[target] = result.data;
    next();
  };
};

module.exports = { validateJoi, validateZod };

Utilisation dans les routes pour valider différentes sources de données :

const { z } = require('zod');
const Joi = require('joi');
const { validateJoi, validateZod } = require('../middlewares/validate-schema');
const { createOrderSchema } = require('../validators/user.schema.joi');

// Valider le body JSON avec Joi
router.post('/orders', validateJoi(createOrderSchema), createOrderController);

// Valider les paramètres d'URL avec Zod
const idSchema = z.object({ id: z.string().uuid("ID utilisateur invalide") });
router.get('/users/:id', validateZod(idSchema, 'params'), getUserController);

// Valider les query strings avec Joi
const paginationSchema = Joi.object({
  page: Joi.number().integer().min(1).default(1),
  limit: Joi.number().integer().min(1).max(100).default(20),
  sort: Joi.string().valid('asc', 'desc').default('desc')
});
router.get('/products', validateJoi(paginationSchema, 'query'), listProductsController);

Étape 11 : Tests unitaires de la validation

La validation est une logique critique qui doit être couverte par des tests. Un schéma de validation cassé peut exposer l’application à des injections ou provoquer des régressions silencieuses. Utilisez Jest ou le test runner natif de Node.js (disponible depuis Node.js 20) pour tester les schémas sans démarrer le serveur.

// tests/user.validator.test.js
const { createOrderSchema } = require('../src/validators/user.schema.joi');

describe('Schéma de commande Joi', () => {
  const validOrder = {
    userId: '550e8400-e29b-41d4-a716-446655440000',
    items: [
      { productId: 'PROD12345678', quantity: 2, unitPrice: 19.99 }
    ],
    shippingAddress: {
      street: '12 rue de la Paix',
      city: 'Paris',
      postalCode: '75001',
      country: 'FR'
    }
  };

  test('Accepte une commande valide', () => {
    const { error } = createOrderSchema.validate(validOrder);
    expect(error).toBeUndefined();
  });

  test('Rejette un UUID malformé', () => {
    const { error } = createOrderSchema.validate({
      ...validOrder, userId: 'pas-un-uuid'
    });
    expect(error).toBeDefined();
    expect(error.details[0].path).toContain('userId');
  });

  test('Rejette un code postal à 4 chiffres', () => {
    const { error } = createOrderSchema.validate({
      ...validOrder,
      shippingAddress: { ...validOrder.shippingAddress, postalCode: '1234' }
    });
    expect(error).toBeDefined();
  });

  test('Supprime les champs inconnus (stripUnknown)', () => {
    const { value } = createOrderSchema.validate(
      { ...validOrder, isAdmin: true, secret: 'payload' },
      { stripUnknown: true }
    );
    expect(value.isAdmin).toBeUndefined();
    expect(value.secret).toBeUndefined();
  });

  test('Interdit le couponCode avec moins de 3 articles', () => {
    const { error } = createOrderSchema.validate({
      ...validOrder,
      couponCode: 'PROMO12345'  // 1 seul article, coupon interdit
    });
    expect(error).toBeDefined();
  });
});

Le test stripUnknown est particulièrement important : il vérifie que le comportement anti-mass-assignment fonctionne bien et que des champs injectés comme isAdmin sont effectivement supprimés. Ce test doit faire partie de la pipeline CI/CD pour garantir qu’aucun refactoring ne réintroduit une vulnérabilité. Pour renforcer la sécurité de la gestion des sessions en lien avec la validation, consultez notre guide sur la gestion des sessions dans Node.js.

Étape 12 : Gestionnaire d’erreurs global et déploiement sécurisé

En production, quelques configurations supplémentaires renforcent la robustesse de la validation. Express 5 gère nativement les erreurs asynchrones, mais il faut quand même configurer un gestionnaire d’erreurs global pour les erreurs de Multer et les rejets non capturés.

// Gestionnaire d'erreurs global Express (après toutes les routes)
app.use((err, req, res, next) => {
  // Erreurs Multer : fichier trop volumineux
  if (err.code === 'LIMIT_FILE_SIZE') {
    return res.status(413).json({
      status: 'error',
      message: 'Fichier trop volumineux (maximum 5 Mo)'
    });
  }

  // Type MIME interdit
  if (err.message && err.message.includes('Type de fichier interdit')) {
    return res.status(415).json({
      status: 'error',
      message: err.message
    });
  }

  // Corps JSON malformé
  if (err.type === 'entity.parse.failed') {
    return res.status(400).json({
      status: 'error',
      message: 'Corps de la requête JSON invalide'
    });
  }

  // Erreur générique : ne jamais exposer la stack trace en production
  console.error('[ERROR]', err.message);
  res.status(500).json({
    status: 'error',
    message: 'Erreur interne du serveur'
  });
});

// Protection contre les rejets de promesse non capturés
process.on('unhandledRejection', (reason) => {
  console.error('[UNHANDLED REJECTION]', reason);
});

En production, activez NODE_ENV=production. Express et Helmet ajustent leur comportement dans ce mode : Express supprime les en-têtes qui exposent la version du framework, et les messages d’erreur deviennent moins verbeux. Combinez cette configuration avec les bonnes pratiques de rate limiting dans Node.js pour limiter le nombre de tentatives de validation par IP et détecter les scans automatisés.

Comparaison des bibliothèques de validation Node.js en 2026

Chaque bibliothèque de validation a ses forces et ses cas d’usage optimaux. Le tableau suivant compare les principales options disponibles dans l’écosystème Node.js en 2026 :

BibliothèqueTéléchargements/semaineTaille bundleTypeScript natifMeilleur usage
express-validator 725M+~25 KoTypes fournisValidation par champ sur routes Express
Joi 1712M+~145 KoTypes fournisSchémas complexes, conditions entre champs
Zod 322M+~55 KoInférence nativeProjets TypeScript, monorepo front+back
Yup9M+~40 KoTypes fournisFormulaires React/front-end avec Formik
class-validator5M+~60 KoDécorateurs TSNestJS avec décorateurs TypeScript

Pour un projet Express classique sans TypeScript, express-validator est le choix naturel : il s’intègre directement comme middleware, sa syntaxe chaînée est lisible, et sa communauté large garantit une documentation abondante. Pour des schémas imbriqués complexes ou des validations conditionnelles, Joi offre une expressivité supérieure. Pour les nouveaux projets TypeScript, Zod est devenu le standard de facto en 2025-2026 grâce à l’inférence de type automatique.

6 pièges courants à éviter absolument

Piège 1 : Valider sans sanitiser. Beaucoup de développeurs ajoutent .isEmail() mais oublient .normalizeEmail() et .escape(). La validation confirme que la valeur respecte un format attendu, mais sans sanitisation, la valeur validée peut contenir des caractères dangereux. Ces deux étapes sont complémentaires et doivent toujours être combinées.

Piège 2 : Ne valider que le body et oublier query et params. Les paramètres d’URL (req.params.id) et les query strings (req.query.page) sont aussi des entrées utilisateur. Une injection peut passer par GET /users/../../etc/passwd ou GET /products?limit=999999. Validez systématiquement les trois sources.

Piège 3 : Faire confiance au type MIME des fichiers uploadés. Le champ file.mimetype de Multer provient de l’en-tête Content-Type envoyé par le client, qui peut être falsifié avec n’importe quel outil HTTP. Un fichier PHP renommé photo.jpg avec le type MIME image/jpeg passera le filtre Multer de base. Utilisez file-type pour vérifier les magic bytes réels du fichier.

Piège 4 : Exposer les détails d’erreur en production. Les messages d’erreur de validation Joi contiennent par défaut des informations sur la structure attendue du schéma. En production, ces informations aident l’attaquant à affiner ses payloads. Filtrez ou généralisez les messages d’erreur pour les routes publiques, et conservez les messages détaillés uniquement dans les logs serveur.

Piège 5 : Appliquer la validation uniquement à l’entrée et oublier la sortie. Si votre API retourne des données de la base directement dans la réponse sans filtrer les champs sensibles (mot de passe hashé, token de réinitialisation, rôles internes), vous exposez des informations critiques. Utilisez les propriétés select: false de Mongoose ou une bibliothèque de sérialisation pour contrôler ce qui sort.

Piège 6 : Utiliser une liste noire au lieu d’une liste blanche. Interdire <script> et onerror n’est pas suffisant : il existe des dizaines de vecteurs XSS alternatifs (<svg/onload>, <math href="javascript:...">, encodages Unicode, etc.). La liste noire est un jeu du chat et de la souris perdu d’avance. Utilisez toujours une liste blanche de caractères et de balises autorisés.

Dépannage : 8 problèmes fréquents

Problème 1 : validationResult(req) retourne toujours vide. Vérifiez que les règles de validation sont bien passées comme tableau dans la définition de la route, avant le middleware validate. Si les règles sont importées comme objet et non comme tableau, express-validator ne les exécute pas. Vérifiez également que le body parser (express.json()) est configuré avant les routes.

Problème 2 : .escape() encode les données stockées en base. La méthode .escape() encode les caractères HTML : < devient &lt;. Si ces données sont stockées dans ce format encodé, elles s’afficheront avec les entités HTML dans une application React qui utilise dangerouslySetInnerHTML. Solution : appliquer .escape() uniquement pour les champs affichés dans des templates HTML côté serveur. Pour les APIs JSON consommées par du JavaScript, préférez sanitiser à la sortie.

Problème 3 : Joi retourne des messages en anglais. Joi génère ses messages d’erreur en anglais par défaut. Pour les franciser, utilisez le paramètre messages lors de la validation. Vous pouvez créer un fichier de messages globaux réutilisable : Joi.defaults(schema => schema.messages({ 'string.email': '{{#label}} doit être un e-mail valide' })).

Problème 4 : Multer retourne LIMIT_FILE_SIZE mais la taille semble correcte. La limite de Multer s’applique à la taille brute du payload HTTP, pas au fichier décodé. Un fichier de 4,8 Mo encodé en multipart peut légèrement dépasser la limite de 5 Mo à cause des headers multipart. Réduisez légèrement la limite (4.5 * 1024 * 1024) ou augmentez-la si votre cas d’usage le justifie.

Problème 5 : express-mongo-sanitize bloque des données légitimes. La bibliothèque supprime tout champ dont le nom commence par $ ou contient un point. Si votre API accepte des noms de champs dynamiques avec des points (des chemins de propriétés imbriquées), configurez l’option allowDots: false ou gérez ces cas avant le middleware global.

Problème 6 : Zod safeParse échoue sur des types JSON valides. Zod est strict sur les types. Un champ numérique envoyé comme chaîne ("age": "25" au lieu de "age": 25) échouera à la validation z.number(). Pour accepter les deux, utilisez z.coerce.number() qui tente une conversion automatique. Pour les query strings, qui sont toujours des chaînes, z.coerce est quasi obligatoire.

Problème 7 : La validation passe mais les données en base sont incorrectes. Si vous utilisez Mongoose, son schéma applique sa propre validation et ses casts. Dans certains cas, Mongoose peut modifier des valeurs après votre validation (notamment les nombres et les booleans). Testez le flux complet avec des données réelles et vérifiez ce qui est réellement stocké.

Problème 8 : DOMPurify côté serveur ne fonctionne pas. DOMPurify est conçu pour le navigateur. Pour une utilisation côté serveur avec Node.js, il faut obligatoirement passer une fenêtre DOM via jsdom, comme dans l’exemple de l’étape 7. Si vous obtenez l’erreur DOMPurify is not a function ou des résultats vides, vérifiez que vous utilisez bien createDOMPurify(window) avec l’import correct : const createDOMPurify = require('dompurify').

Conseils avancés pour la validation en production

Validation OpenAPI automatique. Si votre API est documentée avec une spécification OpenAPI 3.x, des bibliothèques comme express-openapi-validator peuvent générer automatiquement les middlewares de validation à partir de votre spec YAML. Cette approche garantit que la validation reste synchronisée avec la documentation, éliminant les dérives entre les deux.

Rate limiting sur les routes de validation. Les erreurs de validation répétées depuis une même IP signalent souvent un fuzzing automatisé. Combinez la validation avec un rate limiter par IP et par route pour détecter et bloquer ces scans. Notre guide sur le rate limiting dans Node.js couvre la mise en place avec express-rate-limit et Redis pour les environnements distribués.

Logging structuré des erreurs de validation. Enregistrez les erreurs de validation avec l’IP source, l’user agent, le champ concerné et la valeur rejetée (en masquant les mots de passe). Ces logs permettent de détecter des patterns d’attaque et de répondre aux exigences d’audit RGPD en cas d’incident. Utilisez Pino ou Winston pour générer des logs JSON analysables par ELK Stack ou Datadog.

Validation côté client ET côté serveur. La validation côté client améliore l’expérience utilisateur. Mais n’importe quel outil HTTP (curl, Postman, Burp Suite) peut contourner la validation front-end. Les deux niveaux sont complémentaires. Si vous utilisez Zod, partagez le même schéma entre le front-end et le back-end dans un monorepo pour une cohérence parfaite des règles.

Versioning des schémas de validation. Les schémas évoluent avec votre API. Pour les APIs à plusieurs versions (/v1, /v2), créez des schémas distincts par version. Un changement de validation peut casser des clients légitimes si les règles deviennent plus strictes sans préavis. La stratégie de versioning doit être documentée dans votre changelog et communiquée aux consommateurs de l’API.

Projet complet : démarrage rapide

Voici le point d’entrée complet intégrant toutes les couches de sécurité couvertes dans ce tutoriel. Ce fichier src/app.js peut servir de base pour n’importe quelle API Node.js en production :

// src/app.js - Application complète avec toutes les couches de validation
const express = require('express');
const helmet = require('helmet');
const mongoSanitize = require('express-mongo-sanitize');

const app = express();

// COUCHE 1 : En-têtes de sécurité HTTP
app.use(helmet());

// COUCHE 2 : Parsing avec limites strictes
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true, limit: '10kb' }));

// COUCHE 3 : Sanitisation NoSQL globale
app.use(mongoSanitize({ replaceWith: '_' }));

// COUCHE 4 : Routes avec validation par route
app.use('/api/users', require('./routes/user.routes'));

// COUCHE 5 : Gestionnaire d'erreurs global
app.use((err, req, res, next) => {
  if (err.code === 'LIMIT_FILE_SIZE')
    return res.status(413).json({ status: 'error', message: 'Fichier trop volumineux' });
  if (err.type === 'entity.parse.failed')
    return res.status(400).json({ status: 'error', message: 'JSON invalide' });
  console.error('[ERROR]', err.message);
  res.status(500).json({ status: 'error', message: 'Erreur interne' });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`API démarrée sur le port ${PORT}`));

Test de l’API avec curl pour valider le bon fonctionnement :

# Test d'une inscription valide
curl -X POST http://localhost:3000/api/users/register \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","username":"alice_dev","password":"MotDePasse$ecur1t3","age":28}'

# Réponse attendue
{"status":"success","message":"Utilisateur créé avec succès"}

# Test avec données invalides
curl -X POST http://localhost:3000/api/users/register \
  -H 'Content-Type: application/json' \
  -d '{"email":"pas-un-email","username":"a","password":"court"}'

# Réponse attendue
{
  "status": "error",
  "message": "Données invalides",
  "errors": [
    {"field":"email","message":"Adresse e-mail invalide","value":"pas-un-email"},
    {"field":"username","message":"Nom d'utilisateur : entre 3 et 30 caractères","value":"a"},
    {"field":"password","message":"Mot de passe : minimum 12 caractères","value":"court"}
  ]
}

# Test d'injection NoSQL bloquée
curl -X POST http://localhost:3000/api/users/login \
  -H 'Content-Type: application/json' \
  -d '{"email":{"$gt":""},"password":"nimportequoi"}'

# Réponse attendue : 400 - L'e-mail doit être une chaîne de caractères

Ressources de référence

FAQ sur la validation des données dans Node.js

Quelle est la différence entre validation et sanitisation ?

La validation vérifie que les données respectent un format attendu (email valide, nombre dans une plage, chaîne d’une certaine longueur) et rejette les données non conformes avec une erreur. La sanitisation transforme les données pour les rendre sûres, sans nécessairement les rejeter (conversion en minuscules, suppression de caractères HTML, suppression des espaces). Les deux opérations sont complémentaires et doivent toujours être appliquées ensemble.

express-validator ou Joi : lequel choisir en 2026 ?

express-validator est idéal pour les validations par champ simples directement sur les routes Express. Joi est préférable pour les schémas d’objets complexes avec des règles conditionnelles, des tableaux imbriqués ou des dépendances entre champs. Dans une application de taille moyenne, les deux coexistent souvent : express-validator pour les routes de formulaire simples, Joi pour les endpoints API à structures complexes, Zod pour les projets TypeScript.

La validation Node.js protège-t-elle contre les injections SQL ?

La validation est une couche de défense complémentaire, pas un substitut aux requêtes paramétrées. Pour SQL, la protection principale passe par les requêtes préparées (pg, mysql2, Knex) ou un ORM (Prisma, Sequelize) qui échappe automatiquement les valeurs. La validation des entrées intercepte les payloads manifestement malveillants avant qu’ils n’atteignent la couche base de données, mais ne remplace pas la paramétrisation.

Comment valider des données dans un microservice Node.js ?

Dans une architecture microservices, chaque service doit valider ses propres entrées, y compris les messages reçus des autres services via une queue (Kafka, RabbitMQ). Ne faites pas confiance aux données provenant d’un autre service interne : si ce service est compromis, des données malveillantes peuvent se propager. Utilisez des schémas Zod ou Joi partagés dans une bibliothèque interne commune à tous les services pour garantir la cohérence.

La validation côté client remplace-t-elle la validation côté serveur ?

Non. La validation côté client peut être contournée en quelques secondes avec n’importe quel outil HTTP ou en désactivant JavaScript dans le navigateur. La validation côté serveur est obligatoire. La validation côté client est un bonus d’expérience utilisateur, pas une mesure de sécurité.

Comment valider les variables d’environnement au démarrage de Node.js ?

Utilisez Zod ou Joi pour valider les variables d’environnement au démarrage, avant de lancer le serveur Express. Créez un fichier src/env.js qui parse process.env avec un schéma Zod et lance une erreur explicite si une variable requise est manquante ou mal formatée. Cette pratique évite que l’application démarre avec une configuration incorrecte et plante silencieusement en production.

Comment gérer la validation dans un formulaire multipart avec fichiers et JSON simultanément ?

Quand une route accepte à la fois des fichiers (multipart/form-data) et des données JSON, vous ne pouvez pas utiliser express.json() et Multer simultanément sur la même route. Configurez Multer en premier pour parser le multipart, puis accédez aux champs texte via req.body (qui contient les champs non-fichier du formulaire multipart) et validez-les avec express-validator ou Joi. Les champs JSON imbriqués doivent être envoyés comme des chaînes stringifiées dans le formulaire multipart et parsés manuellement.

Couverture associée

Articles liés