- Sécurité
- NestJS
- OWASP
- Prisma
Un audit sérieux sur son propre code, avant lancement. Pour chaque faille : le motif à chercher, pourquoi elle est exploitable, et le correctif. Aucune n'était théorique.
Avant de mettre en ligne une marketplace avec comptes, paiements et téléversements, j'ai audité ma propre API comme si elle appartenait à quelqu'un d'autre. Douze classes de failles sont ressorties. Aucune n'était théorique ; toutes étaient atteignables depuis un navigateur.
Voici les plus instructives, avec pour chacune le motif à chercher dans votre propre code.
1. Le mass-assignment que TypeScript ne protège pas
grep -rn "Partial<Create" --include="*.controller.ts" src/Le corps de requête typé Partial<CreateUserDto> paraît sûr. Il ne l'est pas. Partial est un mapped type TypeScript : il est effacé à la compilation. À l'exécution, il ne reste que Object. Le ValidationPipe de NestJS, même configuré en whitelist et forbidNonWhitelisted, n'a plus aucune métadonnée sur laquelle s'appuyer : il ne valide rien du tout.
Résultat : n'importe quel champ envoyé atteint Prisma. role, isVerified, sellerUserId. Un utilisateur se promeut administrateur avec un champ JSON.
Correctif : class UpdateUserDto extends PartialType(CreateUserDto) {} depuis @nestjs/mapped-types, qui conserve les décorateurs à l'exécution. Et en défense en profondeur, une liste blanche explicite côté service plutôt qu'un ...rest.
2. Rotation de refresh token non atomique
Le schéma classique — vérifier le token, puis le révoquer, puis en émettre un nouveau — comporte une fenêtre de concurrence. Deux requêtes simultanées avec le même token passent toutes deux la vérification avant que l'une ait révoqué. Deux couples de tokens vivants coexistent, et la détection de réutilisation devient aveugle : c'est précisément le mécanisme censé repérer un vol de token qui est neutralisé.
Correctif : un compare-and-swap, en une seule requête.
const { count } = await prisma.refreshToken.updateMany({
where: { id, revokedAt: null },
data: { revokedAt: new Date() },
});
if (count !== 1) throw new UnauthorizedException();Un seul gagnant, garanti par la base.
3. Énumération d'emails au login
Trois fuites différentes mènent au même résultat — savoir si une adresse est inscrite :
- Le temps de réponse. Si
argon2.verifyn'est appelé que lorsque l'email existe, la réponse est mesurablement plus lente pour un compte réel. Argon2 est délibérément coûteux : l'écart est de l'ordre de la centaine de millisecondes, parfaitement observable. - Les messages. « Email inconnu » contre « mot de passe incorrect ».
- Les états. Révéler qu'un compte est suspendu avant d'avoir vérifié le mot de passe.
Correctif : toujours exécuter la vérification, contre un hash factice pré-calculé quand l'utilisateur n'existe pas. Un 401 uniforme pour tout échec. Et l'état du compte révélé seulement après un mot de passe valide.
await argon2.verify(user?.passwordHash ?? DUMMY_HASH, pepper + password);4. Une action destructive derrière un simple access token
La suppression de compte acceptée sur présentation du seul access token signifie qu'un token volé — XSS, session laissée ouverte — suffit à détruire les données. Correctif : exiger le mot de passe dans le corps de la requête pour toute action irréversible. Même schéma que le changement de mot de passe.
5. trust proxy mal réglé derrière un CDN
grep -rn "trust proxy" src/main.tsapp.set('trust proxy', 1) déclare un saut de proxy. Derrière Cloudflare puis Caddy, il y en a deux. Avec un saut de trop non déclaré, req.ip devient l'adresse du nœud de bordure du CDN — identique pour tous les visiteurs. Le rate-limiting par IP, la détection de force brute et les hachages d'IP deviennent tous inopérants, sans que rien ne le signale.
Correctif : rendre le nombre de sauts configurable par environnement (1 en développement, 2 derrière le CDN en production).
6. Rate-limiting par IP seulement
Limiter par IP punit les utilisateurs légitimes derrière un NAT partagé et ne gêne pas un attaquant qui change d'adresse. Correctif : un tracker qui utilise l'identifiant utilisateur quand la requête est authentifiée, et l'IP seulement à défaut. Stockage dans Redis dès qu'il y a plus d'une instance, sans quoi chaque instance compte dans son coin. Et des seuils resserrés là où ça compte : connexion et inscription à 5 par minute, mot de passe oublié à 3, appels IA à 3-5.
7. Captcha : couverture back ≠ couverture front
Le décalage est sournois parce qu'il est invisible en développement, où le fournisseur de captcha est généralement désactivé. Si le back exige un token que le formulaire ne produit pas, le formulaire est mort en production — et personne ne le voit, puisqu'il ne lève pas d'erreur visible : il retourne simplement un 400.
grep -rn "@RequireCaptcha" --include="*.controller.ts" # exigences côté back
grep -rln "CaptchaWidget" src/app src/components # rendus côté frontLes deux listes doivent coïncider. Cibles pertinentes : inscription, mot de passe oublié, contact, demande de devis, premier message. Pas la connexion — le throttling y suffit et le captcha y dégrade l'expérience pour rien.
8. Stockage objet : une policy globale au lieu d'une policy par préfixe
Rendre un bucket S3 ou MinIO lisible publiquement d'un seul geste expose tout ce qu'il contient — y compris les messages privés, les pièces justificatives et les documents de vérification d'identité. Correctif : une policy par préfixe. Publics : images de catalogue, profils. Privés, accessibles uniquement par URL signée : messages, documents, vidéos.
9. Webhook de paiement non vérifié
Un webhook de paiement traité sans vérification de signature est un point de terminaison qui crédite un compte sur simple requête HTTP. Le correctif complet a quatre morceaux, et trois ne suffisent pas :
- Activer le corps brut de la requête (
rawBody) — une signature se vérifie sur les octets exacts, pas sur le JSON reparsé. - Vérifier la signature avant tout traitement.
- Rendre le traitement idempotent : les fournisseurs rejouent les événements, par conception.
- Vérifier que le statut est bien payé, et traiter le cas du paiement asynchrone confirmé plus tard.
10. Adresses IP en clair en base
Une IP est une donnée personnelle. Stockée en clair et sans limite de durée, elle transforme une table de logs en fichier nominatif. Correctif : ne stocker qu'un sha256(ip + sel), avec une rétention bornée. Le hash conserve la capacité de détecter des récidives sans conserver l'identité.
11. Rôle périmé dans le JWT
Un token émis avant une rétrogradation continue de porter l'ancien rôle jusqu'à son expiration. Avec un access token de quinze minutes, c'est un quart d'heure d'accès administrateur accordé à quelqu'un qui vient de le perdre. Correctif : relire statut et rôle en base à chaque requête, avec un cache court et une invalidation explicite à chaque changement de rôle.
12. Un 403 qui répond à la question qu'on ne voulait pas poser
Répondre 403 sur une ressource appartenant à autrui confirme qu'elle existe. Sur des identifiants séquentiels, cela suffit à mesurer le volume d'affaires d'un concurrent. Correctif : répondre 404. L'absence de droit et l'inexistence doivent être indiscernables.
La méthode compte autant que la liste
Deux règles ont plus de valeur que le catalogue lui-même.
Contra-valider chaque signalement dans le code. Lire le fichier, ne pas croire un rapport — le sien, celui d'un outil ou celui d'un tiers. Un audit qui produit des faux positifs finit ignoré, et c'est le vrai danger.
Prouver que la faille est fermée. Un test qui échoue avant le correctif et passe après. Sans cette preuve, on n'a pas corrigé une faille : on a modifié du code en espérant.