Aller au contenu
·36 min de lecture

Bonnes pratiques de sécurité des API : la checklist que j'applique à chaque projet

Authentification, autorisation, validation des entrées, limitation de débit, CORS, gestion des secrets et le Top 10 OWASP des API. Ce que je vérifie avant chaque mise en production.

Partager:X / TwitterLinkedIn

J'ai expédié des API complètement ouvertes. Pas par malveillance, pas par paresse — je ne savais tout simplement pas ce que j'ignorais. Un endpoint qui renvoyait tous les champs de l'objet utilisateur, y compris les mots de passe hashés. Un limiteur de débit qui ne vérifiait que les adresses IP, ce qui signifiait que n'importe qui derrière un proxy pouvait marteler l'API. Une implémentation JWT où j'avais oublié de vérifier le claim iss, si bien que des jetons provenant d'un service complètement différent fonctionnaient parfaitement.

Chacune de ces erreurs est arrivée en production. Chacune a été détectée — certaines par moi, d'autres par des utilisateurs, une par un chercheur en sécurité qui a eu la gentillesse de m'envoyer un e-mail au lieu de le publier sur Twitter.

Cet article est la checklist que j'ai construite à partir de ces erreurs. Je la parcours avant chaque mise en production. Non pas parce que je suis paranoïaque, mais parce que j'ai appris que les bugs de sécurité sont ceux qui font le plus mal. Un bouton cassé agace les utilisateurs. Un flux d'authentification cassé fait fuiter leurs données.

Authentification vs Autorisation#

Ces deux mots sont utilisés de manière interchangeable dans les réunions, dans la documentation, même dans les commentaires de code. Ils ne désignent pas la même chose.

L'authentification répond à la question : « Qui êtes-vous ? » C'est l'étape de connexion. Nom d'utilisateur et mot de passe, flux OAuth, lien magique — tout ce qui prouve votre identité.

L'autorisation répond à la question : « Qu'êtes-vous autorisé à faire ? » C'est l'étape des permissions. Cet utilisateur peut-il supprimer cette ressource ? Peut-il accéder à cet endpoint d'administration ? Peut-il lire les données d'un autre utilisateur ?

Le bug de sécurité le plus courant que j'ai vu dans les API en production n'est pas un flux de connexion cassé. C'est une vérification d'autorisation manquante. L'utilisateur est authentifié — il possède un jeton valide — mais l'API ne vérifie jamais s'il est autorisé à effectuer l'action qu'il demande.

JWT : anatomie et erreurs qui comptent#

Les JWT sont partout. Ils sont aussi mal compris partout. Un JWT a trois parties, séparées par des points :

header.payload.signature

L'en-tête indique quel algorithme a été utilisé. Le payload contient les claims (identifiant utilisateur, rôles, expiration). La signature prouve que personne n'a altéré les deux premières parties.

Voici une vérification JWT correcte en Node.js :

typescript
import jwt from "jsonwebtoken";
import { timingSafeEqual } from "crypto";
 
interface TokenPayload {
  sub: string;
  role: "user" | "admin";
  iss: string;
  aud: string;
  exp: number;
  iat: number;
  jti: string;
}
 
function verifyToken(token: string): TokenPayload {
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!, {
      algorithms: ["HS256"], // Ne jamais autoriser "none"
      issuer: "api.yourapp.com",
      audience: "yourapp.com",
      clockTolerance: 30, // 30 secondes de tolérance pour le décalage d'horloge
    }) as TokenPayload;
 
    return payload;
  } catch (error) {
    if (error instanceof jwt.TokenExpiredError) {
      throw new ApiError(401, "Token expired");
    }
    if (error instanceof jwt.JsonWebTokenError) {
      throw new ApiError(401, "Invalid token");
    }
    throw new ApiError(401, "Authentication failed");
  }
}

Quelques points à noter :

  1. algorithms: ["HS256"] — C'est critique. Si vous ne spécifiez pas l'algorithme, un attaquant peut envoyer un jeton avec "alg": "none" dans l'en-tête et contourner entièrement la vérification. C'est l'attaque alg: none, et elle a affecté de vrais systèmes en production.

  2. issuer et audience — Sans ceux-ci, un jeton émis pour le Service A fonctionne sur le Service B. Si vous exploitez plusieurs services partageant le même secret (ce que vous ne devriez pas faire, mais que beaucoup font), c'est ainsi que se produit l'abus de jetons inter-services.

  3. Gestion d'erreurs spécifique — Ne renvoyez pas "invalid token" pour chaque échec. Distinguer entre expiré et invalide aide le client à savoir s'il doit rafraîchir ou se ré-authentifier.

Rotation des jetons de rafraîchissement#

Les jetons d'accès doivent avoir une durée de vie courte — 15 minutes est la norme. Mais vous ne voulez pas que les utilisateurs re-saisissent leur mot de passe toutes les 15 minutes. C'est là qu'interviennent les jetons de rafraîchissement.

Le schéma qui fonctionne réellement en production :

typescript
import { randomBytes } from "crypto";
import { redis } from "./redis";
 
interface RefreshTokenData {
  userId: string;
  family: string; // Famille de jetons pour la détection de rotation
  createdAt: number;
}
 
async function rotateRefreshToken(
  oldRefreshToken: string
): Promise<{ accessToken: string; refreshToken: string }> {
  const tokenData = await redis.get(`refresh:${oldRefreshToken}`);
 
  if (!tokenData) {
    // Jeton non trouvé — soit expiré, soit déjà utilisé.
    // Si déjà utilisé, c'est une potentielle attaque par rejeu.
    // Invalider toute la famille de jetons.
    const parsed = decodeRefreshToken(oldRefreshToken);
    if (parsed?.family) {
      await invalidateTokenFamily(parsed.family);
    }
    throw new ApiError(401, "Invalid refresh token");
  }
 
  const data: RefreshTokenData = JSON.parse(tokenData);
 
  // Supprimer l'ancien jeton immédiatement — usage unique
  await redis.del(`refresh:${oldRefreshToken}`);
 
  // Générer de nouveaux jetons
  const newRefreshToken = randomBytes(64).toString("hex");
  const newAccessToken = generateAccessToken(data.userId);
 
  // Stocker le nouveau jeton de rafraîchissement avec la même famille
  await redis.setex(
    `refresh:${newRefreshToken}`,
    60 * 60 * 24 * 30, // 30 jours
    JSON.stringify({
      userId: data.userId,
      family: data.family,
      createdAt: Date.now(),
    })
  );
 
  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}
 
async function invalidateTokenFamily(family: string): Promise<void> {
  // Scanner tous les jetons de cette famille et les supprimer.
  // C'est l'option nucléaire — si quelqu'un rejoue un jeton de rafraîchissement,
  // on supprime chaque jeton de la famille, forçant la ré-authentification.
  const keys = await redis.keys(`refresh:*`);
  for (const key of keys) {
    const data = await redis.get(key);
    if (data) {
      const parsed = JSON.parse(data) as RefreshTokenData;
      if (parsed.family === family) {
        await redis.del(key);
      }
    }
  }
}

Le concept de famille de jetons est ce qui rend cela sécurisé. Chaque jeton de rafraîchissement appartient à une famille (créée lors de la connexion). Lors de la rotation, le nouveau jeton hérite de la famille. Si un attaquant rejoue un ancien jeton de rafraîchissement, vous détectez la réutilisation et supprimez toute la famille. L'utilisateur légitime est déconnecté, mais l'attaquant ne passe pas.

Ce débat dure depuis des années, et la réponse est claire : cookies httpOnly pour les jetons de rafraîchissement, toujours.

localStorage est accessible à tout JavaScript s'exécutant sur votre page. Si vous avez une seule vulnérabilité XSS — et à grande échelle, vous en aurez éventuellement — l'attaquant peut lire le jeton et l'exfiltrer. Fin de partie.

Les cookies httpOnly ne sont pas accessibles au JavaScript. Point final. Une vulnérabilité XSS peut toujours effectuer des requêtes au nom de l'utilisateur (car les cookies sont envoyés automatiquement), mais l'attaquant ne peut pas voler le jeton lui-même. C'est une différence significative.

typescript
// Définition d'un cookie de jeton de rafraîchissement sécurisé
function setRefreshTokenCookie(res: Response, token: string): void {
  res.cookie("refresh_token", token, {
    httpOnly: true,     // Non accessible via JavaScript
    secure: true,       // HTTPS uniquement
    sameSite: "strict", // Pas de requêtes inter-sites
    maxAge: 30 * 24 * 60 * 60 * 1000, // 30 jours
    path: "/api/auth",  // Envoyé uniquement aux endpoints d'authentification
  });
}

Le path: "/api/auth" est un détail que la plupart des gens manquent. Par défaut, les cookies sont envoyés à chaque endpoint de votre domaine. Votre jeton de rafraîchissement n'a pas besoin d'aller vers /api/users ou /api/products. Restreignez le chemin, réduisez la surface d'attaque.

Pour les jetons d'accès, je les garde en mémoire (une variable JavaScript). Pas localStorage, pas sessionStorage, pas un cookie. En mémoire. Ils ont une durée de vie courte (15 minutes), et quand la page se rafraîchit, le client appelle silencieusement l'endpoint de rafraîchissement pour en obtenir un nouveau. Oui, cela signifie une requête supplémentaire au chargement de la page. Ça en vaut la peine.

Validation des entrées : ne faites jamais confiance au client#

Le client n'est pas votre ami. Le client est un inconnu qui est entré chez vous en disant « J'ai le droit d'être ici. » Vous vérifiez son identité quand même.

Chaque donnée provenant de l'extérieur de votre serveur — corps de requête, paramètres de requête, paramètres d'URL, en-têtes — est une entrée non fiable. Peu importe que votre formulaire React ait une validation. Quelqu'un la contournera avec curl.

Zod pour la validation typée#

Zod est la meilleure chose qui soit arrivée à la validation des entrées en Node.js. Il vous donne une validation à l'exécution avec les types TypeScript gratuitement :

typescript
import { z } from "zod";
 
const CreateUserSchema = z.object({
  email: z
    .string()
    .email("Invalid email format")
    .max(254, "Email too long")
    .transform((e) => e.toLowerCase().trim()),
 
  password: z
    .string()
    .min(12, "Password must be at least 12 characters")
    .max(128, "Password too long") // Prévenir le DoS bcrypt
    .regex(
      /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
      "Password must contain uppercase, lowercase, and a number"
    ),
 
  name: z
    .string()
    .min(1, "Name is required")
    .max(100, "Name too long")
    .regex(/^[\p{L}\p{M}\s'-]+$/u, "Name contains invalid characters"),
 
  role: z.enum(["user", "editor"]).default("user"),
  // Note : "admin" n'est intentionnellement pas une option ici.
  // L'attribution du rôle admin passe par un endpoint séparé et privilégié.
});
 
type CreateUserInput = z.infer<typeof CreateUserSchema>;
 
// Utilisation dans un gestionnaire Express
app.post("/api/users", async (req, res) => {
  const result = CreateUserSchema.safeParse(req.body);
 
  if (!result.success) {
    return res.status(400).json({
      error: "Validation failed",
      details: result.error.issues.map((issue) => ({
        field: issue.path.join("."),
        message: issue.message,
      })),
    });
  }
 
  // result.data est entièrement typé comme CreateUserInput
  const user = await createUser(result.data);
  return res.status(201).json({ id: user.id, email: user.email });
});

Quelques détails importants pour la sécurité :

  • max(128) sur le mot de passe — bcrypt a une limite d'entrée de 72 octets, et certaines implémentations tronquent silencieusement. Mais surtout, si vous autorisez un mot de passe de 10 Mo, bcrypt passera un temps considérable à le hasher. C'est un vecteur de DoS.
  • max(254) sur l'e-mail — La RFC 5321 limite les adresses e-mail à 254 caractères. Tout ce qui est plus long n'est pas un e-mail valide.
  • Enum pour le rôle, sans admin — L'assignation de masse est l'une des plus anciennes vulnérabilités des API. Si vous acceptez le rôle depuis le corps de la requête sans le valider, quelqu'un enverra "role": "admin" en espérant que ça passe.

L'injection SQL n'est pas résolue#

« Utilisez juste un ORM » ne vous protège pas si vous écrivez des requêtes brutes pour la performance. Et tout le monde finit par écrire des requêtes brutes pour la performance.

typescript
// VULNÉRABLE — concaténation de chaînes
const query = `SELECT * FROM users WHERE email = '${email}'`;
 
// SÛR — requête paramétrée
const query = `SELECT * FROM users WHERE email = $1`;
const result = await pool.query(query, [email]);

Avec Prisma, vous êtes globalement protégé — mais $queryRaw peut encore vous mordre :

typescript
// VULNÉRABLE — template literal dans $queryRaw
const users = await prisma.$queryRaw`
  SELECT * FROM users WHERE name LIKE '%${searchTerm}%'
`;
 
// SÛR — utilisation de Prisma.sql pour la paramétrisation
import { Prisma } from "@prisma/client";
 
const users = await prisma.$queryRaw(
  Prisma.sql`SELECT * FROM users WHERE name LIKE ${`%${searchTerm}%`}`
);

Injection NoSQL#

MongoDB n'utilise pas SQL, mais n'est pas immunisé contre l'injection. Si vous passez une entrée utilisateur non assainie comme objet de requête, les choses tournent mal :

typescript
// VULNÉRABLE — si req.body.username est { "$gt": "" }
// cela renvoie le premier utilisateur de la collection
const user = await db.collection("users").findOne({
  username: req.body.username,
});
 
// SÛR — conversion explicite en chaîne
const user = await db.collection("users").findOne({
  username: String(req.body.username),
});
 
// MIEUX — valider avec Zod d'abord
const LoginSchema = z.object({
  username: z.string().min(1).max(50),
  password: z.string().min(1).max(128),
});

La correction est simple : validez les types d'entrée avant qu'ils n'atteignent votre pilote de base de données. Si username doit être une chaîne, vérifiez que c'est une chaîne.

Traversée de chemin#

Si votre API sert des fichiers ou lit à partir d'un chemin qui inclut une entrée utilisateur, la traversée de chemin va ruiner votre semaine :

typescript
import path from "path";
import { access, constants } from "fs/promises";
 
const ALLOWED_DIR = "/app/uploads";
 
async function resolveUserFilePath(userInput: string): Promise<string> {
  // Normaliser et résoudre vers un chemin absolu
  const resolved = path.resolve(ALLOWED_DIR, userInput);
 
  // Critique : vérifier que le chemin résolu est toujours dans le répertoire autorisé
  if (!resolved.startsWith(ALLOWED_DIR + path.sep)) {
    throw new ApiError(403, "Access denied");
  }
 
  // Vérifier que le fichier existe réellement
  await access(resolved, constants.R_OK);
 
  return resolved;
}
 
// Sans cette vérification :
// GET /api/files?name=../../../etc/passwd
// résout vers /etc/passwd

Le pattern path.resolve + startsWith est l'approche correcte. N'essayez pas de supprimer ../ manuellement — il existe trop d'astuces d'encodage (..%2F, ..%252F, ....//) qui contourneront votre regex.

Limitation de débit#

Sans limitation de débit, votre API est un buffet à volonté pour les bots. Attaques par force brute, bourrage d'identifiants, épuisement des ressources — la limitation de débit est la première défense contre tout cela.

Token Bucket vs fenêtre glissante#

Token bucket : Vous avez un seau qui contient N jetons. Chaque requête coûte un jeton. Les jetons se rechargent à un rythme fixe. Si le seau est vide, la requête est rejetée. Cela permet des pics — si le seau est plein, vous pouvez faire N requêtes instantanément.

Fenêtre glissante : Comptez les requêtes dans une fenêtre temporelle mobile. Plus prévisible, plus difficile à percer par des pics.

J'utilise la fenêtre glissante pour la plupart des cas car le comportement est plus facile à comprendre et à expliquer à l'équipe :

typescript
import { Redis } from "ioredis";
 
interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  resetAt: number;
}
 
async function slidingWindowRateLimit(
  redis: Redis,
  key: string,
  limit: number,
  windowMs: number
): Promise<RateLimitResult> {
  const now = Date.now();
  const windowStart = now - windowMs;
 
  const multi = redis.multi();
 
  // Supprimer les entrées hors de la fenêtre
  multi.zremrangebyscore(key, 0, windowStart);
 
  // Compter les entrées dans la fenêtre
  multi.zcard(key);
 
  // Ajouter la requête actuelle (on la supprimera si au-dessus de la limite)
  multi.zadd(key, now.toString(), `${now}:${Math.random()}`);
 
  // Définir l'expiration de la clé
  multi.pexpire(key, windowMs);
 
  const results = await multi.exec();
 
  if (!results) {
    throw new Error("Redis transaction failed");
  }
 
  const count = results[1][1] as number;
 
  if (count >= limit) {
    // Au-dessus de la limite — supprimer l'entrée qu'on vient d'ajouter
    await redis.zremrangebyscore(key, now, now);
 
    return {
      allowed: false,
      remaining: 0,
      resetAt: windowStart + windowMs,
    };
  }
 
  return {
    allowed: true,
    remaining: limit - count - 1,
    resetAt: now + windowMs,
  };
}

Limites de débit en couches#

Une seule limite de débit globale ne suffit pas. Les différents endpoints ont des profils de risque différents :

typescript
interface RateLimitConfig {
  window: number;
  max: number;
}
 
const RATE_LIMITS: Record<string, RateLimitConfig> = {
  // Endpoints d'authentification — limites strictes, cible de force brute
  "POST:/api/auth/login": { window: 15 * 60 * 1000, max: 5 },
  "POST:/api/auth/register": { window: 60 * 60 * 1000, max: 3 },
  "POST:/api/auth/reset-password": { window: 60 * 60 * 1000, max: 3 },
 
  // Lectures de données — plus généreux
  "GET:/api/users": { window: 60 * 1000, max: 100 },
  "GET:/api/products": { window: 60 * 1000, max: 200 },
 
  // Écritures de données — modéré
  "POST:/api/posts": { window: 60 * 1000, max: 10 },
  "PUT:/api/posts": { window: 60 * 1000, max: 30 },
 
  // Fallback global
  "*": { window: 60 * 1000, max: 60 },
};
 
function getRateLimitKey(req: Request, config: RateLimitConfig): string {
  const identifier = req.user?.id ?? getClientIp(req);
  const endpoint = `${req.method}:${req.path}`;
  return `ratelimit:${identifier}:${endpoint}`;
}

Remarquez : les utilisateurs authentifiés sont limités par identifiant utilisateur, pas par IP. C'est important car de nombreux utilisateurs légitimes partagent des IP (réseaux d'entreprise, VPN, opérateurs mobiles). Si vous ne limitez que par IP, vous bloquerez des bureaux entiers.

En-têtes de limitation de débit#

Informez toujours le client de ce qui se passe :

typescript
function setRateLimitHeaders(
  res: Response,
  result: RateLimitResult,
  limit: number
): void {
  res.set({
    "X-RateLimit-Limit": limit.toString(),
    "X-RateLimit-Remaining": result.remaining.toString(),
    "X-RateLimit-Reset": Math.ceil(result.resetAt / 1000).toString(),
    "Retry-After": result.allowed
      ? undefined
      : Math.ceil((result.resetAt - Date.now()) / 1000).toString(),
  });
 
  if (!result.allowed) {
    res.status(429).json({
      error: "Too many requests",
      retryAfter: Math.ceil((result.resetAt - Date.now()) / 1000),
    });
  }
}

Configuration CORS#

CORS est probablement le mécanisme de sécurité le plus mal compris du développement web. La moitié des réponses Stack Overflow sur CORS sont « mettez juste Access-Control-Allow-Origin: * et ça marche. » C'est techniquement vrai. C'est aussi ainsi que vous ouvrez votre API à chaque site malveillant sur Internet.

Ce que CORS fait (et ne fait pas) réellement#

CORS est un mécanisme du navigateur. Il dit au navigateur si le JavaScript de l'Origine A est autorisé à lire la réponse de l'Origine B. C'est tout.

Ce que CORS ne fait pas :

  • Il ne protège pas votre API de curl, Postman ou des requêtes serveur-à-serveur
  • Il n'authentifie pas les requêtes
  • Il ne chiffre rien
  • Il n'empêche pas le CSRF par lui-même (bien qu'il aide combiné avec d'autres mécanismes)

Ce que CORS fait :

  • Il empêche site-malveillant.com de faire des requêtes fetch vers votre-api.com et de lire la réponse dans le navigateur de l'utilisateur
  • Il empêche le JavaScript de l'attaquant d'exfiltrer des données via la session authentifiée de la victime

Le piège du caractère générique#

typescript
// DANGEREUX — permet à tout site web de lire vos réponses API
app.use(cors({ origin: "*" }));
 
// ÉGALEMENT DANGEREUX — c'est une approche "dynamique" courante qui n'est que * avec des étapes supplémentaires
app.use(
  cors({
    origin: (origin, callback) => {
      callback(null, true); // Autorise tout
    },
  })
);

Le problème avec * est qu'il rend vos réponses API lisibles par n'importe quel JavaScript sur n'importe quelle page. Si votre API renvoie des données utilisateur et que l'utilisateur est authentifié via des cookies, n'importe quel site visité par l'utilisateur peut lire ces données.

Encore pire : Access-Control-Allow-Origin: * ne peut pas être combiné avec credentials: true. Donc si vous avez besoin de cookies (pour l'authentification), vous ne pouvez littéralement pas utiliser le caractère générique. Mais j'ai vu des gens essayer de contourner cela en renvoyant l'en-tête Origin — ce qui équivaut à * avec les identifiants, le pire des deux mondes.

La configuration correcte#

typescript
import cors from "cors";
 
const ALLOWED_ORIGINS = new Set([
  "https://yourapp.com",
  "https://www.yourapp.com",
  "https://admin.yourapp.com",
]);
 
if (process.env.NODE_ENV === "development") {
  ALLOWED_ORIGINS.add("http://localhost:3000");
  ALLOWED_ORIGINS.add("http://localhost:5173");
}
 
app.use(
  cors({
    origin: (origin, callback) => {
      // Autoriser les requêtes sans origine (apps mobiles, curl, serveur-à-serveur)
      if (!origin) {
        return callback(null, true);
      }
 
      if (ALLOWED_ORIGINS.has(origin)) {
        return callback(null, origin);
      }
 
      callback(new Error(`Origin ${origin} not allowed by CORS`));
    },
    credentials: true, // Autoriser les cookies
    methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
    allowedHeaders: ["Content-Type", "Authorization"],
    exposedHeaders: ["X-RateLimit-Limit", "X-RateLimit-Remaining"],
    maxAge: 86400, // Mettre en cache le preflight pendant 24 heures
  })
);

Décisions clés :

  • Ensemble d'origines explicite, pas une regex. Les regex sont piégeuses — yourapp.com pourrait correspondre à evilyourapp.com si votre regex n'est pas correctement ancrée.
  • credentials: true car nous utilisons des cookies httpOnly pour les jetons de rafraîchissement.
  • maxAge: 86400 — Les requêtes preflight (OPTIONS) ajoutent de la latence. Dire au navigateur de mettre en cache le résultat CORS pendant 24 heures réduit les allers-retours inutiles.
  • exposedHeaders — Par défaut, le navigateur n'expose qu'une poignée d'en-têtes de réponse « simples » au JavaScript. Si vous voulez que le client lise vos en-têtes de limitation de débit, vous devez les exposer explicitement.

Requêtes preflight#

Quand une requête n'est pas « simple » (elle utilise un en-tête non standard, une méthode non standard ou un type de contenu non standard), le navigateur envoie d'abord une requête OPTIONS pour demander la permission. C'est le preflight.

Si votre configuration CORS ne gère pas OPTIONS, les requêtes preflight échoueront, et la requête réelle ne sera jamais envoyée. La plupart des bibliothèques CORS gèrent cela automatiquement, mais si vous utilisez un framework qui ne le fait pas, vous devez le gérer :

typescript
// Gestion manuelle du preflight (la plupart des frameworks le font pour vous)
app.options("*", (req, res) => {
  res.set({
    "Access-Control-Allow-Origin": getAllowedOrigin(req.headers.origin),
    "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH",
    "Access-Control-Allow-Headers": "Content-Type, Authorization",
    "Access-Control-Max-Age": "86400",
  });
  res.status(204).end();
});

En-têtes de sécurité#

Les en-têtes de sécurité sont l'amélioration de sécurité la moins chère que vous puissiez faire. Ce sont des en-têtes de réponse qui disent au navigateur d'activer des fonctionnalités de sécurité. La plupart d'entre eux sont une seule ligne de configuration, et ils protègent contre des classes entières d'attaques.

Les en-têtes qui comptent#

typescript
import helmet from "helmet";
 
// Une ligne. C'est le gain de sécurité le plus rapide dans toute application Express.
app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'"],
        styleSrc: ["'self'", "'unsafe-inline'"], // Nécessaire pour beaucoup de solutions CSS-in-JS
        imgSrc: ["'self'", "data:", "https:"],
        connectSrc: ["'self'", "https://api.yourapp.com"],
        fontSrc: ["'self'"],
        objectSrc: ["'none'"],
        mediaSrc: ["'self'"],
        frameSrc: ["'none'"],
        upgradeInsecureRequests: [],
      },
    },
    hsts: {
      maxAge: 31536000, // 1 an
      includeSubDomains: true,
      preload: true,
    },
    referrerPolicy: { policy: "strict-origin-when-cross-origin" },
  })
);

Ce que fait chaque en-tête :

Content-Security-Policy (CSP) — L'en-tête de sécurité le plus puissant. Il dit au navigateur exactement quelles sources sont autorisées pour les scripts, styles, images, polices, etc. Si un attaquant injecte une balise <script> qui charge depuis evil.com, CSP la bloque. C'est la défense la plus efficace contre le XSS.

Strict-Transport-Security (HSTS) — Dit au navigateur de toujours utiliser HTTPS, même si l'utilisateur tape http://. La directive preload vous permet de soumettre votre domaine à la liste HSTS intégrée du navigateur, de sorte que même la première requête est forcée en HTTPS.

X-Frame-Options — Empêche votre site d'être intégré dans un iframe. Cela stoppe les attaques de clickjacking où un attaquant superpose votre page avec des éléments invisibles. Helmet le définit à SAMEORIGIN par défaut. Le remplacement moderne est frame-ancestors dans CSP.

X-Content-Type-Options: nosniff — Empêche le navigateur de deviner (sniffing) le type MIME d'une réponse. Sans cela, si vous servez un fichier avec le mauvais Content-Type, le navigateur pourrait l'exécuter comme du JavaScript.

Referrer-Policy — Contrôle combien d'informations URL sont envoyées dans l'en-tête Referer. strict-origin-when-cross-origin envoie l'URL complète pour les requêtes same-origin mais seulement l'origine pour les requêtes cross-origin. Cela empêche la fuite de paramètres URL sensibles vers des tiers.

Tester vos en-têtes#

Après le déploiement, vérifiez votre score sur securityheaders.com. Visez une note A+. Il faut environ cinq minutes de configuration pour y arriver.

Vous pouvez aussi vérifier les en-têtes programmatiquement :

typescript
import { describe, it, expect } from "vitest";
 
describe("Security headers", () => {
  it("should include all required security headers", async () => {
    const response = await fetch("https://api.yourapp.com/health");
 
    expect(response.headers.get("strict-transport-security")).toBeTruthy();
    expect(response.headers.get("x-content-type-options")).toBe("nosniff");
    expect(response.headers.get("x-frame-options")).toBe("SAMEORIGIN");
    expect(response.headers.get("content-security-policy")).toBeTruthy();
    expect(response.headers.get("referrer-policy")).toBeTruthy();
    expect(response.headers.get("x-powered-by")).toBeNull(); // Helmet supprime ceci
  });
});

La vérification x-powered-by est subtile mais importante. Express définit X-Powered-By: Express par défaut, indiquant aux attaquants exactement quel framework vous utilisez. Helmet le supprime.

Gestion des secrets#

Ceci devrait être évident, mais je le vois encore dans les pull requests : des clés API, des mots de passe de base de données et des secrets JWT codés en dur dans les fichiers source. Ou commités dans des fichiers .env qui n'étaient pas dans .gitignore. Une fois que c'est dans l'historique git, c'est là pour toujours, même si vous supprimez le fichier dans le commit suivant.

Les règles#

  1. Ne jamais commiter de secrets dans git. Ni dans le code, ni dans .env, ni dans les fichiers de configuration, ni dans les fichiers Docker Compose, ni dans les commentaires « juste pour tester ».

  2. Utilisez .env.example comme modèle. Il documente quelles variables d'environnement sont nécessaires, sans contenir les valeurs réelles :

bash
# .env.example — commiter ceci
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
JWT_SECRET=your-secret-here
REDIS_URL=redis://localhost:6379
SMTP_API_KEY=your-smtp-key
 
# .env — JAMAIS commiter ceci
# Listé dans .gitignore
  1. Valider les variables d'environnement au démarrage. N'attendez pas qu'une requête atteigne un endpoint qui a besoin de l'URL de la base de données. Échouez rapidement :
typescript
import { z } from "zod";
 
const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32, "JWT secret must be at least 32 characters"),
  REDIS_URL: z.string().url(),
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
  PORT: z.coerce.number().default(3000),
  CORS_ORIGINS: z.string().transform((s) => s.split(",")),
});
 
export type Env = z.infer<typeof envSchema>;
 
function validateEnv(): Env {
  const result = envSchema.safeParse(process.env);
 
  if (!result.success) {
    console.error("Invalid environment variables:");
    console.error(result.error.format());
    process.exit(1); // Ne pas démarrer avec une mauvaise configuration
  }
 
  return result.data;
}
 
export const env = validateEnv();
  1. Utilisez un gestionnaire de secrets en production. Les variables d'environnement fonctionnent pour des configurations simples, mais elles ont des limitations : elles sont visibles dans les listes de processus, elles persistent en mémoire, et elles peuvent fuiter via les logs d'erreurs.

Pour les systèmes en production, utilisez un véritable gestionnaire de secrets :

  • AWS Secrets Manager ou SSM Parameter Store
  • HashiCorp Vault
  • Google Secret Manager
  • Azure Key Vault
  • Doppler (si vous voulez quelque chose qui fonctionne sur tous les clouds)

Le schéma est le même quel que soit celui que vous utilisez : l'application récupère les secrets au démarrage depuis le gestionnaire de secrets, pas depuis les variables d'environnement.

  1. Faites tourner les secrets régulièrement. Si vous utilisez le même secret JWT depuis deux ans, il est temps de le faire tourner. Implémentez la rotation des clés : supportez plusieurs clés de signature valides simultanément, signez les nouveaux jetons avec la nouvelle clé, vérifiez avec l'ancienne et la nouvelle, et retirez l'ancienne clé après l'expiration de tous les jetons existants.
typescript
interface SigningKey {
  id: string;
  secret: string;
  createdAt: Date;
  active: boolean; // Seule la clé active signe les nouveaux jetons
}
 
async function verifyWithRotation(token: string): Promise<TokenPayload> {
  const keys = await getSigningKeys(); // Retourne toutes les clés valides
 
  for (const key of keys) {
    try {
      return jwt.verify(token, key.secret, {
        algorithms: ["HS256"],
      }) as TokenPayload;
    } catch {
      continue; // Essayer la clé suivante
    }
  }
 
  throw new ApiError(401, "Invalid token");
}
 
function signToken(payload: Omit<TokenPayload, "iat" | "exp">): string {
  const activeKey = getActiveSigningKey();
  return jwt.sign(payload, activeKey.secret, {
    algorithm: "HS256",
    expiresIn: "15m",
    keyid: activeKey.id, // Inclure l'identifiant de clé dans l'en-tête
  });
}

Top 10 OWASP de sécurité des API#

Le Top 10 OWASP de sécurité des API est la liste standard de l'industrie des vulnérabilités des API. Il est mis à jour périodiquement, et chaque élément de la liste est quelque chose que j'ai vu dans de vrais codes. Passons-les en revue.

API1 : Broken Object Level Authorization (BOLA)#

La vulnérabilité API la plus courante. L'utilisateur est authentifié, mais l'API ne vérifie pas s'il a accès à l'objet spécifique qu'il demande.

typescript
// VULNÉRABLE — tout utilisateur authentifié peut accéder aux données de n'importe quel utilisateur
app.get("/api/users/:id", authenticate, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  return res.json(user);
});
 
// CORRIGÉ — vérifier que l'utilisateur accède à ses propres données (ou est admin)
app.get("/api/users/:id", authenticate, async (req, res) => {
  if (req.user.id !== req.params.id && req.user.role !== "admin") {
    return res.status(403).json({ error: "Access denied" });
  }
  const user = await db.users.findById(req.params.id);
  return res.json(user);
});

La version vulnérable est partout. Elle passe toutes les vérifications d'authentification — l'utilisateur a un jeton valide — mais elle ne vérifie pas qu'il est autorisé à accéder à cette ressource spécifique. Changez l'ID dans l'URL, et vous obtenez les données de quelqu'un d'autre.

API2 : Broken Authentication#

Mécanismes de connexion faibles, MFA manquant, jetons qui n'expirent jamais, mots de passe stockés en clair. Cela couvre la couche d'authentification elle-même.

La correction est tout ce que nous avons discuté dans la section authentification : exigences de mot de passe fortes, bcrypt avec suffisamment de tours, jetons d'accès à courte durée de vie, rotation des jetons de rafraîchissement, verrouillage de compte après tentatives échouées.

typescript
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes
 
async function handleLogin(email: string, password: string): Promise<AuthResult> {
  const lockoutKey = `lockout:${email}`;
  const attempts = await redis.get(lockoutKey);
 
  if (attempts && parseInt(attempts) >= MAX_LOGIN_ATTEMPTS) {
    const ttl = await redis.pttl(lockoutKey);
    throw new ApiError(
      429,
      `Account locked. Try again in ${Math.ceil(ttl / 60000)} minutes.`
    );
  }
 
  const user = await db.users.findByEmail(email);
 
  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    // Incrémenter les tentatives échouées
    await redis.multi()
      .incr(lockoutKey)
      .pexpire(lockoutKey, LOCKOUT_DURATION)
      .exec();
 
    // Même message d'erreur dans les deux cas — ne pas révéler si l'e-mail existe
    throw new ApiError(401, "Invalid email or password");
  }
 
  // Réinitialiser les tentatives échouées après connexion réussie
  await redis.del(lockoutKey);
 
  return generateTokens(user);
}

Le commentaire sur le « même message d'erreur » est important. Si votre API renvoie « utilisateur non trouvé » pour les e-mails invalides et « mauvais mot de passe » pour les e-mails valides avec un mauvais mot de passe, vous dites à un attaquant quels e-mails existent dans votre système.

API3 : Broken Object Property Level Authorization#

Renvoyer plus de données que nécessaire, ou permettre aux utilisateurs de modifier des propriétés qu'ils ne devraient pas.

typescript
// VULNÉRABLE — renvoie l'objet utilisateur entier, incluant les champs internes
app.get("/api/users/:id", authenticate, authorize, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  return res.json(user);
  // La réponse inclut : passwordHash, internalNotes, billingId, ...
});
 
// CORRIGÉ — liste explicite des champs renvoyés
app.get("/api/users/:id", authenticate, authorize, async (req, res) => {
  const user = await db.users.findById(req.params.id);
  return res.json({
    id: user.id,
    name: user.name,
    email: user.email,
    avatar: user.avatar,
    createdAt: user.createdAt,
  });
});

Ne renvoyez jamais des objets de base de données entiers. Sélectionnez toujours les champs que vous voulez exposer. Cela s'applique aussi aux écritures — ne répandez pas le corps entier de la requête dans votre requête de mise à jour :

typescript
// VULNÉRABLE — assignation de masse
app.put("/api/users/:id", authenticate, async (req, res) => {
  await db.users.update(req.params.id, req.body);
  // L'attaquant envoie : { "role": "admin", "verified": true }
});
 
// CORRIGÉ — sélectionner les champs autorisés
const UpdateUserSchema = z.object({
  name: z.string().min(1).max(100).optional(),
  avatar: z.string().url().optional(),
});
 
app.put("/api/users/:id", authenticate, async (req, res) => {
  const data = UpdateUserSchema.parse(req.body);
  await db.users.update(req.params.id, data);
});

API4 : Unrestricted Resource Consumption#

Votre API est une ressource. CPU, mémoire, bande passante, connexions à la base de données — ils sont tous finis. Sans limites, un seul client peut les épuiser tous.

Cela va au-delà de la limitation de débit. Cela inclut :

typescript
// Limiter la taille du corps de requête
app.use(express.json({ limit: "1mb" }));
 
// Limiter la complexité des requêtes
const MAX_PAGE_SIZE = 100;
const DEFAULT_PAGE_SIZE = 20;
 
const PaginationSchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce
    .number()
    .int()
    .positive()
    .max(MAX_PAGE_SIZE)
    .default(DEFAULT_PAGE_SIZE),
});
 
// Limiter la taille des fichiers uploadés
const upload = multer({
  limits: {
    fileSize: 5 * 1024 * 1024, // 5 Mo
    files: 1,
  },
  fileFilter: (req, file, cb) => {
    const allowed = ["image/jpeg", "image/png", "image/webp"];
    if (allowed.includes(file.mimetype)) {
      cb(null, true);
    } else {
      cb(new Error("Invalid file type"));
    }
  },
});
 
// Timeout pour les requêtes de longue durée
app.use((req, res, next) => {
  res.setTimeout(30000, () => {
    res.status(408).json({ error: "Request timeout" });
  });
  next();
});

API5 : Broken Function Level Authorization#

Différent de BOLA. Il s'agit ici d'accéder à des fonctions (endpoints) auxquelles vous ne devriez pas avoir accès, pas à des objets. L'exemple classique : un utilisateur ordinaire qui découvre des endpoints d'administration.

typescript
// Middleware qui vérifie l'accès basé sur les rôles
function requireRole(...allowedRoles: string[]) {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user) {
      return res.status(401).json({ error: "Not authenticated" });
    }
 
    if (!allowedRoles.includes(req.user.role)) {
      // Logger la tentative — cela pourrait être une attaque
      logger.warn("Unauthorized access attempt", {
        userId: req.user.id,
        role: req.user.role,
        requiredRoles: allowedRoles,
        endpoint: `${req.method} ${req.path}`,
        ip: req.ip,
      });
 
      return res.status(403).json({ error: "Insufficient permissions" });
    }
 
    next();
  };
}
 
// Appliquer aux routes
app.delete("/api/users/:id", authenticate, requireRole("admin"), deleteUser);
app.get("/api/admin/stats", authenticate, requireRole("admin"), getStats);
app.post("/api/posts", authenticate, requireRole("admin", "editor"), createPost);

Ne comptez pas sur le fait de cacher les endpoints. « La sécurité par l'obscurité » n'est pas de la sécurité. Même si l'URL du panneau d'administration n'est liée nulle part, quelqu'un trouvera /api/admin/users par fuzzing.

API6 : Unrestricted Access to Sensitive Business Flows#

Abus automatisé de fonctionnalités métier légitimes. Pensez à : des bots achetant des articles en stock limité, la création automatisée de comptes pour le spam, le scraping de prix de produits.

Les atténuations sont spécifiques au contexte : CAPTCHAs, empreinte digitale d'appareil, analyse comportementale, authentification renforcée pour les opérations sensibles. Il n'y a pas de snippet de code universel.

API7 : Server Side Request Forgery (SSRF)#

Si votre API récupère des URLs fournies par l'utilisateur (webhooks, URLs d'image de profil, aperçus de liens), un attaquant peut faire demander des ressources internes par votre serveur :

typescript
import { URL } from "url";
import dns from "dns/promises";
import { isPrivateIP } from "./network-utils";
 
async function safeFetch(userProvidedUrl: string): Promise<Response> {
  let parsed: URL;
 
  try {
    parsed = new URL(userProvidedUrl);
  } catch {
    throw new ApiError(400, "Invalid URL");
  }
 
  // Autoriser uniquement HTTP(S)
  if (!["http:", "https:"].includes(parsed.protocol)) {
    throw new ApiError(400, "Only HTTP(S) URLs are allowed");
  }
 
  // Résoudre le nom d'hôte et vérifier si c'est une IP privée
  const addresses = await dns.resolve4(parsed.hostname);
 
  for (const addr of addresses) {
    if (isPrivateIP(addr)) {
      throw new ApiError(400, "Internal addresses are not allowed");
    }
  }
 
  // Maintenant fetch avec un timeout et une limite de taille
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 5000);
 
  try {
    const response = await fetch(userProvidedUrl, {
      signal: controller.signal,
      redirect: "error", // Ne pas suivre les redirections (elles pourraient rediriger vers des IP internes)
    });
 
    return response;
  } finally {
    clearTimeout(timeout);
  }
}

Détails clés : résolvez le DNS d'abord et vérifiez l'IP avant de faire la requête. Bloquez les redirections — un attaquant peut héberger une URL qui redirige vers http://169.254.169.254/ (endpoint de métadonnées AWS) pour contourner votre vérification au niveau de l'URL.

API8 : Security Misconfiguration#

Identifiants par défaut non modifiés, méthodes HTTP inutiles activées, messages d'erreur verbeux en production, listing de répertoire activé, CORS mal configuré. C'est la catégorie « vous avez oublié de verrouiller la porte ».

typescript
// Ne pas exposer les traces de pile en production
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  logger.error("Unhandled error", {
    error: err.message,
    stack: err.stack,
    path: req.path,
    method: req.method,
  });
 
  if (process.env.NODE_ENV === "production") {
    // Message d'erreur générique — ne pas révéler les internals
    res.status(500).json({
      error: "Internal server error",
      requestId: req.id, // Inclure un identifiant de requête pour le débogage
    });
  } else {
    // En développement, montrer l'erreur complète
    res.status(500).json({
      error: err.message,
      stack: err.stack,
    });
  }
});
 
// Désactiver les méthodes HTTP inutiles
app.use((req, res, next) => {
  const allowed = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"];
  if (!allowed.includes(req.method)) {
    return res.status(405).json({ error: "Method not allowed" });
  }
  next();
});

API9 : Improper Inventory Management#

Vous avez déployé la v2 de l'API mais oublié d'arrêter la v1. Ou il y a un endpoint /debug/ qui était utile pendant le développement et qui tourne encore en production. Ou un serveur de staging accessible publiquement avec des données de production.

Ce n'est pas une correction de code — c'est une discipline opérationnelle. Maintenez une liste de tous les endpoints API, toutes les versions déployées et tous les environnements. Utilisez un scan automatisé pour trouver les services exposés. Supprimez ce dont vous n'avez pas besoin.

API10 : Unsafe Consumption of APIs#

Votre API consomme des API tierces. Validez-vous leurs réponses ? Que se passe-t-il si un payload de webhook de Stripe provient en fait d'un attaquant ?

typescript
import crypto from "crypto";
 
// Vérifier les signatures de webhook Stripe
function verifyStripeWebhook(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const timestamp = signature.split(",").find((s) => s.startsWith("t="))?.slice(2);
  const expectedSig = signature.split(",").find((s) => s.startsWith("v1="))?.slice(3);
 
  if (!timestamp || !expectedSig) return false;
 
  // Rejeter les anciens timestamps (prévenir les attaques par rejeu)
  const age = Math.abs(Date.now() / 1000 - parseInt(timestamp));
  if (age > 300) return false; // 5 minutes de tolérance
 
  const signedPayload = `${timestamp}.${payload}`;
  const computedSig = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");
 
  return crypto.timingSafeEqual(
    Buffer.from(computedSig),
    Buffer.from(expectedSig)
  );
}

Vérifiez toujours les signatures des webhooks. Validez toujours la structure des réponses d'API tierces. Définissez toujours des timeouts sur les requêtes sortantes. Ne faites jamais confiance aux données simplement parce qu'elles proviennent d'un « partenaire de confiance ».

Journalisation d'audit#

Quand quelque chose tourne mal — et ce sera le cas — les logs d'audit sont la manière dont vous comprenez ce qui s'est passé. Mais la journalisation est une arme à double tranchant. Journalisez trop peu et vous êtes aveugle. Journalisez trop et vous créez une responsabilité en matière de vie privée.

Quoi journaliser#

typescript
interface AuditLogEntry {
  timestamp: string;
  action: string;           // "user.login", "post.delete", "admin.role_change"
  actor: {
    id: string;
    ip: string;
    userAgent: string;
  };
  target: {
    type: string;           // "user", "post", "setting"
    id: string;
  };
  result: "success" | "failure";
  metadata: Record<string, unknown>; // Contexte supplémentaire
  requestId: string;        // Pour corréler avec les logs applicatifs
}
 
async function auditLog(entry: AuditLogEntry): Promise<void> {
  // Écrire dans un magasin de données séparé, en append-only
  // Ce NE devrait PAS être la même base de données que votre application
  await auditDb.collection("audit_logs").insertOne({
    ...entry,
    timestamp: new Date().toISOString(),
  });
 
  // Pour les actions critiques, écrire aussi dans un log externe immuable
  if (isCriticalAction(entry.action)) {
    await externalLogger.send(entry);
  }
}

Journalisez ces événements :

  • Authentification : connexions, déconnexions, tentatives échouées, rafraîchissements de jetons
  • Autorisation : événements d'accès refusé (ce sont souvent des indicateurs d'attaque)
  • Modifications de données : créations, mises à jour, suppressions — qui a changé quoi, quand
  • Actions d'administration : changements de rôles, gestion des utilisateurs, modifications de configuration
  • Événements de sécurité : déclenchements de limitation de débit, violations CORS, requêtes malformées

Ce qu'il ne faut PAS journaliser#

Ne journalisez jamais :

  • Mots de passe (même hashés — le hash est un identifiant)
  • Numéros de carte de crédit complets (journalisez uniquement les 4 derniers chiffres)
  • Numéros de sécurité sociale ou pièces d'identité gouvernementales
  • Clés API ou jetons (journalisez un préfixe au maximum : sk_live_...abc)
  • Informations de santé personnelles
  • Corps de requête complets qui pourraient contenir des PII
typescript
function sanitizeForLogging(data: Record<string, unknown>): Record<string, unknown> {
  const sensitiveKeys = new Set([
    "password",
    "passwordHash",
    "token",
    "secret",
    "apiKey",
    "creditCard",
    "ssn",
    "authorization",
  ]);
 
  const sanitized: Record<string, unknown> = {};
 
  for (const [key, value] of Object.entries(data)) {
    if (sensitiveKeys.has(key.toLowerCase())) {
      sanitized[key] = "[REDACTED]";
    } else if (typeof value === "object" && value !== null) {
      sanitized[key] = sanitizeForLogging(value as Record<string, unknown>);
    } else {
      sanitized[key] = value;
    }
  }
 
  return sanitized;
}

Logs inviolables#

Si un attaquant accède à votre système, l'une des premières choses qu'il fera est de modifier les logs pour couvrir ses traces. La journalisation inviolable rend cela détectable :

typescript
import crypto from "crypto";
 
let previousHash = "GENESIS"; // Le hash initial dans la chaîne
 
function createTamperEvidentEntry(entry: AuditLogEntry): AuditLogEntry & { hash: string } {
  const content = JSON.stringify(entry) + previousHash;
  const hash = crypto.createHash("sha256").update(content).digest("hex");
 
  previousHash = hash;
 
  return { ...entry, hash };
}
 
// Pour vérifier l'intégrité de la chaîne :
function verifyLogChain(entries: Array<AuditLogEntry & { hash: string }>): boolean {
  let expectedPreviousHash = "GENESIS";
 
  for (const entry of entries) {
    const { hash, ...rest } = entry;
    const content = JSON.stringify(rest) + expectedPreviousHash;
    const computedHash = crypto.createHash("sha256").update(content).digest("hex");
 
    if (computedHash !== hash) {
      return false; // La chaîne est rompue — les logs ont été altérés
    }
 
    expectedPreviousHash = hash;
  }
 
  return true;
}

C'est le même concept qu'une blockchain — le hash de chaque entrée de log dépend de l'entrée précédente. Si quelqu'un modifie ou supprime une entrée, la chaîne se rompt.

Sécurité des dépendances#

Votre code est peut-être sécurisé. Mais qu'en est-il des 847 paquets npm dans votre node_modules ? Le problème de la chaîne d'approvisionnement est réel, et il s'est aggravé au fil des années.

npm audit est le strict minimum#

bash
# Exécuter dans le CI, faire échouer le build sur les vulnérabilités hautes/critiques
npm audit --audit-level=high
 
# Corriger ce qui peut être corrigé automatiquement
npm audit fix
 
# Voir ce que vous importez réellement
npm ls --all

Mais npm audit a des limitations. Il ne vérifie que la base de données des avis npm, et ses évaluations de gravité ne sont pas toujours précises. Ajoutez des outils supplémentaires :

Scan automatisé des dépendances#

yaml
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 10
    reviewers:
      - "your-team"
    labels:
      - "dependencies"
    # Grouper les mises à jour mineures et de patch pour réduire le bruit des PR
    groups:
      production-dependencies:
        patterns:
          - "*"
        update-types:
          - "minor"
          - "patch"

Le lockfile est un outil de sécurité#

Commitez toujours votre package-lock.json (ou pnpm-lock.yaml, ou yarn.lock). Le lockfile fixe les versions exactes de chaque dépendance, y compris les transitives. Sans lui, npm install pourrait tirer une version différente de celle que vous avez testée — et cette version différente pourrait être compromise.

bash
# Dans le CI, utilisez ci au lieu de install — il respecte strictement le lockfile
npm ci

npm ci échoue si le lockfile ne correspond pas à package.json, au lieu de le mettre à jour silencieusement. Cela détecte les cas où quelqu'un a modifié package.json mais a oublié de mettre à jour le lockfile.

Évaluer avant d'installer#

Avant d'ajouter une dépendance, demandez-vous :

  1. En ai-je vraiment besoin ? Puis-je écrire cela en 20 lignes au lieu d'ajouter un paquet ?
  2. Combien de téléchargements a-t-il ? Des compteurs de téléchargement bas ne sont pas nécessairement mauvais, mais ils signifient moins de regards qui examinent le code.
  3. Quand a-t-il été mis à jour pour la dernière fois ? Un paquet qui n'a pas été mis à jour depuis 3 ans pourrait avoir des vulnérabilités non corrigées.
  4. Combien de dépendances apporte-t-il ? is-odd dépend de is-number qui dépend de kind-of. C'est trois paquets pour faire ce qu'une ligne de code peut faire.
  5. Qui le maintient ? Un seul mainteneur est un seul point de compromission.
typescript
// Vous n'avez pas besoin d'un paquet pour ceci :
const isEven = (n: number): boolean => n % 2 === 0;
 
// Ni pour ceci :
const leftPad = (str: string, len: number, char = " "): string =>
  str.padStart(len, char);
 
// Ni pour ceci :
const isNil = (value: unknown): value is null | undefined =>
  value === null || value === undefined;

La checklist de pré-déploiement#

Voici la checklist que j'utilise réellement avant chaque mise en production. Elle n'est pas exhaustive — la sécurité n'est jamais « terminée » — mais elle détecte les erreurs qui comptent le plus.

#VérificationCritère de réussitePriorité
1AuthentificationJWT vérifiés avec algorithme, émetteur et audience explicites. Pas de alg: none.Critique
2Expiration des jetonsLes jetons d'accès expirent en 15 min ou moins. Les jetons de rafraîchissement tournent à chaque utilisation.Critique
3Stockage des jetonsJetons de rafraîchissement dans des cookies httpOnly sécurisés. Pas de jetons dans localStorage.Critique
4Autorisation sur chaque endpointChaque endpoint d'accès aux données vérifie les permissions au niveau de l'objet. BOLA testé.Critique
5Validation des entréesToutes les entrées utilisateur validées avec Zod ou équivalent. Pas de req.body brut dans les requêtes.Critique
6Injection SQL/NoSQLToutes les requêtes de base de données utilisent des requêtes paramétrées ou des méthodes ORM. Pas de concaténation de chaînes.Critique
7Limitation de débitEndpoints auth : 5/15min. API générale : 60/min. En-têtes de limitation de débit renvoyés.Élevée
8CORSListe d'origines autorisées explicite. Pas de caractère générique avec les identifiants. Preflight en cache.Élevée
9En-têtes de sécuritéCSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy tous présents.Élevée
10Gestion des erreursLes erreurs en production renvoient des messages génériques. Pas de traces de pile, pas d'erreurs SQL exposées.Élevée
11SecretsPas de secrets dans le code ou l'historique git. .env dans .gitignore. Validés au démarrage.Critique
12Dépendancesnpm audit propre (pas de haute/critique). Lockfile commité. npm ci dans le CI.Élevée
13HTTPS uniquementHSTS activé avec preload. HTTP redirige vers HTTPS. Flag de cookie sécurisé défini.Critique
14JournalisationÉvénements d'authentification, accès refusés et mutations de données journalisés. Pas de PII dans les logs.Moyenne
15Limites de taille des requêtesParseur de corps limité (1 Mo par défaut). Uploads de fichiers plafonnés. Pagination des requêtes imposée.Moyenne
16Protection SSRFURLs fournies par l'utilisateur validées. IP privées bloquées. Redirections désactivées ou validées.Moyenne
17Verrouillage de compteLes tentatives de connexion échouées déclenchent un verrouillage après 5 essais. Verrouillage journalisé.Élevée
18Vérification des webhooksTous les webhooks entrants vérifiés avec signatures. Protection contre le rejeu via timestamp.Élevée
19Endpoints d'administrationContrôle d'accès basé sur les rôles sur toutes les routes admin. Tentatives journalisées.Critique
20Assignation de masseLes endpoints de mise à jour utilisent un schéma Zod avec des champs en liste blanche. Pas de spread du corps brut.Élevée

Je conserve cela comme modèle d'issue GitHub. Avant de taguer une release, quelqu'un dans l'équipe doit vérifier chaque ligne et signer. Ce n'est pas glamour, mais ça fonctionne.

Le changement de mentalité#

La sécurité n'est pas une fonctionnalité que vous ajoutez à la fin. Ce n'est pas un sprint que vous faites une fois par an. C'est une manière de penser chaque ligne de code que vous écrivez.

Quand vous écrivez un endpoint, pensez : « Que se passe-t-il si quelqu'un envoie des données que je n'attends pas ? » Quand vous ajoutez un paramètre, pensez : « Que se passe-t-il si quelqu'un change ceci pour l'ID de quelqu'un d'autre ? » Quand vous ajoutez une dépendance, pensez : « Que se passe-t-il si ce paquet est compromis mardi prochain ? »

Vous ne détecterez pas tout. Personne ne le fait. Mais parcourir cette checklist — méthodiquement, avant chaque déploiement — détecte les choses qui comptent le plus. Les gains faciles. Les failles évidentes. Les erreurs qui transforment une mauvaise journée en violation de données.

Prenez l'habitude. Parcourez la checklist. Déployez en toute confiance.

Articles similaires