Sécurité7 min de lecture

Sécuriser les mutations Next.js : Server Actions, Route Handlers et contrôles d'accès

Protocole complet en 10 étapes pour durcir les Server Actions et Route Handlers : authentification, autorisation d'ownership, validation Zod et protection CSRF.

Dans une application web, une mutation désigne toute opération d'écriture, de mise à jour ou de suppression modifiant l'état persistant du système (base de données, stockage d'objets, tiers de paiement ou envoi d'emails). Avec Next.js App Router, les mutations sont principalement gérées par deux mécanismes : les Server Actions (fonctions asynchrones marquées de la directive "use server") et les Route Handlers (app/api/.../route.ts).

Une incompréhension fréquente chez les équipes accélérées par l'IA réside dans la séparation client/serveur : une Server Action n'est pas un appel de fonction interne privé. Dès lors qu'une fonction porte la directive "use server", Next.js expose automatiquement un endpoint HTTP POST public accessible par n'importe quel client HTTP (curl, Postman, script d'attaque), même si le composant React qui l'appelle n'est jamais rendu à l'écran.

Le piège fondamental : le frontend n'est pas un contrôle d'accès

Dans de nombreuses applications générées rapidement, la sécurité est faussement déléguée à l'arbre des composants React :

// Faux sentiment de sécurité absolu : masquage purement graphique
export default function AdminSettingsPage({ user }: { user: User }) {
  if (!user.isAdmin) {
    return <p>Accès non autorisé</p>;
  }

  return (
    <form action={deleteUserOrganization}>
      <button type="submit">Purger l'organisation</button>
    </form>
  );
}

Si l'action serveur deleteUserOrganization ne revérifie pas formellement les privilèges de session en son propre sein, n'importe quel internaute non authentifié peut forger une requête HTTP POST directement vers l'identifiant technique de la Server Action et déclencher la suppression. Masquer un bouton dans l'interface utilisateur ne constitue en aucun cas une protection d'accès.

Les trois piliers de vérification : AuthN, AuthZ et Ownership

Chaque mutation sensible doit valider trois questions successives avant d'exécuter la moindre écriture :

1. Authentification (AuthN)   : Qui effectue la requête ? (Session valide, jeton non expiré)
              │
              ▼
2. Autorisation (AuthZ)       : Ce rôle a-t-il le droit d'effectuer cette action ? (Admin, Membre, Facturation)
              │
              ▼
3. Propriété (Ownership)      : L'utilisateur a-t-il le droit de modifier CETTE ressource précise ? (user_id = session.id)

Une erreur classique consiste à valider l'authentification (l'utilisateur est bien connecté) tout en omettant la vérification d'ownership : l'utilisateur 42 peut alors modifier ou supprimer le document appartenant à l'utilisateur 99 simplement en changeant le paramètre documentId dans sa requête (vulnérabilité BOLA / IDOR).


Guide d'implémentation pratique en 10 étapes

Étape 1 : Obtenir la session côté serveur de manière infalsifiable

N'acceptez jamais l'identifiant utilisateur (userId) depuis les arguments fournis par le client. Extrayez toujours l'identité depuis les cookies de session vérifiés cryptographiquement :

import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

export async function getAuthenticatedUser() {
  const cookieStore = await cookies();
  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() { return cookieStore.getAll(); },
        setAll(cookiesToSet) { cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options)); },
      },
    }
  );

  const { data: { user }, error } = await supabase.auth.getUser();
  if (error || !user) throw new Error('Session invalide ou expirée');
  return { user, supabase };
}

Étape 2 : Vérifier les rôles et permissions (AuthZ)

Si l'action requiert des prérogatives élevées, validez le rôle dans votre schéma métier plutôt que dans un champ de formulaire ou un cookie non signé.

Étape 3 : Valider strictement les entrées au runtime avec Zod

TypeScript ne protège votre code qu'au moment de la compilation. À l'exécution, les données reçues sur le réseau peuvent être de n'importe quel type ou format. Utilisez un schéma Zod avec safeParse :

import { z } from 'zod';

const UpdateProfileSchema = z.object({
  displayName: z.string().trim().min(2).max(50),
  bio: z.string().trim().max(500).optional(),
  website: z.string().url().max(200).optional(),
});

Étape 4 : Se prémunir contre l'assignation de masse (Mass Assignment)

Ne transmettez jamais l'intégralité du corps de requête (...formData) directement à votre ORM ou client SQL. Spécifiez explicitement chaque colonne autorisée à être mise à jour pour empêcher un utilisateur d'injecter des champs comme is_admin: true ou balance: 999999.

Étape 5 : Valider l'ownership de la ressource

Lors d'une mise à jour ou d'une suppression, incluez systématiquement le user_id de la session dans la clause de condition :

// Vérification stricte d'ownership
const { error, count } = await supabase
  .from('documents')
  .update({ title: parsedData.title })
  .match({ id: documentId, user_id: user.id });

if (error || count === 0) {
  throw new Error('Document introuvable ou droits insuffisants');
}

Étape 6 : Contrôler l'origine et la protection CSRF

Next.js applique une protection native contre les attaques CSRF (Cross-Site Request Forgery) sur les Server Actions en comparant l'en-tête Origin de la requête avec l'en-tête Host. Ne contournez pas cette protection en désactivant arbitrairement les vérifications d'origine dans next.config.js. Pour les Route Handlers classiques personnalisés, vérifiez manuellement la correspondance de l'origine si vous traitez des cookies d'authentification.

Étape 7 : Rendre les mutations réjouables sécurisées (Idempotence)

Pour les opérations créant des factures, prélevant des crédits ou déclenchant des transferts externes, générez un jeton d'idempotence unique côté client et persistez-le côté serveur avant d'exécuter l'action.

Étape 8 : Limiter le débit d'exécution (Rate Limiting)

Appliquez une borne temporelle sur les Server Actions sensibles (création de compte, changement de mot de passe, envoi d'invitations) à l'aide d'un magasin clé-valeur (Redis / Upstash) basé sur l'adresse IP et l'identifiant de session.

Étape 9 : Journaliser les échecs sans faire fuiter de données sensibles

En cas d'erreur de mutation, enregistrez un identifiant technique d'erreur dans vos journaux côté serveur tout en retournant un message générique sans détail d'infrastructure au client.

Étape 10 : Mettre en œuvre le motif de Server Action sécurisée réutilisable

Encapsulez vos Server Actions dans une fabrique (wrapper) assurant l'authentification et la validation systématiques :

// Motif de Server Action sécurisée
export async function updateUserSettingsAction(rawInput: unknown) {
  "use server";
  
  // 1. Authentification infalsifiable
  const { user, supabase } = await getAuthenticatedUser();
  
  // 2. Validation runtime stricte
  const validation = UpdateProfileSchema.safeParse(rawInput);
  if (!validation.success) {
    return { success: false, errors: validation.error.flatten().fieldErrors };
  }
  
  // 3. Mutation restreinte à l'ownership
  const { error } = await supabase
    .from('profiles')
    .update({
      display_name: validation.data.displayName,
      bio: validation.data.bio,
    })
    .eq('id', user.id);

  if (error) {
    console.error('[Action Error]', error);
    return { success: false, message: 'Erreur lors de la mise à jour' };
  }

  return { success: true };
}

Checklist de validation rapide pour une mutation

  • L'action s'exécute-t-elle strictement côté serveur ("use server" ou Route Handler) ?
  • La session utilisateur est-elle extraite d'une source cryptographique fiable (cookie vérifié côté serveur) ?
  • L'identifiant utilisateur (user_id) n'est-il jamais accepté en clair depuis les arguments du client ?
  • Les entrées réseau sont-elles toutes validées au runtime (schéma Zod avec safeParse) ?
  • La requête de base de données impose-t-elle l'appartenance de la ressource (user_id = session.user.id) ?
  • L'assignation de masse est-elle prévenue par une sélection explicite des champs mis à jour ?
  • Les erreurs retournées au client ne divulguent-elles aucune structure de table ni exception interne ?

Ce que GVO observe sur ce sujet

Lors de l'audit technique de votre code, le moteur GVO analyse spécifiquement la couverture de sécurité de vos mutations :

  • Ce que GVO peut observer :
    • Détection de toutes les fonctions exportées marquées de la directive "use server" et des méthodes POST, PUT, PATCH, DELETE des Route Handlers ;
    • Présence ou absence d'un appel d'authentification observable (getUser(), getSession(), middleware) dans le corps ou la chaîne d'exécution de l'action ;
    • Présence d'un schéma de validation observable (Zod, Valibot, allowlist) sur les arguments d'entrée ;
    • Détection d'effets de bord destructifs ou financiers sans vérification d'identité locale observable.
  • Ce que GVO ne peut pas garantir :
    • Une analyse statique ne vérifie pas la cohérence logique de vos règles métier complexes (par exemple les droits hiérarchiques entre sous-organisations) ;
    • GVO n'exécute pas de requêtes d'intrusion dynamique contre votre environnement de production déployé.

Sources et références

Vérifier ce point sur votre projet

GVO confronte le code et les configurations de votre application aux règles de sécurité réelles.