Aller au contenu
CodeAuditAgent
Tous les articles

Checklist de sécurité pour l’App Router de Next.js

Checklist sécurité pour l’App Router Next.js : server actions, route handlers, middleware, variables d’env, CSRF, en-têtes, webhooks et cache utilisateur.

· 8 min de lecture · Lina Source LLC

L’App Router a ramené beaucoup de code du navigateur vers le serveur. C’est globalement un gain de sécurité : les requêtes, les secrets et la logique métier s’exécutent désormais là où les utilisateurs ne peuvent pas les lire. Il a aussi brouillé la frontière entre ce qui est un endpoint public et ce qui ressemble simplement à un appel de fonction. La plupart des bugs graves dans les applications Next.js viennent de ce flou.

Cette checklist couvre les problèmes que nous rencontrons le plus souvent dans les bases de code App Router, dans l’ordre où il vaut la peine de les vérifier. Rien d’exotique ; chaque point est un endroit où la commodité du framework masque une frontière de confiance.

1. Les server actions sont des endpoints publics

Une fonction marquée 'use server' est compilée en un endpoint HTTP. Quiconque peut charger votre site peut trouver l’identifiant de l’action et l’appeler avec des arguments arbitraires, que votre interface affiche ou non le bouton qui l’utilise. Masquer un formulaire aux non-administrateurs ne protège pas l’action qui se trouve derrière.

Chaque action a besoin des trois mêmes étapes que n’importe quel handler d’API : authentifier l’appelant, valider l’entrée et autoriser l’enregistrement précis qui est touché. Placez ces vérifications dans le corps de l’action elle-même. Une vérification dans le composant de page qui affiche le formulaire s’exécute au chargement de la page, pas à l’appel de l’action : elle ne protège donc rien.

'use server';

import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

const Input = z.object({
  projectId: z.string().uuid(),
  name: z.string().trim().min(1).max(100),
});

export async function renameProject(formData: FormData) {
  const session = await auth();
  if (!session?.user) throw new Error('Unauthorized');

  const { projectId, name } = Input.parse({
    projectId: formData.get('projectId'),
    name: formData.get('name'),
  });

  // La vérification de propriété fait partie de l'écriture, pas d'une lecture séparée
  const result = await db.project.updateMany({
    where: { id: projectId, ownerId: session.user.id },
    data: { name },
  });
  if (result.count === 0) throw new Error('Not found');

  revalidatePath('/projects');
}

Méfiez-vous des fichiers utilitaires qui exportent de nombreuses actions. Toute fonction exportée dans un module 'use server' est appelable, y compris celle que quelqu’un a ajoutée pour un script interne et a oubliée.

2. Les route handlers exigent les mêmes vérifications

Les route handlers dans app/api ressemblent plus clairement à des endpoints, mais ils partagent le même mode de défaillance : un segment dynamique sert à charger un enregistrement sans vérifier à qui il appartient. Dans les versions récentes de Next.js, params est une Promise ; attendez-la, validez-la, et restreignez la requête à l’appelant.

// app/api/documents/[id]/route.ts
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

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

  const { id } = await params;
  const doc = await db.document.findFirst({
    where: { id, ownerId: session.user.id },
  });
  if (!doc) return Response.json({ error: 'not_found' }, { status: 404 });

  return Response.json(doc, {
    headers: { 'Cache-Control': 'private, no-store' },
  });
}

Renvoyez 404 plutôt que 403 pour les enregistrements que l’appelant ne possède pas, afin que l’endpoint ne confirme pas quels identifiants existent. Ne faites jamais confiance à params, searchParams, aux en-têtes ou aux cookies pour des décisions d’autorisation ; ce sont tous des entrées contrôlées par l’attaquant.

3. Le middleware n’est pas à lui seul une frontière d’authentification

Le middleware (renommé proxy dans Next.js 16) est utile pour rediriger les utilisateurs déconnectés et définir des en-têtes. Il ne devrait pas être le seul endroit où l’autorisation se joue. Les matchers sont faciles à mal écrire, de nouvelles routes sont ajoutées en dehors de leur portée, et les server actions envoient leur requête au chemin de la page, qui peut ne pas correspondre au motif que vous aviez en tête. En 2025, la CVE-2025-29927 a montré qu’un en-tête interne forgé pouvait amener certaines versions de Next.js à sauter entièrement le middleware.

Considérez le middleware comme une couche de confort. La vraie vérification appartient au plus près des données : dans chaque action et chaque handler, ou mieux, dans une couche d’accès aux données par laquelle passe toute lecture côté serveur. Une couche d’accès aux données est un module unique, réservé au serveur, qui expose des fonctions comme getProjectForUser(projectId) et effectue en interne la vérification de session et le filtre de propriété. Les pages, les actions et les route handlers l’appellent au lieu du client de base de données directement, si bien qu’une nouvelle route ne peut pas oublier la vérification : il n’existe aucun chemin non vérifié à oublier.

4. Gardez le code serveur sur le serveur

Toute variable d’environnement préfixée par NEXT_PUBLIC_ est intégrée au bundle JavaScript à la compilation et visible par chaque visiteur. C’est correct pour une clé Stripe publiable ou un identifiant d’analytics, et c’est une fuite pour tout le reste. Cherchez NEXT_PUBLIC_ dans la base de code et vérifiez que chaque valeur pourrait figurer sans risque sur un panneau publicitaire. L’erreur inverse arrive aussi : une variable sans le préfixe est lue dans un composant client, revient undefined dans le navigateur, et quelqu’un la renomme en NEXT_PUBLIC_ pour faire disparaître l’erreur. Si une valeur est nécessaire dans le navigateur, demandez-vous d’abord si le navigateur devrait en disposer.

// lib/dal.ts
import 'server-only';
import { cache } from 'react';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

export const getCurrentUser = cache(async () => {
  const session = await auth();
  if (!session?.user) return null;
  // Ne renvoyer que les champs dont l'interface a besoin, jamais la ligne entière
  return db.user.findUnique({
    where: { id: session.user.id },
    select: { id: true, name: true, plan: true },
  });
});

Le paquet server-only fait échouer la compilation si un composant client importe le module, ce qui empêche les clients de base de données et les helpers qui lisent des secrets de finir dans le navigateur. Surveillez aussi ce que les composants serveur passent en props aux composants client : tout ce qui traverse cette frontière est sérialisé dans la page, donc passer une ligne utilisateur complète expédie au navigateur son hash de mot de passe et ses drapeaux internes.

5. CSRF : sachez ce que le framework couvre

Les server actions n’acceptent que POST, et Next.js compare l’en-tête Origin à l’hôte avant de les exécuter. Si vous déployez derrière un proxy ou sur plusieurs domaines, configurez serverActions.allowedOrigins de façon délibérée plutôt que de l’élargir jusqu’à ce que les erreurs disparaissent.

Les route handlers ne bénéficient d’aucune protection de ce type. Si un handler POST, PUT ou DELETE s’authentifie par cookies, un formulaire hébergé sur un autre site peut toujours lui soumettre des données. Vérifier le content type ne suffit pas à soi seul : un formulaire HTML peut envoyer des corps urlencoded, multipart et text/plain sans preflight, et un handler qui analyse le corps de manière permissive les acceptera. Définissez les cookies de session avec SameSite=Lax ou Strict, n’effectuez jamais de changement d’état sur un GET, et vérifiez l’en-tête Origin sur les mutations authentifiées par cookie. Les handlers qui n’acceptent qu’un bearer token dans l’en-tête Authorization ne sont pas exposés au CSRF classique.

6. En-têtes de sécurité et CSP

Next.js envoie très peu d’en-têtes de sécurité par défaut. Ajoutez-les dans next.config via la fonction headers(), ou dans le middleware lorsque vous avez besoin d’un nonce par requête pour une Content-Security-Policy stricte.

  • Content-Security-Policy, idéalement basée sur un nonce, avec object-src 'none' et base-uri 'self'.
  • Strict-Transport-Security avec un max-age long, une fois HTTPS solide partout.
  • X-Content-Type-Options: nosniff.
  • Referrer-Policy: strict-origin-when-cross-origin.
  • frame-ancestors dans la CSP, ou X-Frame-Options, pour empêcher le clickjacking.
  • poweredByHeader: false dans next.config, pour supprimer l’en-tête X-Powered-By.

7. Limitation de débit

Il n’existe pas de limiteur de débit intégré. La connexion, l’inscription, la réinitialisation de mot de passe, la vérification d’OTP et tout ce qui coûte de l’argent par appel (e-mail, SMS, requêtes IA) ont besoin de limites par utilisateur et par IP. Les compteurs en mémoire ne fonctionnent pas sur des déploiements serverless ou multi-instances ; utilisez un stockage partagé comme Redis ou votre base de données. Appliquez les limites dans l’action ou le handler, là où l’utilisateur authentifié est connu, et pas seulement dans le middleware. Indexez les limites sur quelque chose qu’un attaquant ne peut pas faire tourner à bas coût : le compte pour les routes authentifiées, et l’adresse e-mail ou le numéro de téléphone visé pour les flux de réinitialisation et d’OTP, en plus de l’IP. Renvoyez 429 avec un en-tête Retry-After pour que les clients légitimes lèvent le pied correctement.

8. Vérifiez les webhooks contre le corps brut

Les signatures de webhook sont calculées sur les octets exacts envoyés par le fournisseur. Analyser le corps en JSON puis le re-sérialiser modifie ces octets et casse la vérification, ce qui pousse certains à l’abandonner. Dans un route handler, lisez le corps avec req.text() et vérifiez avant toute autre chose.

// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get('stripe-signature');
  if (!signature) return new Response('Missing signature', { status: 400 });

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch {
    return new Response('Invalid signature', { status: 400 });
  }

  // Les handlers doivent être idempotents : les fournisseurs réessaient et peuvent livrer deux fois
  await handleStripeEvent(event);
  return new Response('ok');
}

Stockez les identifiants d’événements traités avec une contrainte d’unicité, afin qu’une livraison réessayée ou rejouée n’accorde pas deux fois un abonnement, et gérez les événements qui arrivent dans le désordre, car les fournisseurs ne garantissent pas l’ordre de livraison.

9. Ne mettez pas en cache globalement des données par utilisateur

L’App Router met en cache de manière agressive, et les valeurs par défaut ont changé d’une version à l’autre. Une fonction enveloppée dans unstable_cache ou 'use cache' qui renvoie les données de « l’utilisateur courant » sans inclure l’identifiant de l’utilisateur dans sa clé de cache servira les données d’un utilisateur au suivant. Il en va de même pour les pages rendues statiquement alors qu’elles auraient dû être dynamiques, et pour la mise en cache des réponses d’API par un CDN. Testez-le directement : connectez-vous avec deux utilisateurs différents dans deux navigateurs et chargez les mêmes pages. Tout ce qui montre les données du premier utilisateur au second est un bug de cache, et généralement un bug sérieux.

  • Passez explicitement l’identifiant de l’utilisateur ou du tenant aux fonctions mises en cache afin qu’il fasse partie de la clé.
  • Ne lisez pas les cookies ni les en-têtes à l’intérieur des fonctions mises en cache ; lisez-les à l’extérieur et passez les valeurs.
  • Envoyez Cache-Control: private, no-store sur les réponses qui contiennent des données par utilisateur.
  • Vérifiez la sortie de build : les routes que vous attendez dynamiques ne doivent pas y figurer comme statiques.

Dérouler la checklist

Parcourez la liste par route, pas par fichier : pour chaque action et chaque handler, qui peut l’appeler, quelles entrées elle croit sur parole, ce qu’elle met en cache et ce qu’elle renvoie. CodeAuditAgent peut faire un premier passage sur un dépôt GitHub public ou un extrait collé, en signalant chaque résultat avec sa gravité, sa CWE, la ligne citée et un correctif suggéré ; les questions de conception, comme celle de savoir quelles routes devraient être publiques, relèvent toujours du jugement de votre équipe.