Accueil / Articles / Renforcement de la sécurité de Node.js : attaques DoS, injections et défense contre les conteneurs

Renforcement de la sécurité de Node.js : attaques DoS, injections et défense contre les conteneurs

Apprenez des techniques pratiques pour protéger les services Node.js contre les attaques DoS et ReDoS du cycle d’événements, les attaques par injection, ainsi que les conteneurs non sécurisés, en utilisant la limitation de débit et Helmet.

1457 mots

Même un backend disposant d’une couverture de tests excellente et d’une architecture propre peut être mis hors ligne en un instant s’il est vulnérable à la saturation du boucle d’événements, aux attaques par injection ou à un environnement de conteneur mal sécurisé.

Puisque Node.js s’exécute sur une boucle d’événements à thread unique, les faiblesses de sécurité dans un service Node.js se manifestent non seulement sous forme de fuites de données mais aussi par une indisponibilité totale — c’est-à-dire un refus de service complet.

1. Protéger la boucle d’événements : prévenir les refus de service (DoS)

Le risque opérationnel le plus grave dans Node.js est le blocage de la boucle d’événements. Si un attaquant soumet des données qui forcent le moteur de expressions régulières à effectuer un backtracking de type O(2^n), ou submerge le serveur avec des en-têtes de requête HTTP trop volumineux, la boucle d’événements s’arrête. Une fois bloquée, le processus cesse de répondre à toutes les requêtes, provenant de tous les clients, et pas seulement du client malveillant.

Incoming Request Floods ──┐
                          ▼
             ┌──────────────────────────┐
             │ Node.js Event Loop       │
             │ (Single Thread Execution)│
             └────────────┬─────────────┘
                          │ ❌ Synchronous CPU Block
                          ▼
             ┌──────────────────────────┐
             │ Event Loop Freeze        │ ──► All other concurrent requests
             │ (High Latency / DoS)     │     time out or drop!
             └──────────────────────────┘

Defense A : Limites strictes pour le corps de la requête et la charge utile

Lorsqu’elles restent à leurs valeurs par défaut, de nombreux middleware de parsing du corps accepteront volontiers des charges utiles suffisamment volumineuses pour épuiser la mémoire disponible ou consommer un nombre excessif de cycles CPU rien que pour parser les données d’entrée.

La mauvaise méthode : Parsing illimité du corps

// ❌ BAD Accepts arbitrarily large JSON payloads
app.use(express.json());

La bonne méthode : Imposer des limites à la charge utile

Configurez des plafonds de taille explicites tant au niveau de la couche d’application que sur le bord du réseau — dans votre proxy inversé ou votre couche d’ingestion, comme NGINX, Cloudflare ou un AWS Application Load Balancer.

// shared/middleware/security.js
const express = require('express');

// Strict payload limits per content type
const configurePayloadLimits = (app) => {
  // Limit standard JSON bodies to 100kb
  app.use(express.json({ limit: '100kb' }));

  // Limit URL-encoded forms to 50kb
  app.use(express.urlencoded({ extended: true, limit: '50kb' }));
};

module.exports = { configurePayloadLimits };

Defense B : Prévention des attaques ReDoS (Denial of Service par expression régulière)

Une vulnérabilité ReDoS apparaît lorsque votre code exécute une expression régulière à rétrogradation catastrophique sur des données contrôlées par un attaquant.

Pattern vulnérable : Quantificateurs imbriqués

// ❌ VULNERABLE: Exponential backtracking O(2^n)
const emailRegex = /^([a-zA-Z0-9_\.\-])+\@(([a-zA-Z0-9\-])+\.)+([a-zA-Z0-9]{2,4})+$/;

// An input like "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!" will hang the process for minutes!
emailRegex.test(userInput);

Pattern sûr : utiliser des bibliothèques validées ou des moteurs linéaires

Au lieu de développer soi-même des expressions régulières pour une logique de validation complexe, privilégiez des bibliothèques de schémas éprouvées comme Zod ou Joi, ou un package de validation dédié tel que validator.

const { z } = require('zod');

// Schema validation using safe built-in string parsers
const UserInputSchema = z.object({
  email: z.string().email().max(255), // Enforces linear-time length constraints
  username: z.string().alphanumeric().min(3).max(30)
});

function validatePayload(payload) {
  return UserInputSchema.parse(payload);
}

2. Prévention des attaques d’injection (SQL, NoSQL et commandes)

Les failles d’injection surviennent chaque fois que des valeurs non fiables sont intégrées directement dans les requêtes de base de données ou dans des commandes transmises au système d’exploitation.

La mauvaise méthode : concaténation de chaînes dans les requêtes

// ❌ VULNERABLE: Direct string interpolation (SQL Injection)
async function findUserByEmail(email) {
  const query = `SELECT * FROM users WHERE email = '${email}'`;
  return await db.query(query); // Attacker passes: "' OR '1'='1"
}

La bonne méthode : requêtes paramétrées et typage strict

Évitez de construire des requêtes en interpolant des paramètres bruts dans vos appels au pilote de base de données ou à un ORM. Utilisez des placeholders pour les paramètres — $1, $2 ou ? selon le pilote — afin que le moteur de base de données traite toujours les valeurs fournies comme de simples données, et jamais comme du SQL exécutable.

// ✅ GOOD: Parameterized SQL Execution
async function findUserByEmail(email) {
  const text = 'SELECT id, email, role, created_at FROM users WHERE email = $1';
  const values = [email];

  const result = await db.query(text, values);
  return result.rows[0] || null;
}

3. Limitation des débits distribuée via Redis

La limitation des débits n’est pas optionnelle pour une API sérieuse. L’en omettre revient à laisser la porte ouverte aux clients abusifs ou aux intégrations défaillantes pour solliciter intensément vos points d’entrée, épuisant les connexions de base de données et nuisant aux performances pour tous les autres utilisateurs.

Le problème, c’est que dans des environnements disposant de plusieurs instances en exécution — pensez aux pods Kubernetes derrière un équilibreur de charge — un limiteur de débit qui conserve ses compteurs en mémoire locale ne fonctionne tout simplement pas, car chaque processus possède son propre état isolé. Ce dont on a besoin, c’est plutôt d’un stockage partagé et distribué comme Redis, afin que chaque instance vérifie et mette à jour le même compteur atomique.

Client Requests ──► [ Pod 1 ] ──┐
                                ├──► [ Distributed Redis ]
Client Requests ──► [ Pod 2 ] ──┘    (Atomic Request Counter)

Mise en œuvre d’un limiteur de débit pour les entreprises

// shared/middleware/rateLimiter.js
const { RateLimiterRedis } = require('rate-limiter-flexible');
const redisClient = require('../database/redis');

// Create rate limiter instance: max 100 requests per 15 minutes per IP
const rateLimiter = new RateLimiterRedis({
  storeClient: redisClient,
  keyPrefix: 'rl_api',
  points: 100, // Maximum requests allowed
  duration: 15 * 60, // Per 15 minutes (in seconds)
  blockDuration: 60 * 5 // Block IP for 5 minutes if exceeded
});

const rateLimiterMiddleware = async (req, res, next) => {
  try {
    const clientIp = req.ip || req.headers['x-forwarded-for'];
    await rateLimiter.consume(clientIp);
    next();
  } catch (rejRes) {
    // Return standard HTTP 429 Too Many Requests
    res.setHeader('Retry-After', Math.round(rejRes.msBeforeNext / 1000) || 60);
    return res.status(429).json({
      status: 'error',
      code: 'TOO_MANY_REQUESTS',
      message: 'Rate limit exceeded. Please slow down your requests.'
    });
  }
};

module.exports = rateLimiterMiddleware;

4. Sécurité des en-têtes HTTP avec Helmet

Par défaut, les réponses d’Express révèlent des détails internes à travers des en-têtes tels que X-Powered-By: Express. Cet unique en-tête offre aux attaquants un raccourci : ils savent exactement quel framework vous utilisez et peuvent directement recourir à des exploits connus spécifiques à ce framework.

La solution consiste à appliquer un ensemble renforcé d’en-têtes de réponse par défaut à l’aide du middleware helmet.

// shared/middleware/securityHeaders.js
const helmet = require('helmet');

function applySecurityHeaders(app) {
  app.use(
    helmet({
      // Hide frame options to mitigate Clickjacking
      frameguard: { action: 'deny' },
      // Strict Content Security Policy
      contentSecurityPolicy: {
        directives: {
          defaultSrc: ["'self'"],
          scriptSrc: ["'self'"],
          objectSrc: ["'none'"],
          upgradeInsecureRequests: []
        }
      },
      // Remove X-Powered-By header
      hidePoweredBy: true,
      // Enforce HTTPS
      hsts: {
        maxAge: 31536000, // 1 year
        includeSubDomains: true,
        preload: true
      }
    })
  );
}

module.exports = { applySecurityHeaders };

5. Sécurité des conteneurs et du runtime : Renforcement des Dockerfiles

Rédiger du code d’application sécurisé ne suffit pas si sa mise en production annule ces mesures. Si le processus Node.js s’exécute en tant que root à l’intérieur de son conteneur Docker, toute vulnérabilité d’exécution de code à distance qui apparaît donne à l’attaquant un accès au niveau root — lui permettant potentiellement d’accéder au système de fichiers du conteneur ou même à l’hôte.

Bonnes pratiques pour les Dockerfiles de production à plusieurs étapes

# -------------------------------------------------------------------
# STAGE 1: Build & Dependencies
# -------------------------------------------------------------------
FROM node:20-alpine AS builder

WORKDIR /usr/src/app

# Copy dependency manifests
COPY package*.json ./

# Install all dependencies (including devDependencies for building/testing)
RUN npm ci

# Copy source code
COPY . .

# Prune devDependencies for production runtime
RUN npm prune --production

# -------------------------------------------------------------------
# STAGE 2: Minimal Production Runtime
# -------------------------------------------------------------------
FROM node:20-alpine AS runner

# Set NODE_ENV to production
ENV NODE_ENV=production

WORKDIR /usr/src/app

# Copy built artifacts and production node_modules from builder
COPY --chown=node:node package*.json ./
COPY --chown=node:node --from=builder /usr/src/app/node_modules ./node_modules
COPY --chown=node:node --from=builder /usr/src/app/src ./src

# 🔒 SECURITY REQUIREMENT: Never run Node.js as root in container environments
USER node

EXPOSE 3000

CMD ["node", "src/server.js"]

Pourquoi c’est important :

  • USER node : retire les privilèges root au processus en cours d’exécution et le fait fonctionner plutôt en tant qu’utilisateur non privilégié
  • Construction en plusieurs étapes : évite d’inclure les outils de développement, les compilateurs et les suites de tests dans l’image finale, réduisant ainsi ce qu’un attaquant pourrait exploiter
  • npm ci : garantit des installations reproductibles en respectant strictement les versions indiquées dans package-lock.json
  • Liste de contrôle pour l’architecture – Partie 4

    Utilisez cette liste de contrôle pour auditer un déploiement en production avant de le lancer :

    • Contraintes sur la charge utile : Chaque point de terminaison API est-il protégé par une limite de taille de la charge utile HTTP, comme express.json({ limit: '100kb' }) ?
    • Défense contre les attaques ReDoS : Toutes les entrées de chaîne non fiables sont-elles validées à l’aide de bibliothèques éprouvées (Zod, Joi) ou d’une logique de validation sans rétrogradation, plutôt que via des expressions régulières personnalisées ?
    • Consultes paramétrées : Toutes les requêtes à la base de données transmettent-elles les entrées utilisateur via des paramètres liés ($1, $2 ou équivalent) afin d’empêcher structurellement les injections SQL et NoSQL ?
    • Limitation de débit distribuée : Un limiteur de débit atomique, basé sur Redis, protège-t-il activement vos API publiques contre le trafic de type refus de service ?
    • En-têtes de sécurité : helmet est-il configuré pour supprimer les en-têtes identifiant le framework, imposer HSTS et bloquer l’incorporation de frames ?
    • Utilisateur de conteneur non privilégié : Votre Dockerfile déclare-t-il explicitement USER node afin que le processus ne s’exécute jamais en tant que root ?
    • Vérification des dépendances : Des vérifications automatiques de vulnérabilités — npm audit, Snyk, Dependabot ou similaires — sont-elles intégrées à votre pipeline CI/CD ?

    Lectures complémentaires