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.
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 :
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 :
-
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'attaquealg: none, et elle a affecté de vrais systèmes en production. -
issueretaudience— 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. -
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 :
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.
Stockage des jetons : le débat httpOnly Cookie vs localStorage#
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.
// 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 :
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.
// 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 :
// 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 :
// 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 :
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/passwdLe 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 :
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 :
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 :
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#
// 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#
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.compourrait correspondre àevilyourapp.comsi votre regex n'est pas correctement ancrée. credentials: truecar 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 :
// 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#
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 :
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#
-
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 ». -
Utilisez
.env.examplecomme modèle. Il documente quelles variables d'environnement sont nécessaires, sans contenir les valeurs réelles :
# .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- 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 :
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();- 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.
- 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.
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.
// 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.
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.
// 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 :
// 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 :
// 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.
// 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 :
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 ».
// 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 ?
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#
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
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 :
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#
# 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 --allMais 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#
# .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.
# Dans le CI, utilisez ci au lieu de install — il respecte strictement le lockfile
npm cinpm 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 :
- En ai-je vraiment besoin ? Puis-je écrire cela en 20 lignes au lieu d'ajouter un paquet ?
- 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.
- 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.
- Combien de dépendances apporte-t-il ?
is-odddépend deis-numberqui dépend dekind-of. C'est trois paquets pour faire ce qu'une ligne de code peut faire. - Qui le maintient ? Un seul mainteneur est un seul point de compromission.
// 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érification | Critère de réussite | Priorité |
|---|---|---|---|
| 1 | Authentification | JWT vérifiés avec algorithme, émetteur et audience explicites. Pas de alg: none. | Critique |
| 2 | Expiration des jetons | Les jetons d'accès expirent en 15 min ou moins. Les jetons de rafraîchissement tournent à chaque utilisation. | Critique |
| 3 | Stockage des jetons | Jetons de rafraîchissement dans des cookies httpOnly sécurisés. Pas de jetons dans localStorage. | Critique |
| 4 | Autorisation sur chaque endpoint | Chaque endpoint d'accès aux données vérifie les permissions au niveau de l'objet. BOLA testé. | Critique |
| 5 | Validation des entrées | Toutes les entrées utilisateur validées avec Zod ou équivalent. Pas de req.body brut dans les requêtes. | Critique |
| 6 | Injection SQL/NoSQL | Toutes 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 |
| 7 | Limitation de débit | Endpoints auth : 5/15min. API générale : 60/min. En-têtes de limitation de débit renvoyés. | Élevée |
| 8 | CORS | Liste d'origines autorisées explicite. Pas de caractère générique avec les identifiants. Preflight en cache. | Élevée |
| 9 | En-têtes de sécurité | CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy tous présents. | Élevée |
| 10 | Gestion des erreurs | Les erreurs en production renvoient des messages génériques. Pas de traces de pile, pas d'erreurs SQL exposées. | Élevée |
| 11 | Secrets | Pas de secrets dans le code ou l'historique git. .env dans .gitignore. Validés au démarrage. | Critique |
| 12 | Dépendances | npm audit propre (pas de haute/critique). Lockfile commité. npm ci dans le CI. | Élevée |
| 13 | HTTPS uniquement | HSTS activé avec preload. HTTP redirige vers HTTPS. Flag de cookie sécurisé défini. | Critique |
| 14 | Journalisation | Événements d'authentification, accès refusés et mutations de données journalisés. Pas de PII dans les logs. | Moyenne |
| 15 | Limites de taille des requêtes | Parseur de corps limité (1 Mo par défaut). Uploads de fichiers plafonnés. Pagination des requêtes imposée. | Moyenne |
| 16 | Protection SSRF | URLs fournies par l'utilisateur validées. IP privées bloquées. Redirections désactivées ou validées. | Moyenne |
| 17 | Verrouillage de compte | Les tentatives de connexion échouées déclenchent un verrouillage après 5 essais. Verrouillage journalisé. | Élevée |
| 18 | Vérification des webhooks | Tous les webhooks entrants vérifiés avec signatures. Protection contre le rejeu via timestamp. | Élevée |
| 19 | Endpoints d'administration | Contrôle d'accès basé sur les rôles sur toutes les routes admin. Tentatives journalisées. | Critique |
| 20 | Assignation de masse | Les 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.