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 : 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 manifestation spécifique diffère selon le framework, mais les modèles sous-jacents sont cohérents. 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 manquer les subtils.
Nous avons conduit 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 front-end et des webhooks externes. Aucune des découvertes n'était catastrophique, mais plusieurs étaient le type de problème qui devient un incident sérieux si découvert par quelqu'un ayant de mauvaises intentions. 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 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, aucune validation des entrées ni limitation de débit ne compense.
L'audit a commencé par un inventaire complet des tables Supabase et leur statut RLS. Chaque table devrait avoir RLS activé. Toute table sans RLS activé est lisible dans le monde entier 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 RLS désactivé. C'était une table de recherche traitée comme des 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 RLS et ajouter une politique – mais l'échec de processus sous-jacent était notable : les nouvelles tables devraient avoir RLS activé par défaut, pas comme étape rétroactive.
Les politiques RLS sur les tables protégées ont ensuite été examinées pour leur exactitude. Une erreur courante est 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 – la vérification sur la table principale n'est pas transitive aux données jointes.
Une politique contenait une erreur logique qui avait survécu à la révision du code : elle utilisait USING là où elle aurait dû utiliser à la fois USING et WITH CHECK. La clause USING régit l'accès en lecture ; WITH CHECK régit l'accès en écriture. Une politique avec seulement USING permet à tout utilisateur authentifié d'écrire dans la table tant qu'il peut la 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 erreurs informatives 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 z.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 remédiation 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 conscience de l'exigence 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 de phishing 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 Content Security Policy é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'un 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 inline :
// 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 du CSP utilisait le mode Content-Security-Policy-Report-Only, qui signale les violations sans les bloquer. Cela a mis en évidence plusieurs scripts inline légitimes et origines tierces qui devaient figurer dans la liste d'autorisation avant de passer en mode d'application. Exécuter en mode rapport uniquement pendant une semaine avant d'appliquer est l'approche standard – l'application sans cette étape casse 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 vaut la peine d'ê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 à fenêtre glissante soutenu par Redis. 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 jamais dans une utilisation normale, et suffisamment restrictives pour que les attaques automatisées ne soient pas réalisables.
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 mitigation standard consiste à exécuter la comparaison de hachage de mot de passe de manière inconditionnelle, en utilisant un hachage 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;
}
L'authentification intégrée de Supabase gère cela correctement pour son propre point de terminaison de connexion. Le problème 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é
Parmi les découvertes de toutes les cinq phases, chaque élément a été adressé avant que l'audit ne soit considéré comme complet. 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 du 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 attaques temporelles 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 révision de politique RLS avant que la pull request puisse être fusionnée, les routes API ont un wrapper de validation Zod partagé 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 inline.
Le résultat le plus saillant 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 touchent 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 délibérément.
Ressources connexes
- Reprise après sinistre pour les services auto-hébergés – La couche de sauvegarde et de récupération qui complète le renforcement de la sécurité – ce qui se passe lorsque les contrôles échouent malgré les meilleures intentions.
- Créer une application de préparation aux examens avec des milliers de questions – Les politiques RLS Supabase dans une application en production, y compris le modèle d'accès multi-tenant.