tva
← Analyses

Un audit de sécurité en plusieurs phases : renforcer une application Next.js

Les audits de sécurité sur les applications web ont tendance à trouver les mêmes catégories de problèmes quelle que soit la pile technologique : lacunes d’autorisation, validation des entrées manquante, en-têtes de sécurité HTTP inadéquats et vulnérabilités temporelles dans les flux d’authentification. La forme précise de ces problèmes diffère selon le framework, mais les mécanismes sous-jacents restent les mêmes. Ce qui varie, c’est la rigueur avec laquelle l’audit est structuré – une revue non structurée est susceptible de détecter les problèmes évidents et de passer à côté des plus subtils.

Nous avons mené un audit en plusieurs phases sur une application Next.js en production utilisant Supabase comme backend. L’application gérait l’authentification des utilisateurs, stockait des enregistrements liés aux utilisateurs et exposait plusieurs routes API utilisées à la fois par le frontend et des webhooks externes. Aucun constat n’était catastrophique, mais plusieurs concernaient des problèmes susceptibles de devenir des incidents graves s’ils étaient découverts par une personne malveillante. Voici ce que l’audit a couvert, ce qu’il a trouvé, et ce qui a changé en conséquence.

Phase un : authentification et autorisation

La première phase s’est concentrée sur la couche d’authentification et les politiques de sécurité au niveau des lignes (RLS) régissant l’accès aux données. Ce sont les contrôles les plus critiques dans toute application Supabase : si l’authentification est défectueuse ou si la RLS est mal configurée, ni la validation des entrées ni la limitation de débit ne peuvent compenser le problème.

L’audit a commencé par un inventaire complet des tables Supabase et de leur état RLS. La RLS devrait être activée sur chaque table d’un schéma exposé. Avec les droits accordés par défaut par Supabase, toute table d’un schéma exposé tel que public sans RLS activée est accessible en lecture et en écriture par quiconque possède la clé anon – qui est publique par conception et intégrée dans le bundle côté client. Dans cette application, une table avait la RLS désactivée. C’était une table de correspondance considérée comme une source de données de référence, et au moment de sa création, elle ne contenait que des valeurs statiques. Au fil du temps, certaines lignes avaient été ajoutées qui comprenaient des métadonnées liées aux utilisateurs qui auraient dû être protégées. La correction était simple – activer la RLS et ajouter une politique – mais la défaillance du processus sous-jacent était notable : la RLS devrait être activée par défaut sur les nouvelles tables, plutôt qu’après coup.

Les politiques RLS sur les tables protégées ont ensuite été examinées pour leur exactitude. Une erreur courante consiste à écrire une politique qui vérifie correctement l’identifiant utilisateur mais le fait sur la mauvaise colonne. Par exemple, une politique sur une table documents qui vérifie auth.uid() = created_by restreint correctement l’accès au créateur, mais si l’application prend également en charge les documents partagés, une ligne de collaborateur dans une table de jointure document_collaborators a besoin de sa propre politique – le contrôle de la table principale ne s’étend pas automatiquement aux données jointes.

Une politique contenait une erreur logique qui avait survécu à la revue de code : elle utilisait USING là où elle aurait dû utiliser à la fois USING et WITH CHECK. La clause USING détermine quelles lignes existantes une commande peut voir, mettre à jour ou supprimer ; WITH CHECK détermine quelles lignes nouvelles ou modifiées peuvent être écrites. Lorsqu’une politique couvrant les écritures (FOR ALL ou FOR UPDATE) omet WITH CHECK, PostgreSQL réutilise l’expression USING pour les écritures. Une clause USING conçue comme une règle de lecture permettait donc à tout utilisateur authentifié d’écrire des lignes qu’il pouvait lire – ce qui n’était pas le comportement prévu. C’est une subtilité de la RLS PostgreSQL facile à négliger.

Phase deux : validation des entrées

La deuxième phase a examiné chaque point d’entrée où les données fournies par l’utilisateur entrent dans le système : les gestionnaires de routes API dans app/api/, les actions serveur et les soumissions de formulaires. L’objectif était de confirmer que toutes les entrées sont validées avant d’être utilisées, et que les échecs de validation produisent des messages d’erreur explicites plutôt que des exceptions non gérées.

L’application utilisait Zod pour la validation de manière partielle, mais incohérente. Certaines routes avaient des schémas complets avec schema.parse() au début du gestionnaire. D’autres lisaient les champs req.body directement sans validation, en s’appuyant sur des assertions de type TypeScript qui ne fournissent aucune sécurité à l’exécution :

// Non sécurisé : assertion de type TypeScript, aucune validation à l'exécution
const { userId, documentId } = req.body as { userId: string; documentId: string };

// Sécurisé : analyse Zod, lève une exception sur une entrée invalide
const schema = z.object({
  userId: z.string().uuid(),
  documentId: z.string().uuid(),
});
const { userId, documentId } = schema.parse(req.body);

La correction a consisté à ajouter des schémas Zod à chaque route API non validée et à établir un middleware de validation partagé que toutes les routes traversent. Le modèle de middleware est important : il garantit que la couche de validation ne peut pas être contournée par un chemin de code ajouté sans tenir compte de cette obligation de validation.

Une route acceptait un paramètre redirectUrl utilisé dans un appel res.redirect(). C’est une vulnérabilité de redirection ouverte – un attaquant peut créer un lien vers l’application qui redirige les utilisateurs vers un site d’hameçonnage après une interaction d’apparence légitime. La correction consiste à valider la cible de redirection par rapport à une liste d’autorisation de chemins acceptables :

const ALLOWED_REDIRECT_PATHS = ['/dashboard', '/settings', '/profile'];

const redirectPath = schema.parse(req.query).redirectUrl;
if (!ALLOWED_REDIRECT_PATHS.includes(redirectPath)) {
  return res.redirect('/dashboard');
}
return res.redirect(redirectPath);

Phase trois : en-têtes de sécurité HTTP et CSP

Les en-têtes de politique de sécurité du contenu (CSP) étaient complètement absents de l’application. Sans CSP, une injection XSS réussie – que ce soit via du contenu généré par les utilisateurs ou un script tiers compromis – peut exécuter du JavaScript arbitraire dans le contexte de l’application, avec accès aux jetons d’authentification et aux données utilisateur.

L’ajout d’une CSP à une application Next.js nécessite de la prudence car Next.js intègre certains scripts par défaut. L’approche recommandée utilise des nonces – une valeur aléatoire cryptographiquement sécurisée générée par requête qui est ajoutée à l’en-tête CSP et appliquée à chaque script intégré. Next.js applique le nonce à ses propres scripts en lisant la politique dans les en-têtes de requête. Le middleware de production doit donc aussi transmettre la CSP (et généralement un en-tête x-nonce) via NextResponse.next({ request: { headers } }), comme le montre le guide CSP de Next.js ; l’extrait ci-dessous définit uniquement les en-têtes de réponse. Il importe également le module crypto de Node, qui n’est pas disponible dans l’environnement d’exécution Edge ; dans cet environnement, il faut utiliser l’API globale Web Crypto (par exemple crypto.randomUUID()) :

// middleware.ts
import { NextResponse } from 'next/server';
import crypto from 'crypto';

export function middleware(request: Request) {
  const nonce = crypto.randomBytes(16).toString('base64');
  const cspHeader = [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'unsafe-inline'`,
    `img-src 'self' data: https:`,
    `connect-src 'self' https://api.example.com`,
    `frame-ancestors 'none'`,
  ].join('; ');

  const response = NextResponse.next();
  response.headers.set('Content-Security-Policy', cspHeader);
  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('X-Content-Type-Options', 'nosniff');
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
  return response;
}

Le déploiement initial de la CSP utilisait le mode Content-Security-Policy-Report-Only, qui signale les violations sans les bloquer. Cela a mis en évidence plusieurs scripts intégrés légitimes et origines tierces qui devaient figurer dans la liste d’autorisation avant d’activer le blocage. Utiliser le mode de signalement seul pendant une semaine avant d’activer le blocage est une approche courante – activer le blocage sans cette étape perturbe généralement des fonctionnalités qui n’étaient pas visibles lors des tests.

L’audit a également constaté que l’en-tête Strict-Transport-Security était défini par l’équilibreur de charge mais pas par l’application. C’est acceptable si l’équilibreur de charge est le seul point d’entrée, mais doit être confirmé explicitement. La défense en profondeur signifie que l’application devrait définir ses propres en-têtes de sécurité même lorsque l’infrastructure les fournit.

Phase quatre : limitation de débit

Les points de terminaison d’authentification – connexion, inscription, réinitialisation du mot de passe – n’avaient aucune limitation de débit. Sans limitation de débit, ces points de terminaison acceptent des requêtes illimitées, ce qui permet des attaques par force brute sur les mots de passe et l’énumération des adresses e-mail valides via la différence de comportement des réponses.

La limitation de débit sur les routes API Next.js peut être implémentée avec un compteur adossé à Redis ; l’exemple ci-dessous utilise une fenêtre fixe, plus simple qu’une fenêtre glissante. Comme il exécute INCR et EXPIRE dans des appels distincts, une défaillance entre les deux peut laisser une clé sans expiration. Le code de production devrait donc exécuter les deux opérations de manière atomique (par exemple dans une transaction ou un script Lua). La clé est généralement l’adresse IP ou, pour les points de terminaison authentifiés, l’identifiant utilisateur. Pour les points de terminaison d’authentification, la limitation basée sur l’IP est appropriée car l’utilisateur n’est pas encore authentifié :

// lib/rateLimit.ts
import { Redis } from '@upstash/redis';

const redis = new Redis({ url: process.env.UPSTASH_URL!, token: process.env.UPSTASH_TOKEN! });

export async function checkRateLimit(identifier: string, limit: number, windowSeconds: number) {
  const key = `rate_limit:${identifier}`;
  const count = await redis.incr(key);
  if (count === 1) {
    await redis.expire(key, windowSeconds);
  }
  return { allowed: count <= limit, remaining: Math.max(0, limit - count) };
}

Les limites appliquées étaient conservatrices : les tentatives de connexion sont limitées par IP par fenêtre de quinze minutes, les demandes de réinitialisation de mot de passe sont limitées par adresse e-mail par heure. Ces limites sont suffisamment permissives pour que les utilisateurs légitimes ne les rencontrent que rarement dans une utilisation normale, et suffisamment restrictives pour rendre les attaques automatisées par force brute impraticables.

Phase cinq : protection contre les attaques temporelles

Les attaques temporelles sur les points de terminaison d’authentification permettent à un attaquant de déduire si une adresse e-mail est enregistrée en mesurant la différence de temps de réponse entre « e-mail non trouvé » et « e-mail trouvé, mot de passe incorrect. » Une recherche qui trouve un utilisateur prend légèrement plus de temps qu’une qui n’en trouve pas, car la comparaison de mot de passe (qui utilise bcrypt ou Argon2) ne s’exécute que lorsque l’utilisateur est trouvé. Cette différence – généralement de l’ordre de 50–200 ms – est mesurable avec suffisamment de requêtes.

La mesure de protection standard consiste à exécuter la comparaison de l’empreinte du mot de passe de manière inconditionnelle, en utilisant une empreinte factice lorsque l’utilisateur n’existe pas :

const DUMMY_HASH = '$2b$12$dummyhashvaluethatisvalidbutnevermatchesanypassword';

export async function verifyLogin(email: string, password: string) {
  const user = await db.user.findUnique({ where: { email } });

  // Toujours exécuter la comparaison bcrypt pour normaliser le temps de réponse
  const hashToCompare = user?.passwordHash ?? DUMMY_HASH;
  const isValid = await bcrypt.compare(password, hashToCompare);

  if (!user || !isValid) {
    return null;  // Même chemin de code pour les deux cas d'échec
  }
  return user;
}

La valeur DUMMY_HASH ci-dessus est un espace réservé : ce n’est pas un hachage bcrypt valide de 60 caractères, et des bibliothèques comme bcryptjs renvoient immédiatement false pour les hachages mal formés, ce qui annulerait la normalisation temporelle. En pratique, il faut générer un véritable hachage une seule fois au démarrage (par exemple avec bcrypt.hash() sur une chaîne aléatoire), en utilisant le même facteur de coût que pour les hachages stockés. Le problème ne concernait pas le point de terminaison de connexion intégré de Supabase ; il est apparu dans une route d’authentification personnalisée que l’application avait ajoutée pour gérer une intégration héritée. Toute logique d’authentification personnalisée qui n’utilise pas les fonctions d’authentification de Supabase doit implémenter la normalisation temporelle explicitement.

Ce qui a changé et ce qui n’a pas changé

Tous les constats relevés au cours des cinq phases ont été traités avant de considérer l’audit comme terminé. La priorisation était claire : la mauvaise configuration de la RLS et la redirection ouverte ont été traitées comme critiques et corrigées immédiatement. Le déploiement de la CSP et la limitation de débit ont été traités comme prioritaires et déployés dans le même sprint. La normalisation des temps de réponse sur la route héritée était de priorité moyenne et a été livrée dans le sprint suivant.

L’audit a également produit un ensemble de changements de processus : les nouvelles tables nécessitent désormais une revue de la politique RLS avant que la pull request puisse être fusionnée, les routes API ont une fonction partagée de validation Zod qui fait partie du modèle de route, et l’en-tête CSP est maintenant généré à partir d’une configuration centralisée plutôt que de chaînes définies directement dans le code.

Le résultat le plus marquant n’était pas un correctif individuel mais l’inventaire. Comprendre exactement quelles données contient chaque table, quelle politique RLS la régit et quelles routes API y accèdent est la base pour prendre des décisions de sécurité correctes à mesure que l’application évolue. Sans cet inventaire, la sécurité est réactive – vous corrigez ce que vous trouvez. Avec lui, la sécurité devient une propriété sur laquelle vous pouvez raisonner de manière structurée.

Analyses connexes

Pour aller plus loin