Skip to Content
SécuritéPartie 10 — Sécurité Next.js / React / Node10.2 — Server Actions & route handlers

Chapitre 10.2 — Server Actions & route handlers

⏱️ TL;DR — Une Server Action ressemble à un simple appel de fonction depuis un composant — mais c’en est un piège trompeur : à la compilation, elle devient un endpoint HTTP que n’importe qui peut appeler directement (pas seulement via ton UI). Ses arguments viennent donc du client et sont hostiles. Idem pour les route handlers (app/api/.../route.ts). Conséquence : chaque Server Action / route handler doit, comme toute API, (1) vérifier l’authentification et l’autorisation (session, appartenance de l’objet — anti-IDOR), et (2) valider les entrées (un schéma Zod), sans jamais supposer qu’elles sont « propres » parce qu’elles viennent de « ton » composant.

🎯 Objectifs

  • Comprendre qu’une Server Action est un endpoint réseau exposé, pas un appel local.
  • Vérifier auth + autorisation dans chaque Server Action / route handler.
  • Valider les entrées avec un schéma (Zod) à la frontière serveur.
  • Éviter les pièges : confiance dans les arguments, IDOR, mass assignment.

Une Server Action est un endpoint

Les Server Actions permettent d’appeler du code serveur « comme » une fonction depuis un composant. C’est ergonomique — et trompeur. À la compilation, Next.js transforme chaque Server Action en un endpoint HTTP avec un identifiant ; l’« appel de fonction » côté client est en réalité une requête réseau vers cet endpoint. Conséquence cruciale : n’importe qui peut appeler cet endpoint directement (avec curl, un script), en fournissant les arguments qu’il veut — pas seulement via ton interface, pas seulement avec les valeurs prévues.

'use server' // Ceci N'EST PAS un simple appel local : c'est un ENDPOINT expose. export async function supprimerCours(coursId: string) { // ❌ si on fait confiance a coursId sans verifier -> n'importe qui supprime n'importe quel cours await db.cours.delete({ where: { id: coursId } }) }

Le modèle mental correct : une Server Action (et un route handler) est exactement comme une route d’API publique. Ses arguments franchissent la frontière de confiance (Partie 1) — ils sont hostiles. Le fait qu’ils soient « passés par ton composant » ne garantit rien : l’attaquant contourne le composant.

⚠️ Piège — Le piège n°1 des Server Actions : les traiter comme des fonctions internes de confiance (« c’est mon composant qui l’appelle avec le bon id »). Faux : l’endpoint est exposé et appelable directement avec n’importe quels arguments. Une Server Action supprimerCours(id) sans contrôle d’auth et d’autorisation par objet est un IDOR trivial : l’attaquant appelle l’endpoint avec l’id du cours d’un autre. Chaque Server Action doit vérifier la session, l’autorisation, et valider ses entrées — comme une API.

Les deux contrôles obligatoires

Pour chaque Server Action et route handler, deux vérifications, côté serveur, non négociables :

1. Authentification + autorisation

'use server' import { auth } from '@/lib/auth' import { z } from 'zod' const schema = z.object({ coursId: z.string().uuid() }) export async function supprimerCours(input: unknown) { // (1) AUTHENTIFICATION : y a-t-il une session ? const session = await auth() if (!session) throw new Error('Non authentifie') // 401 // (2) VALIDATION des entrees (voir plus bas) const { coursId } = schema.parse(input) // (3) AUTORISATION PAR OBJET : ce cours appartient-il a l'utilisateur ? (anti-IDOR) const cours = await db.cours.findFirst({ where: { id: coursId, formateurId: session.user.id }, // porte par le proprietaire }) if (!cours) throw new Error('Interdit') // 403/404 : pas SON cours await db.cours.delete({ where: { id: cours.id } }) }

C’est le contrôle d’accès de la Partie 5 appliqué : vérifier la session (authentification), puis l’appartenance de l’objet (autorisation par objet, requête portée par le propriétaire pour fermer l’IDOR). Ne jamais supposer que « l’utilisateur ne peut atteindre que ses cours parce que l’UI ne montre que les siens ».

2. Validation des entrées (Zod)

Les arguments d’une Server Action ou le corps d’un route handler sont non typés à l’exécution (le typage TypeScript disparaît à la compilation — il ne valide rien au runtime). Un attaquant envoie ce qu’il veut : un objet, un tableau, des champs en trop, des types inattendus. On valide donc à la frontière avec un schéma runtime, typiquement Zod :

// route handler : app/api/cours/route.ts import { z } from 'zod' const CreerCours = z.object({ titre: z.string().min(1).max(200), description: z.string().max(5000), // pas de champ "formateurId" ni "role" accepte du client (anti mass assignment) }) export async function POST(req: Request) { const session = await auth() if (!session) return new Response('Non authentifie', { status: 401 }) const data = CreerCours.parse(await req.json()) // rejette tout ce qui n'est pas conforme await db.cours.create({ data: { ...data, formateurId: session.user.id }, // proprietaire = session, PAS le client }) return Response.json({ ok: true }) }

Points clés :

  • Valider le type ET la forme : un schéma qui rejette les objets non conformes bloque aussi la NoSQL injection par opérateur (Partie 6) et les entrées malformées.
  • Anti mass assignment (Partie 5) : n’accepte du client que les champs légitimes ; les champs de droit/propriété (formateurId, role, isAdmin) sont fixés côté serveur depuis la session, jamais lus du corps.

💡 Réflexe — Devant toute Server Action / route handler, applique le trio systématiquement : auth (session valide ?), validation (schéma Zod sur les entrées), autorisation par objet (l’objet visé appartient-il à l’utilisateur ?). Le typage TypeScript ne valide pas au runtime — ne t’y fie jamais pour la sécurité. Si une Server Action n’a pas ces trois contrôles, considère-la comme vulnérable.

Le TypeScript ne sécurise pas

Piège conceptuel fréquent : croire que function action(id: string) garantit que id est une chaîne au runtime. Non : les types TypeScript sont effacés à la compilation ; à l’exécution, id peut être n’importe quoi (un objet, undefined, un tableau) si l’appelant (l’attaquant) l’envoie ainsi. Le typage aide au développement, pas à la sécurité runtime. Seule une validation à l’exécution (Zod, une vérification explicite) protège.

🎯 Côté attaquant — L’attaquant sait que les Server Actions sont des endpoints : il inspecte le réseau pour trouver leurs identifiants, puis les appelle directement avec des arguments arbitraires — id d’objets d’autres utilisateurs (IDOR), types inattendus (objets, opérateurs NoSQL), champs en trop (role: 'admin' → mass assignment). Il ne passe pas par ton UI. Toute Server Action qui « fait confiance » à ses arguments parce qu’ils « viennent du composant » tombe immédiatement. Idem pour les route handlers, qu’il énumère et fuzze.

🧭 Sur FormaCampus — Chaque Server Action et route handler de FormaCampus applique le trio : await auth() (session), un schéma Zod sur toutes les entrées, et une requête portée par le propriétaire/tenant pour l’autorisation par objet (le formateurId/tenantId vient toujours de la session, jamais du corps — anti mass assignment et anti-IDOR). Le typage TypeScript n’est jamais considéré comme une validation runtime. Un helper requireSession() centralise l’auth, et les schémas Zod sont réutilisés. Un test vérifie qu’appeler une action avec l’id d’un objet d’autrui renvoie une erreur d’autorisation. Ainsi, appeler directement l’endpoint (hors UI) ne donne aucun avantage à un attaquant.

✏️ Exercices

Exercice 1 — Sécurise la Server Action. Corrige cette action pour qu’elle ne soit pas un IDOR ni vulnérable aux entrées.

'use server' export async function updateProfil(userId: string, data: any) { await db.user.update({ where: { id: userId }, data }) }

✅ Solution

Deux failles majeures : (1) IDORuserId vient du client ; l’attaquant met l’id d’un autre utilisateur et modifie son profil. (2) Mass assignment + entrée non validéedata: any est appliqué tel quel : l’attaquant envoie { role: 'admin', emailVerified: true, ... }. Correctif :

'use server' import { z } from 'zod' import { auth } from '@/lib/auth' const schema = z.object({ nom: z.string().max(100), bio: z.string().max(500) }) // liste blanche export async function updateProfil(input: unknown) { const session = await auth() if (!session) throw new Error('Non authentifie') const data = schema.parse(input) // valide + limite les champs await db.user.update({ where: { id: session.user.id }, data }) // l'id vient de la SESSION }

On ignore l’userId du client (on prend celui de la session), on valide avec un schéma qui n’accepte que nom/bio (pas role), et on exige une session. Plus d’IDOR, plus de mass assignment.

Exercice 2 — Vrai/Faux. « Mes arguments de Server Action sont typés string en TypeScript, donc je n’ai pas besoin de les valider. » Réponds.

✅ Solution

Faux. Les types TypeScript sont effacés à la compilation : au runtime, l’argument peut être n’importe quoi (objet, undefined, tableau, opérateur NoSQL), car l’attaquant appelle l’endpoint directement avec les valeurs qu’il veut, hors de ton UI typée. Le typage aide au développement, il ne valide rien à l’exécution. Il faut une validation runtime (Zod ou vérifications explicites) sur toute entrée d’une Server Action / route handler, plus l’auth et l’autorisation. Ne jamais confondre typage statique et validation de sécurité.

🧠 Quiz de révision

1. Qu’est-ce qu’une Server Action, du point de vue de la sécurité ?

Un endpoint HTTP exposé : bien qu’elle s’appelle « comme une fonction » depuis un composant, elle devient à la compilation une route que n’importe qui peut appeler directement, avec les arguments qu’il veut. Ses entrées sont hostiles, comme toute API.

2. Quels sont les trois contrôles à appliquer à chaque Server Action / route handler ?

(1) Authentification (session valide ?), (2) Validation des entrées (schéma runtime, Zod), (3) Autorisation par objet (l’objet visé appartient-il à l’utilisateur ? requête portée par le propriétaire — anti-IDOR). Toujours côté serveur.

3. Le typage TypeScript valide-t-il les entrées au runtime ?

Non : les types sont effacés à la compilation ; au runtime, l’argument peut être n’importe quoi. Le typage aide au développement, pas à la sécurité. Il faut une validation runtime (Zod, vérifications explicites).

4. Comment éviter le mass assignment dans un route handler ?

Valider avec un schéma qui n’accepte que les champs légitimes (liste blanche), et fixer les champs de droit/propriété (ownerId, role, tenantId) côté serveur depuis la session — jamais les lire du corps de la requête. Ainsi le client ne peut pas s’attribuer des champs sensibles.

5. Pourquoi « ça vient de mon composant » ne protège-t-il pas une Server Action ?

Parce que l’attaquant contourne le composant : il appelle l’endpoint directement (réseau, curl) avec des arguments arbitraires. La provenance « UI » n’est pas garantie ni vérifiable. Seuls l’auth, la validation et l’autorisation côté serveur protègent.


Chapitre suivant : Auth.js, middleware & headers — l’authentification en Next.js, les limites du middleware comme barrière, et les en-têtes via next.config.

Last updated on