Aller au contenu principal
Smidjan
Cybersécurité

Sécuriser son application web : le guide OWASP 2025

Sécuriser son application web : le guide OWASP 2025

L'OWASP a publié une nouvelle édition de son Top 10. Depuis, la version de référence n'est plus celle de 2021 : la page officielle du projet indique que « la version publiée la plus récente est l'OWASP Top Ten 2025 ». Si votre checklist de sécurité parle encore d'« A03 Injection » ou d'« A10 SSRF », elle référence une liste qui n'est plus à jour.

Cet article reprend les dix catégories de l'édition 2025 dans leur ordre officiel. Pour chacune : le mécanisme de la faille, un exemple de code vulnérable, le correctif commenté en TypeScript / Next.js, et la façon de vérifier que le correctif tient. L'objectif est qu'un développeur puisse appliquer directement, pas seulement comprendre.

Ce qui change avec l'édition 2025

La méthodologie est décrite dans l'introduction officielle. Elle s'appuie sur des données contribuées portant sur plus de 2,8 millions d'applications et sur environ 175 000 enregistrements CVE rattachés à des CWE (contre environ 125 000 pour l'édition 2021). Les dix catégories regroupent 248 CWE, soit une moyenne de 25 CWE par catégorie. La prévalence est calculée comme le nombre d'applications présentant au moins une occurrence d'un CWE lors des tests, et non comme un décompte brut d'occurrences.

Voici la liste officielle 2025, avec le mouvement par rapport à 2021 :

Rang 2025 Catégorie Évolution depuis 2021
A01:2025 Broken Access Control Reste n°1 ; absorbe le SSRF
A02:2025 Security Misconfiguration Monte du n°5 au n°2
A03:2025 Software Supply Chain Failures Nouvelle catégorie, élargit l'ancien A06
A04:2025 Cryptographic Failures Descend du n°2 au n°4
A05:2025 Injection Descend du n°3 au n°5
A06:2025 Insecure Design Descend du n°4 au n°6
A07:2025 Authentication Failures Reste n°7, renommée
A08:2025 Software or Data Integrity Failures Reste n°8
A09:2025 Security Logging and Alerting Failures Reste n°9, renommée
A10:2025 Mishandling of Exceptional Conditions Nouvelle catégorie

Trois changements structurels méritent d'être retenus. D'abord, le SSRF a disparu en tant que catégorie autonome : le CWE-918 est désormais rattaché à A01, l'OWASP considérant qu'il s'agit d'un défaut de contrôle d'accès. Ensuite, l'ancienne catégorie « Vulnerable and Outdated Components » est remplacée par A03 Software Supply Chain Failures, dont le périmètre est bien plus large : elle couvre la chaîne de construction, les pipelines CI/CD, les registres d'artefacts et les postes de développement, pas seulement les dépendances périmées. Enfin, A10 Mishandling of Exceptional Conditions est une catégorie neuve : l'OWASP explique que les classifications précédentes liées à la qualité du code étaient « trop générales » pour être actionnables.

Deux catégories ont été renommées : A07 devient « Authentication Failures » (au lieu de « Identification and Authentication Failures ») et A09 passe de « Monitoring » à « Alerting » — le déplacement de vocabulaire n'est pas cosmétique : journaliser sans alerter ne sert à rien pendant un incident.

A01 — Broken Access Control

Le contrôle d'accès défaillant reste la première catégorie, et l'OWASP indique que 100 % des applications testées présentaient au moins une occurrence d'un CWE de cette catégorie. Le mécanisme est simple : un utilisateur agit hors du périmètre de permissions prévu — lecture d'une ressource d'autrui, modification d'un objet en changeant un identifiant dans l'URL, appel direct d'une route d'API que l'interface ne propose pas.

Le piège le plus courant en Next.js consiste à protéger la page et à oublier la route d'API ou l'action serveur qui la sert. Masquer un bouton dans l'interface n'est pas une frontière de sécurité : la requête peut être forgée directement.

// ❌ app/api/factures/[id]/route.ts — référence directe non contrôlée (IDOR)
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  // N'importe qui d'authentifié — voire non authentifié — peut incrémenter
  // l'identifiant et lire les factures des autres clients.
  const facture = await prisma.facture.findUnique({ where: { id } });
  return Response.json(facture);
}

Le correctif ne consiste pas à ajouter un if après la lecture, mais à faire du propriétaire un critère de la requête elle-même : la donnée d'autrui n'est jamais chargée en mémoire.

// ✅ Le filtre d'appartenance fait partie de la requête, pas d'un test a posteriori.
import { auth } from '@/lib/auth';

export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
  const session = await auth();
  if (!session?.user) {
    return Response.json({ error: 'Non authentifié' }, { status: 401 });
  }

  const { id } = await params;
  const facture = await prisma.facture.findFirst({
    where: { id, proprietaireId: session.user.id },
  });

  // 404 et non 403 : un 403 confirmerait à l'attaquant que l'identifiant existe.
  if (!facture) {
    return Response.json({ error: 'Introuvable' }, { status: 404 });
  }

  return Response.json(facture);
}

Le SSRF relevant désormais de cette catégorie, voici le second cas typique : un proxy d'image ou un webhook qui va chercher une URL fournie par l'utilisateur. Sur une infrastructure cloud, l'URL cible peut être le service de métadonnées de l'instance, qui renvoie des identifiants.

// ✅ Défense SSRF : schéma contraint, liste blanche d'hôtes, redirections non suivies
import { NextRequest } from 'next/server';

const HOTES_AUTORISES = new Set(['images.exemple.be', 'cdn.exemple.be']);

export async function GET(request: NextRequest) {
  const brut = request.nextUrl.searchParams.get('url');
  if (!brut) return Response.json({ error: 'Paramètre manquant' }, { status: 400 });

  let cible: URL;
  try {
    cible = new URL(brut);
  } catch {
    return Response.json({ error: 'URL invalide' }, { status: 400 });
  }

  // 1. Schéma : bloque file:, gopher:, data: — pas seulement « pas http »
  if (cible.protocol !== 'https:') {
    return Response.json({ error: 'Refusé' }, { status: 403 });
  }

  // 2. Comparaison EXACTE d'hôte. Un endsWith('.exemple.be') serait contourné
  //    par « exemple.be.attaquant.com ».
  if (!HOTES_AUTORISES.has(cible.hostname)) {
    return Response.json({ error: 'Refusé' }, { status: 403 });
  }

  // 3. redirect: 'manual' — sinon un 302 renvoyé par l'hôte autorisé vers
  //    169.254.169.254 (métadonnées cloud) contourne toute la liste blanche.
  const amont = await fetch(cible, { redirect: 'manual', signal: AbortSignal.timeout(5000) });
  if (amont.status >= 300 && amont.status < 400) {
    return Response.json({ error: 'Redirection refusée' }, { status: 403 });
  }

  return new Response(amont.body, {
    headers: { 'content-type': amont.headers.get('content-type') ?? 'application/octet-stream' },
  });
}

Comment tester. Construisez une matrice d'autorisations : pour chaque route sensible, listez les rôles (anonyme, utilisateur A, utilisateur B, admin) et le résultat attendu. Puis rejouez chaque requête avec le jeton de session d'un autre utilisateur. Un test automatisé suffit et vaut mieux qu'un scanner :

// tests/authz.spec.ts
test("A ne peut pas lire la facture de B", async ({ request }) => {
  const reponse = await request.get(`/api/factures/${idFactureDeB}`, {
    headers: { cookie: cookieDeA },
  });
  expect(reponse.status()).toBe(404);
});

Côté SSRF, testez avec http://127.0.0.1:3000/, http://[::1]/, http://169.254.169.254/ et une URL de votre domaine autorisé qui renvoie une redirection 302 vers l'une de ces adresses.

Les recommandations officielles de l'OWASP pour cette catégorie tiennent en quelques principes : refuser par défaut, appliquer le contrôle exclusivement côté serveur, modéliser l'appartenance des enregistrements, désactiver le listing de répertoires, limiter le débit des API, invalider les sessions et préférer des JWT à durée de vie courte (A01:2025).

A02 — Security Misconfiguration

La mauvaise configuration bondit de la 5e à la 2e place, et là encore 100 % des applications testées présentaient au moins une occurrence. Il ne s'agit pas d'un défaut de code mais d'un défaut de réglage : comptes par défaut inchangés, fonctionnalités inutiles activées, listing de répertoires, messages d'erreur détaillés exposant traces d'exécution et versions de composants, stockage cloud trop permissif, applications d'exemple laissées en production (A02:2025).

En Next.js, l'essentiel se joue sur les en-têtes de réponse et la séparation stricte serveur/client. Voici une base saine, alignée sur la fiche OWASP sur les en-têtes HTTP :

// next.config.ts
import type { NextConfig } from 'next';

const enTetesSecurite = [
  // Deux ans, sous-domaines inclus. « preload » n'est à activer que si vous
  // maîtrisez TOUS vos sous-domaines : le retrait de la liste prend des mois.
  { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
  { key: 'X-Content-Type-Options', value: 'nosniff' },
  { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
  { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
  { key: 'Cross-Origin-Opener-Policy', value: 'same-origin' },
  // ⚠️ X-XSS-Protection: 1; mode=block était la recommandation d'il y a dix ans.
  // L'en-tête est non standard et déprécié, et l'OWASP comme le MDN signalent
  // qu'il a pu CRÉER des failles XSS sur des sites par ailleurs sains.
  // On le neutralise explicitement et on s'appuie sur la CSP.
  { key: 'X-XSS-Protection', value: '0' },
];

const nextConfig: NextConfig = {
  poweredByHeader: false, // supprime « X-Powered-By: Next.js »
  async headers() {
    return [{ source: '/:path*', headers: enTetesSecurite }];
  },
};

export default nextConfig;

Ce point mérite d'être souligné car il circule encore dans quantité de tutoriels : X-XSS-Protection: 1; mode=block ne doit plus être posé. Le MDN le classe comme non standard et déprécié, et recommande explicitement d'utiliser la Content-Security-Policy à la place (MDN).

La seconde erreur de configuration typique en App Router est la fuite de secret par le client. Une variable préfixée NEXT_PUBLIC_ est inscrite dans le bundle JavaScript livré au navigateur ; un secret qui s'y trouve est public.

// lib/env.ts — validation au démarrage : l'application refuse de partir
// plutôt que de tomber en production sur une variable absente.
import { z } from 'zod';

const schema = z.object({
  DATABASE_URL: z.url(), // Zod 4 ; sur Zod 3 : z.string().url()
  AUTH_SECRET: z.string().min(32),
  RESEND_API_KEY: z.string().startsWith('re_'),
});

export const env = schema.parse(process.env);

Comment tester. Passez le site sur Mozilla Observatory ou securityheaders.com, et la configuration TLS sur SSL Labs. Vérifiez ensuite qu'aucun secret n'a fui côté client :

# Aucun secret ne doit apparaître dans le bundle client
npm run build && grep -rn "sk_live\|AUTH_SECRET\|postgres://" .next/static/ || echo "OK"

A03 — Software Supply Chain Failures

Nouvelle catégorie 2025, et la plus haute par taux d'incidence moyen dans les données contribuées (5,19 %). Elle a aussi été classée n°1 par la moitié des répondants à l'enquête communautaire de l'OWASP. Elle recouvre les « ruptures ou compromissions dans le processus de construction, de distribution ou de mise à jour d'un logiciel » : dépendances vulnérables ou malveillantes, pipeline CI/CD, dépôts de code, registres d'artefacts, postes de développement (A03:2025).

L'OWASP cite trois cas concrets sur cette page : la compromission de SolarWinds en 2019, qui a diffusé un logiciel malveillant via les mises à jour de l'éditeur à environ 18 000 organisations ; le vol de 1,5 milliard de dollars chez Bybit en 2025 via le logiciel de portefeuille ; et Shai-Hulud, premier ver npm auto-propageant, qui exploitait les scripts post-install sur les machines de développement.

Ce dernier point est le plus directement actionnable : npm install exécute par défaut du code arbitraire fourni par les paquets.

# Installation reproductible : échoue si package-lock.json diverge de package.json
npm ci

# Les scripts de cycle de vie sont le vecteur de Shai-Hulud.
# Les désactiver globalement, puis les réactiver au cas par cas.
npm ci --ignore-scripts

# Audit restreint aux dépendances de production, seuil bloquant
npm audit --omit=dev --audit-level=high

# Scanner multi-écosystèmes contre la base OSV (binaire à installer)
osv-scanner scan -r .

Posez ces réglages une fois pour toutes dans le fichier .npmrc du projet :

ignore-scripts=true
save-exact=true
audit-level=high

Côté CI, la règle est d'épingler les actions à un condensat de commit immuable : une étiquette comme @v4 est mutable et peut être repointée vers du code malveillant sans que rien ne change dans votre dépôt.

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read # jeton en lecture seule par défaut
    steps:
      # ❌ - uses: actions/checkout@v4        (étiquette mutable)
      # ✅ épinglage au SHA complet du commit, avec la version en commentaire.
      #    Récupérez le SHA réel de la version que vous auditez :
      #    gh api repos/actions/checkout/git/ref/tags/v5.0.0 --jq .object.sha
      - uses: actions/checkout@COLLEZ_ICI_LE_SHA_COMPLET # v5.0.0
      - run: npm ci --ignore-scripts
      - run: npm audit --omit=dev --audit-level=high

Comment tester. Générez un SBOM et confrontez-le aux bases de vulnérabilités à chaque build : npm sbom --sbom-format=cyclonedx > sbom.json. Activez Dependabot ou Renovate pour les mises à jour, et surtout faites échouer le build sur les vulnérabilités critiques plutôt que d'envoyer un rapport que personne ne lit. L'OWASP recommande en complément de n'obtenir les composants que depuis des sources officielles et signées, de déployer par paliers plutôt que partout simultanément, et d'imposer MFA et séparation des responsabilités sur les dépôts, serveurs de build et artefacts.

A04 — Cryptographic Failures

La catégorie descend au 4e rang mais son contenu s'est déplacé : les faiblesses les plus fréquentes concernent désormais les générateurs pseudo-aléatoires (CWE-338, CWE-331, CWE-1241), aux côtés des clés en dur (CWE-321), de la transmission en clair (CWE-319) et des algorithmes cassés (CWE-327). Les deux scénarios types documentés sont le déclassement d'une connexion HTTPS vers HTTP pour intercepter un cookie de session, et le cassage de mots de passe stockés sans sel ou avec un condensat rapide (A04:2025).

Première erreur, extrêmement répandue : Math.random() pour générer un jeton. Il n'est pas cryptographiquement sûr et son état interne est reconstructible.

import { randomBytes, timingSafeEqual } from 'node:crypto';

// ❌ const jeton = Math.random().toString(36).slice(2);  → prédictible (CWE-338)
// ✅ 32 octets d'entropie issus du CSPRNG du système
export function genererJeton(): string {
  return randomBytes(32).toString('base64url');
}

// Comparaison à temps constant : un === renvoie dès le premier octet différent,
// ce qui laisse fuir le préfixe correct par mesure du temps de réponse.
export function comparerJetons(a: string, b: string): boolean {
  const bufA = Buffer.from(a, 'utf8');
  const bufB = Buffer.from(b, 'utf8');
  // timingSafeEqual lève une exception si les longueurs diffèrent :
  // ce test préalable est obligatoire, et il ne fuit que la longueur.
  if (bufA.length !== bufB.length) return false;
  return timingSafeEqual(bufA, bufB);
}

Pour les mots de passe, la fiche OWASP dédiée recommande Argon2id en premier choix, avec des jeux de paramètres équivalents en résistance, dont m=19456 (19 Mio), t=2, p=1. À défaut, scrypt ; bcrypt uniquement pour l'existant, avec un facteur de travail d'au moins 10 ; PBKDF2 si une conformité FIPS-140 l'impose, à 600 000 itérations en HMAC-SHA256.

import { hash, verify } from '@node-rs/argon2';

// Paramètres OWASP : m=19456 KiB, t=2, p=1
const OPTIONS_ARGON2 = { memoryCost: 19456, timeCost: 2, parallelism: 1 } as const;

export function hacherMotDePasse(motDePasse: string): Promise<string> {
  // Le sel est généré aléatoirement et encodé dans la chaîne PHC retournée :
  // aucune colonne « salt » séparée n'est nécessaire.
  return hash(motDePasse, OPTIONS_ARGON2);
}

export async function verifierMotDePasse(stocke: string, candidat: string): Promise<boolean> {
  try {
    return await verify(stocke, candidat);
  } catch {
    // Condensat corrompu ou format inconnu : on refuse, on ne propage pas.
    return false;
  }
}

Si vous restez sur bcrypt, retenez sa limite structurelle : l'entrée est tronquée au-delà de 72 octets. Un utilisateur avec une phrase de passe longue voit sa fin de mot de passe ignorée. L'OWASP recommande soit de plafonner explicitement l'entrée, soit de pré-hacher via un HMAC encodé en base64.

L'OWASP ajoute une recommandation prospective utile à noter dès maintenant : préparer la migration vers la cryptographie post-quantique à l'horizon 2030, ce qui suppose de savoir où se trouvent vos usages cryptographiques.

Comment tester. testssl.sh ou SSL Labs pour la couche transport. Pour le stockage, une revue de code suffit : cherchez md5, sha1, createHash('sha256') appliqué à un mot de passe, et Math.random dans tout ce qui produit un jeton, un identifiant de session ou un code de réinitialisation.

grep -rnE "Math\.random|createHash\('(md5|sha1)'\)" src/ --include=*.ts --include=*.tsx

A05 — Injection

L'injection descend au 5e rang mais reste massive : 37 CWE rattachés, dont CWE-79 (Cross-Site Scripting) avec plus de 30 000 CVE et CWE-89 (injection SQL) avec plus de 14 000. Point important pour vos référentiels internes : le XSS est bien classé ici, dans Injection, et non dans une catégorie séparée (A05:2025).

Le principe défensif est unique : séparer le code des données, plutôt que tenter de filtrer les caractères dangereux.

// ❌ Interpolation dans du SQL brut
const utilisateurs = await prisma.$queryRawUnsafe(
  `SELECT * FROM "User" WHERE email = '${email}'`
);

// ✅ Template balisé : Prisma transforme chaque ${} en paramètre lié.
// Attention : c'est le template balisé qui protège, pas la fonction —
// $queryRawUnsafe reste vulnérable même appelée avec les mêmes données.
const utilisateurs = await prisma.$queryRaw`
  SELECT * FROM "User" WHERE email = ${email}
`;

Même logique pour les commandes système. exec passe par un shell qui interprète ;, | et $( ) ; execFile ne le fait pas.

import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

const executer = promisify(execFile);

// ❌ exec(`convert ${nomFichier} sortie.png`) → « photo.png; cat /etc/passwd »
// ✅ execFile n'ouvre aucun shell : les métacaractères redeviennent du texte.
export async function convertir(nomFichier: string) {
  // On valide malgré tout : un argument commençant par « - » pourrait être
  // interprété comme une option par le binaire appelé.
  if (!/^[A-Za-z0-9._]+$/.test(nomFichier)) {
    throw new Error('Nom de fichier refusé');
  }
  await executer('/usr/bin/convert', [nomFichier, 'sortie.png']);
}

Pour le XSS, React échappe automatiquement les valeurs interpolées dans le JSX — mais deux portes de sortie restent ouvertes. La première est dangerouslySetInnerHTML :

// ✅ Nettoyage par liste blanche avant injection de HTML
import sanitizeHtml from 'sanitize-html';

export function ContenuRiche({ html }: { html: string }) {
  const propre = sanitizeHtml(html, {
    allowedTags: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'a', 'code', 'pre'],
    allowedAttributes: { a: ['href', 'title'] },
    // Sans cette ligne, « javascript:alert(1) » dans un href passe.
    allowedSchemes: ['http', 'https', 'mailto'],
  });
  return <div dangerouslySetInnerHTML={{ __html: propre }} />;
}

La seconde est plus discrète et souvent oubliée : une URL fournie par l'utilisateur placée dans un href. React n'inspecte pas le schéma, et javascript: s'exécute au clic.

// ❌ <a href={profil.siteWeb}>  →  « javascript:fetch('/api/admin/delete') »
// ✅ On n'accepte que http(s), et on retire le lien sinon.
function lienSur(brut: string): string | undefined {
  try {
    const url = new URL(brut); // lève sur une URL relative : voulu ici
    return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : undefined;
  } catch {
    return undefined;
  }
}

export function LienProfil({ siteWeb }: { siteWeb: string }) {
  const href = lienSur(siteWeb);
  if (!href) return <span>{siteWeb}</span>;
  return <a href={href} rel="noopener noreferrer nofollow" target="_blank">{href}</a>;
}

Comment tester. En boîte noire, sqlmap sur les paramètres, et une charge XSS non destructive du type "><img src=x onerror=console.log(1)> dans chaque champ réaffiché. En boîte blanche, c'est plus rapide : une règle Semgrep sur les motifs dangereux, exécutée en CI.

semgrep --config=p/typescript --config=p/owasp-top-ten --error
grep -rn "dangerouslySetInnerHTML\|queryRawUnsafe\|child_process.*exec(" src/

A06 — Insecure Design

Cette catégorie regroupe les faiblesses issues d'une conception « manquante ou inefficace ». La formule de l'OWASP est la meilleure définition de la frontière : une conception sûre mal implémentée se corrige ; une conception non sûre ne se corrige pas par une implémentation parfaite, parce que les contrôles nécessaires n'ont jamais été créés (A06:2025).

Les exemples cités sont éclairants parce qu'ils ne comportent aucun bug : les questions secrètes pour récupérer un compte (contraires aux recommandations du NIST), une logique métier permettant de réserver des centaines de sièges de cinéma en une opération, ou l'absence de protection anti-robot qui laisse les revendeurs rafler un stock.

Prenons la réinitialisation de mot de passe — le lieu où la conception se voit le mieux. Trois décisions de conception doivent être prises avant d'écrire la moindre ligne.

import { randomBytes, createHash } from 'node:crypto';

const DUREE_VALIDITE_MS = 15 * 60 * 1000;

export async function demanderReinitialisation(email: string) {
  const utilisateur = await prisma.user.findUnique({ where: { email } });

  // Décision 1 — Non-énumération : la réponse est IDENTIQUE que le compte
  // existe ou non. On sort silencieusement, sans révéler quoi que ce soit.
  if (!utilisateur) return;

  // Décision 2 — Le jeton est aléatoire, à usage unique et daté.
  const jeton = randomBytes(32).toString('base64url');

  // Décision 3 — On stocke le CONDENSAT du jeton, jamais le jeton.
  // Une fuite de la base ne permet alors pas de prendre les comptes.
  // SHA-256 suffit ici : le jeton a déjà 256 bits d'entropie, contrairement
  // à un mot de passe — pas besoin d'un algorithme lent.
  const empreinte = createHash('sha256').update(jeton).digest('hex');

  await prisma.jetonReinitialisation.create({
    data: {
      utilisateurId: utilisateur.id,
      empreinte,
      expireLe: new Date(Date.now() + DUREE_VALIDITE_MS),
    },
  });

  await envoyerEmail(email, `${process.env.APP_URL}/reinitialiser?jeton=${jeton}`);
}

Notez que ces trois décisions ne relèvent d'aucune bibliothèque. Elles se prennent au tableau blanc, en se demandant ce qu'un attaquant obtiendrait s'il volait la base, s'il testait mille adresses, ou s'il rejouait un lien déjà utilisé.

Comment tester. La modélisation de menaces sur les fonctions critiques (authentification, paiement, export de données) est la contre-mesure recommandée par l'OWASP, avec la constitution d'une bibliothèque de motifs de conception sûrs, l'intégration de la sécurité dans les user stories, et surtout l'écriture de tests qui valident la résistance aux menaces identifiées — un test qui vérifie qu'un lien de réinitialisation ne fonctionne pas deux fois vaut mieux qu'une ligne de documentation.

A07 — Authentication Failures

Renommée en 2025 pour mieux refléter les 36 CWE qu'elle regroupe. Le mécanisme : « un attaquant parvient à faire reconnaître par le système un utilisateur invalide ou incorrect comme légitime ». Les exemples cités incluent le credential stuffing, la pulvérisation de mots de passe par motifs saisonniers (« Winter2025 » puis « Winter2026 »), la dépendance au seul mot de passe, et les délais d'expiration de session mal implémentés dans les systèmes SSO (A07:2025).

Les recommandations officielles sont explicites : imposer l'authentification multifacteur, contrôler la faiblesse des mots de passe, les confronter aux listes de mots de passe compromis, s'aligner sur le NIST SP 800-63B, et utiliser un gestionnaire de sessions côté serveur.

Un mot sur la limitation de débit, parce que l'exemple qui circule partout comporte deux défauts.

import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const parIp = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '10 m'),
  prefix: 'auth:ip',
});

// Défaut n°1 corrigé : limiter aussi PAR COMPTE. Sinon un botnet réparti sur
// mille adresses attaque un même compte sans jamais toucher la limite par IP.
const parCompte = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, '15 m'),
  prefix: 'auth:compte',
});

// Défaut n°2 corrigé : x-forwarded-for contient « client, proxy1, proxy2 »
// et il est FALSIFIABLE si l'application n'est pas derrière un proxy de
// confiance qui le réécrit. Sur Vercel, préférer l'en-tête normalisé.
function ipClient(request: Request): string {
  const direct = request.headers.get('x-real-ip');
  if (direct) return direct;
  const xff = request.headers.get('x-forwarded-for');
  return xff?.split(',')[0]?.trim() ?? 'inconnu';
}

export async function POST(request: Request) {
  const { email, motDePasse } = await request.json();

  const [ip, compte] = await Promise.all([
    parIp.limit(ipClient(request)),
    parCompte.limit(email.toLowerCase()),
  ]);

  if (!ip.success || !compte.success) {
    // Message et code identiques à un échec d'identifiants : on ne dit pas
    // à l'attaquant qu'il vient de trouver un compte qui existe.
    return Response.json({ error: 'Identifiants invalides' }, { status: 401 });
  }

  const utilisateur = await prisma.user.findUnique({ where: { email } });

  // CONDENSAT_FACTICE est un vrai condensat Argon2id, généré une fois au
  // démarrage sur une valeur aléatoire et gardé en constante de module.
  const valide = utilisateur
    ? await verifierMotDePasse(utilisateur.motDePasseHache, motDePasse)
    : // Vérification factice : sans elle, une réponse instantanée révèle que
      // l'adresse n'existe pas — énumération de comptes par mesure du temps.
      await verifierMotDePasse(CONDENSAT_FACTICE, motDePasse);

  if (!utilisateur || !valide) {
    return Response.json({ error: 'Identifiants invalides' }, { status: 401 });
  }

  // ... création de session
}

Sur la politique de mots de passe elle-même, le NIST SP 800-63B a changé de doctrine et beaucoup d'applications sont restées sur l'ancienne : ne pas imposer de rotation périodique sans indice de compromission, ne pas imposer de règles de composition (majuscule + chiffre + caractère spécial), mais exiger une longueur minimale généreuse et vérifier le mot de passe contre une liste de condensats compromis.

Comment tester. Vérifiez qu'après N tentatives la limite s'applique bien par compte et pas seulement par IP, que le cookie de session change de valeur après connexion (sinon : fixation de session, CWE-384), et que la déconnexion invalide bien la session côté serveur et pas seulement le cookie côté navigateur.

A08 — Software or Data Integrity Failures

La catégorie a perdu un « and » au profit d'un « or », mais surtout elle traite d'un point précis : faire confiance à du code ou à des données sans en vérifier l'intégrité. Greffons et bibliothèques non fiables, pipelines CI/CD qui ne vérifient pas les artefacts, mises à jour automatiques sans contrôle d'intégrité, et désérialisation de données non fiables (CWE-502) (A08:2025).

En écosystème JavaScript, la désérialisation dangereuse prend surtout la forme de la pollution de prototype : un objet JSON contrôlé par l'attaquant qui, fusionné naïvement, modifie Object.prototype pour toute l'application.

// ❌ Fusion naïve : un corps { "__proto__": { "estAdmin": true } } contamine
// Object.prototype, et tout objet de l'application hérite alors de estAdmin.
function fusionner(cible: any, source: any) {
  for (const cle in source) cible[cle] = source[cle];
}

// ✅ Validation par schéma strict : les clés non déclarées sont rejetées,
// et rien d'inattendu n'atteint jamais un objet de l'application.
import { z } from 'zod';

// z.strictObject rejette toute propriété non déclarée, dont « __proto__ ».
// (Sur Zod 3, la forme équivalente est z.object({...}).strict().)
const SchemaProfil = z.strictObject({
  nom: z.string().min(1).max(100),
  bio: z.string().max(500).optional(),
});

export async function PATCH(request: Request) {
  const resultat = SchemaProfil.safeParse(await request.json());
  if (!resultat.success) {
    return Response.json({ error: 'Requête invalide' }, { status: 400 });
  }
  // resultat.data ne contient QUE nom et bio, correctement typés.
}

Pour tout script tiers chargé depuis un domaine que vous ne contrôlez pas, l'intégrité de sous-ressource (SRI) garantit que le fichier livré est bien celui que vous avez audité :

<script
  src="https://cdn.exemple.com/lib.js"
  integrity="sha384-CONDENSAT_CALCULE_SUR_LE_FICHIER_AUDITE"
  crossorigin="anonymous"
></script>

Le condensat se calcule sur le fichier exact que vous avez relu, pas sur celui qui sera servi demain :

curl -s https://cdn.exemple.com/lib.js | openssl dgst -sha384 -binary | openssl base64 -A

Comment tester. Envoyez {"__proto__":{"pollue":true}} sur chaque endpoint qui accepte du JSON, puis vérifiez côté serveur que ({} as any).pollue reste undefined. Vérifiez que chaque <script> externe porte bien un attribut integrity. Et contrôlez que votre pipeline refuse un artefact non signé plutôt que de le déployer avec un avertissement.

A09 — Security Logging and Alerting Failures

Le renommage de « Monitoring » vers « Alerting » est le message principal de cette catégorie : « sans journalisation, les attaques et les compromissions ne peuvent pas être détectées, et sans alerte il est très difficile de réagir vite et efficacement pendant un incident » (A09:2025).

Les exemples cités par l'OWASP sur cette page donnent l'échelle du problème : un assureur santé pour enfants dont la compromission est restée non détectée sept ans, affectant plus de 3,5 millions d'enregistrements ; une compagnie aérienne indienne exposant dix ans de données de passagers ; et une compagnie aérienne européenne sanctionnée à hauteur de 20 millions au titre du RGPD après la collecte, par des attaquants, de plus de 400 000 enregistrements de paiement. Ces trois chiffres sont ceux de la page A09 de l'OWASP ; le point commun des trois affaires n'est pas la sophistication de l'attaque, mais le temps écoulé avant qu'elle ne soit remarquée.

Deux erreurs de mise en œuvre reviennent systématiquement. La première est l'injection dans les journaux (CWE-117) : une valeur utilisateur contenant des retours à la ligne permet de forger de fausses entrées et de brouiller une investigation. La seconde est l'inverse — journaliser des données qui n'ont rien à y faire (CWE-532).

// ❌ L'utilisateur saisit « bob@x.be\n2026-08-03 INFO admin login OK »
//    et fabrique une fausse ligne dans vos journaux.
console.log(`Echec de connexion pour ${email}`);

// ✅ Journal structuré : JSON.stringify encode les retours à la ligne.
type EvenementSecurite = {
  event: string;
  at: string;
  ip: string;
  userId?: string;
  outcome: 'success' | 'failure';
};

export function journaliser(evenement: EvenementSecurite) {
  // Interdit dans un journal : mot de passe, jeton de session, cookie,
  // numéro de carte complet, contenu d'un message privé.
  console.log(JSON.stringify(evenement));
}

journaliser({
  event: 'auth.login.failure',
  at: new Date().toISOString(),
  ip: ipClient(request),
  outcome: 'failure',
});

Les événements à journaliser sans exception : toutes les tentatives d'authentification (réussies et échouées), les échecs de contrôle d'accès, les échecs de validation d'entrée côté serveur, les changements de privilèges, et les transactions de valeur. Et surtout : définissez des seuils d'alerte. Cent échecs d'authentification sur un même compte en cinq minutes doit déclencher une notification, pas seulement une ligne de plus dans un fichier.

Comment tester. Simulez une attaque — vingt authentifications ratées, un accès à une ressource d'autrui, une injection SQL basique — puis posez-vous deux questions : l'événement est-il dans les journaux avec assez de contexte pour identifier l'auteur ? Et quelqu'un a-t-il été prévenu ? Si la réponse à la seconde est non, la journalisation ne sert qu'après coup.

A10 — Mishandling of Exceptional Conditions

Nouvelle catégorie 2025, 24 CWE. Elle traite de trois défaillances : ne pas prévenir les conditions exceptionnelles, ne pas les détecter quand elles surviennent, et y répondre de façon inadéquate ou pas du tout. L'OWASP justifie sa création par le fait que les classifications antérieures liées à la qualité du code étaient trop générales pour guider l'action (A10:2025).

Le CWE emblématique est CWE-636, « Not Failing Securely » : un contrôle de sécurité qui, en cas d'erreur, laisse passer.

// ❌ Échoue « ouvert » : si le service d'autorisation est indisponible,
// tout le monde obtient l'accès. Le catch a été écrit pour « éviter un
// plantage » et a supprimé le contrôle de sécurité.
async function peutAcceder(userId: string, ressource: string): Promise<boolean> {
  try {
    return await serviceAutorisation.verifier(userId, ressource);
  } catch {
    return true;
  }
}

// ✅ Échoue « fermé » : l'indisponibilité refuse l'accès ET alerte.
// Une panne devient une gêne, pas une brèche.
async function peutAcceder(userId: string, ressource: string): Promise<boolean> {
  try {
    return await serviceAutorisation.verifier(userId, ressource);
  } catch (erreur) {
    journaliser({
      event: 'authz.service.unavailable',
      at: new Date().toISOString(),
      ip: 'n/a',
      userId,
      outcome: 'failure',
    });
    return false;
  }
}

Second motif : la fuite d'information par le message d'erreur (CWE-209). Une trace d'exécution renvoyée au client donne à l'attaquant la version de votre ORM, le nom de vos tables et le chemin de vos fichiers.

// ✅ Le détail va dans les journaux serveur ; le client reçoit un message
// neutre et un identifiant de corrélation pour le support.
export async function POST(request: Request) {
  const correlationId = crypto.randomUUID();
  try {
    // ... traitement
  } catch (erreur) {
    console.error(JSON.stringify({ event: 'facture.creation.echec', correlationId, erreur: String(erreur) }));
    return Response.json(
      { error: 'Une erreur est survenue', reference: correlationId },
      { status: 500 },
    );
  }
}

L'OWASP recommande également d'utiliser des transactions avec retour arrière pour les opérations sensibles (une opération financière interrompue en cours de route peut permettre un débit en double), d'appliquer des quotas de ressources pour éviter l'épuisement, et de centraliser la gestion des erreurs pour qu'elle soit cohérente d'un bout à l'autre de l'application.

Comment tester. Coupez volontairement une dépendance — base de données, service d'authentification, API tierce — et observez ce que fait l'application. Si elle continue à servir des pages authentifiées, vous avez un fail open. Vérifiez ensuite qu'aucune réponse d'erreur en production ne contient de trace d'exécution :

curl -s -X POST https://exemple.be/api/factures -d '{"montant":"abc"}' \
  -H 'content-type: application/json' | grep -iE "at .*\(|node_modules|prisma\." \
  && echo "FUITE DE TRACE" || echo "OK"

La CSP : la contre-mesure transversale

La Content-Security-Policy mérite une section à part parce qu'elle réduit l'impact de plusieurs catégories à la fois (A02, A05, A08). La fiche OWASP est claire sur l'approche à retenir : la « pratique dominante actuelle est de créer une CSP stricte, bien plus simple à déployer et plus sûre car moins susceptible d'être contournée » qu'une liste blanche granulaire de domaines. La politique de référence donnée est :

Content-Security-Policy: script-src 'nonce-{ALEATOIRE}' 'strict-dynamic'; object-src 'none'; base-uri 'none';

En Next.js, le nonce doit être régénéré à chaque réponse, donc dans le middleware — jamais dans next.config.ts, dont les en-têtes sont statiques.

// middleware.ts
import { NextResponse, type NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  // Un nonce par réponse. Réutiliser le même annule toute la protection.
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64');

  const csp = [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'unsafe-inline'`,
    `img-src 'self' data: blob:`,
    `font-src 'self'`,
    `connect-src 'self'`,
    `object-src 'none'`,
    `base-uri 'none'`,
    `form-action 'self'`,
    `frame-ancestors 'none'`,
    `upgrade-insecure-requests`,
  ].join('; ');

  const enTetesRequete = new Headers(request.headers);
  enTetesRequete.set('x-nonce', nonce);

  const reponse = NextResponse.next({ request: { headers: enTetesRequete } });
  reponse.headers.set('Content-Security-Policy', csp);
  return reponse;
}

export const config = {
  // On exclut les fichiers statiques : inutile de leur poser une CSP.
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

Le composant lit ensuite le nonce et le transmet aux scripts qu'il injecte :

import { headers } from 'next/headers';

export default async function Page() {
  const nonce = (await headers()).get('x-nonce') ?? undefined;
  return <script nonce={nonce} src="/analytics.js" />;
}

Déployez d'abord en Content-Security-Policy-Report-Only pendant quelques jours, collectez les violations, puis basculez. frame-ancestors 'none' remplace avantageusement X-Frame-Options: DENY sur les navigateurs modernes, mais garder les deux ne coûte rien.

Outiller la vérification en continu

Un audit annuel ne dit rien de l'état du code demain. L'objectif est que chaque catégorie ci-dessus soit couverte par au moins un contrôle automatique qui fait échouer le build.

# Chaîne d'approvisionnement (A03)
npm ci --ignore-scripts
npm audit --omit=dev --audit-level=high
osv-scanner scan -r .          # « scan source » est la sous-commande par défaut

# Analyse statique — injection, secrets en dur, motifs dangereux (A04, A05, A08)
semgrep --config=p/owasp-top-ten --config=p/typescript --error

# Secrets présents dans l'historique Git (A04)
docker run --rm -v "$PWD:/repo" trufflesecurity/trufflehog:latest \
  git file:///repo --only-verified --fail

# Analyse dynamique — contre un environnement de préproduction (A01, A02)
docker run --rm -t ghcr.io/zaproxy/zaproxy:stable \
  zap-baseline.py -t https://preprod.exemple.be -r rapport.html

Deux ressources OWASP complètent utilement le Top 10, qui n'est qu'un document de sensibilisation et non un référentiel de test : le Web Security Testing Guide pour la méthodologie de test, et l'Application Security Verification Standard pour des exigences vérifiables par niveau.

Checklist par niveau de maturité

Niveau 1 — le plancher, non négociable

  • HTTPS partout avec HSTS ; aucun contenu mixte
  • Contrôle d'accès côté serveur sur chaque route et action serveur, filtre d'appartenance dans la requête
  • Requêtes paramétrées ou ORM ; aucun queryRawUnsafe, aucun exec avec entrée utilisateur
  • Mots de passe en Argon2id aux paramètres OWASP
  • npm ci en CI, lockfile commité, npm audit bloquant sur les vulnérabilités hautes
  • En-têtes de sécurité posés, X-XSS-Protection: 0, poweredByHeader: false
  • Aucun secret dans le dépôt ni dans un bundle NEXT_PUBLIC_
  • Erreurs génériques côté client, détail côté serveur uniquement

Niveau 2 — l'attendu d'une équipe professionnelle

  • MFA sur les comptes à privilèges, contrôle contre les listes de mots de passe compromis
  • Limitation de débit par IP et par compte sur authentification et endpoints coûteux
  • CSP stricte à nonce, déployée après une phase en report-only
  • Journaux structurés, sans donnée sensible, avec seuils d'alerte réellement branchés
  • Validation par schéma strict de toutes les entrées, y compris les webhooks
  • SBOM généré à chaque build, actions CI épinglées au SHA
  • Tests automatisés de la matrice d'autorisations
  • Analyse statique (Semgrep) et scan de dépendances (OSV) bloquants en CI

Niveau 3 — maturité

  • Modélisation de menaces documentée sur les fonctions critiques, revue à chaque évolution majeure
  • DAST en pipeline contre la préproduction
  • Séparation des responsabilités dans la CI/CD, artefacts signés, déploiement par paliers
  • Inventaire des usages cryptographiques en vue de la migration post-quantique
  • Plan de réponse à incident écrit et testé, avec les rôles nommés
  • Test d'intrusion externe périodique, dont les conclusions alimentent la modélisation de menaces

Sources

Toutes les affirmations chiffrées de cet article proviennent des pages suivantes :

Les exemples de code sont écrits pour Next.js en App Router avec TypeScript. Ils illustrent un principe : adaptez-les à votre contexte, et testez-les avant de les déployer.

Une remarque, une correction à signaler ou une question sur un point précis ? Le formulaire de contact du site est ouvert.

Besoin d'accompagnement ?

Smidjan vous aide à mettre en place ces solutions pour votre entreprise en Belgique.

Échange directJe réponds personnellement
JB

À propos de l'auteur

Jean-Baptiste Dhondt
Praticien en cybersécurité, cloud, réseau, infra & web

Praticien en cybersécurité (Smidjan) : sécurité cloud, réseau, infrastructure et web, et conformité NIS2 / CyberFundamentals (CCB), en Wallonie.

Disponible immédiatement pour un poste en sécurité, réseaux ou infrastructure — voir la page contact.

Disponible immédiatement