Front-end angularforall.com

- Angular & CSRF : Protection contre les attaques

Angular Securite Csrf Xsrf
Angular & CSRF : Protection contre les attaques

Comprenez les attaques CSRF et comment Angular les prévient avec les tokens XSRF. Configuration HttpClient, bonnes pratiques de sécurité et exemples pratiques.

Anatomie d'une attaque CSRF

Cross-Site Request Forgery (CSRF, aussi XSRF) est une attaque qui exploite la confiance qu'un site accorde aux cookies de session du navigateur. L'attaquant forge une requête authentifiée à l'insu de l'utilisateur.

Scénario d'attaque concret — étape par étape

  1. L'utilisateur est connecté à app-banque.fr (cookie de session actif)
  2. Il reçoit un email frauduleux et clique sur un lien vers site-pirate.com
  3. La page pirate contient : <img src="https://app-banque.fr/api/virement?montant=1000&vers=pirate"> (balise invisible)
  4. Le navigateur envoie la requête GET avec les cookies de session — le virement est exécuté
  5. Version form-based (POST) : <form action="https://app-banque.fr/api/virement" method="POST"> auto-soumis via JS
Pourquoi c'est possible : La politique Same-Origin permet les requêtes cross-origin (formulaires, images, scripts) — elle bloque seulement la lecture des réponses. Le serveur reçoit la requête avec les cookies valides et ne peut pas distinguer une action légitime d'une attaque.
Vecteur d'attaqueMéthode HTTPExemple
Balise imageGET<img src="bank.com/api/transfer?...">
Formulaire auto-soumisPOSTForm avec JS submit() au chargement
Fetch/XMLHttpRequestPOST/PUT/DELETERequête depuis script cross-origin
Lien hypertexteGET<a href="bank.com/api/delete-account">

Mécanismes de défense disponibles

Mécanisme Protection Complexité Angular natif
Double Submit CookieFaible Oui
SameSite=StrictTrès faible Côté serveur
Token synchroniséMoyenne Oui
Origin/Referer header checkFaibleCôté serveur
Custom request headerFaibleVia intercepteur
Approche recommandée : Double Submit Cookie (Angular natif) + SameSite=Strict sur les cookies de session. Ces deux couches ensemble couvrent 99% des scénarios d'attaque, y compris les navigateurs anciens qui ne supportent pas encore SameSite.

Protection XSRF native d'Angular

Angular implémente le pattern Double Submit Cookie via son intercepteur HTTP intégré. Le cycle complet :

  1. Le serveur génère un token aléatoire cryptographiquement sécurisé et le place dans un cookie XSRF-TOKEN (non HttpOnly — Angular doit le lire en JS)
  2. Pour chaque requête POST/PUT/PATCH/DELETE, l'intercepteur Angular lit le token du cookie et l'ajoute dans l'en-tête HTTP X-XSRF-TOKEN
  3. Le serveur compare la valeur du cookie avec la valeur de l'en-tête — elles doivent correspondre
  4. Si elles ne correspondent pas (ou si l'en-tête est absent) → rejet 403 Forbidden

Pourquoi l'attaquant ne peut pas contourner ça : La Same-Origin Policy l'empêche de lire le cookie XSRF-TOKEN depuis son domaine. Sans le token, il ne peut pas remplir l'en-tête. Sans l'en-tête correct, le serveur rejette la requête.

Requêtes protégées par l'intercepteur Angular : POST, PUT, PATCH, DELETE
Requêtes non protégées (lecture seule) : GET, HEAD, OPTIONS — ces méthodes ne modifient pas d'état, pas besoin de CSRF

Configuration avec provideHttpClient

Avec Angular 15+ (API standalone), la configuration XSRF se fait via withXsrfConfiguration() :

// app.config.ts — Protection XSRF explicite (Angular 15+)
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import {
    provideHttpClient,
    withInterceptorsFromDi,
    withXsrfConfiguration
} from '@angular/common/http';

export const appConfig: ApplicationConfig = {
    providers: [
        provideRouter(routes),
        provideHttpClient(
            withInterceptorsFromDi(),
            withXsrfConfiguration({
                cookieName: 'XSRF-TOKEN',   // Nom du cookie envoyé par le serveur
                headerName: 'X-XSRF-TOKEN'  // Nom de l'en-tête ajouté par Angular
            })
        )
    ]
};
// Pour Angular 14 et modules NgModule
import { NgModule } from '@angular/core';
import { HttpClientModule, HttpClientXsrfModule } from '@angular/common/http';

@NgModule({
    imports: [
        HttpClientModule,
        HttpClientXsrfModule.withOptions({
            cookieName: 'XSRF-TOKEN',
            headerName: 'X-XSRF-TOKEN'
        })
    ]
})
export class AppModule { }

HttpClient ajoute le token XSRF automatiquement — aucun code supplémentaire dans les services :

// api.service.ts — Le token est géré automatiquement par l'intercepteur
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class UserService {
    private http = inject(HttpClient);

    updateProfile(data: UserProfile): Observable<UserProfile> {
        // Angular ajoute automatiquement X-XSRF-TOKEN dans les headers
        return this.http.put<UserProfile>('/api/users/profile', data);
    }

    deleteAccount(userId: string): Observable<void> {
        // Token XSRF ajouté automatiquement
        return this.http.delete<void>(`/api/users/${userId}`);
    }

    getProfile(): Observable<UserProfile> {
        // GET → pas de token XSRF (pas nécessaire)
        return this.http.get<UserProfile>('/api/users/profile');
    }
}

Backend Express — validation complète

La protection n'est complète que si le serveur génère le token, le place dans le cookie, et le valide sur chaque requête mutante.

// server.ts — Express avec protection CSRF complète
import express from 'express';
import cookieParser from 'cookie-parser';
import crypto from 'crypto';

const app = express();
app.use(express.json());
app.use(cookieParser(process.env.COOKIE_SECRET)); // signer les cookies

// Générer et définir le token XSRF au démarrage de session
app.use((req, res, next) => {
    if (!req.cookies['XSRF-TOKEN']) {
        const token = crypto.randomBytes(32).toString('hex');
        res.cookie('XSRF-TOKEN', token, {
            httpOnly: false,      // DOIT être false — Angular lit en JS
            secure: true,         // HTTPS uniquement en production
            sameSite: 'Strict',   // Défense complémentaire
            maxAge: 24 * 60 * 60 * 1000  // 24h
        });
    }
    next();
});

// Middleware de validation CSRF — appliqué aux méthodes mutantes
function validateCsrf(req: express.Request, res: express.Response, next: express.NextFunction) {
    const safeMethods = ['GET', 'HEAD', 'OPTIONS'];
    if (safeMethods.includes(req.method)) return next();

    const tokenFromCookie = req.cookies['XSRF-TOKEN'];
    const tokenFromHeader = req.headers['x-xsrf-token'];

    if (!tokenFromCookie || !tokenFromHeader) {
        return res.status(403).json({
            error: 'CSRF_MISSING_TOKEN',
            message: 'Token XSRF manquant'
        });
    }

    // Comparaison en temps constant (éviter timing attacks)
    const isValid = crypto.timingSafeEqual(
        Buffer.from(tokenFromCookie as string),
        Buffer.from(tokenFromHeader as string)
    );

    if (!isValid) {
        return res.status(403).json({
            error: 'CSRF_INVALID_TOKEN',
            message: 'Token XSRF invalide'
        });
    }

    next();
}

// Appliquer la validation CSRF à toutes les routes
app.use(validateCsrf);

// Routes protégées
app.put('/api/users/profile', (req, res) => {
    res.json({ message: 'Profil mis à jour' });
});

app.delete('/api/users/:id', (req, res) => {
    res.json({ message: 'Compte supprimé' });
});

Avec une librairie dédiée (csrf-csrf)

// npm install csrf-csrf — Alternative moderne à csurf (maintenu)
import { doubleCsrf } from 'csrf-csrf';

const { generateToken, doubleCsrfProtection } = doubleCsrf({
    getSecret: () => process.env.CSRF_SECRET!,  // secret serveur pour signer
    cookieName: 'XSRF-TOKEN',
    cookieOptions: {
        httpOnly: false,   // Angular doit lire le cookie
        secure: true,
        sameSite: 'strict'
    },
    getTokenFromRequest: (req) => req.headers['x-xsrf-token'] as string,
    size: 64,              // longueur du token en bytes
});

// Initialiser le token à la connexion
app.get('/api/auth/token', (req, res) => {
    res.json({ csrfToken: generateToken(req, res) });
});

// Appliquer la protection
app.use(doubleCsrfProtection);

SameSite cookies comme défense complémentaire

L'attribut SameSite sur les cookies de session est une défense complémentaire puissante. Avec SameSite=Strict, le navigateur n'envoie pas les cookies pour les requêtes cross-site — même sans token XSRF.

// Configuration des cookies de session (côté serveur)
import session from 'express-session';

app.use(session({
    secret: process.env.SESSION_SECRET!,
    name: 'session_id',
    cookie: {
        httpOnly: true,          // Inaccessible en JS (XSS protection)
        secure: true,            // HTTPS seulement
        sameSite: 'strict',      // Pas d'envoi cross-site → CSRF protection
        maxAge: 4 * 60 * 60 * 1000, // 4 heures
    },
    resave: false,
    saveUninitialized: false,
}));

// Cookie de session JWT
res.cookie('access_token', jwt, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 15 * 60 * 1000, // 15 minutes (access token court)
    path: '/api',            // limiter le chemin d'envoi du cookie
});
Valeur SameSiteComportementProtection CSRFImpact UX
StrictCookie jamais envoyé cross-siteMaximale Perd session si lien externe
LaxCookie envoyé pour navigation top-level GETBonne Recommandé (défaut navigateurs)
NoneToujours envoyé (requis avec Secure)Aucune Nécessaire pour iframes/cross-site

Security Interceptor Angular

Pour les cas où le nom du cookie ou de l'en-tête diffère du standard, ou pour ajouter des headers de sécurité supplémentaires, crée un intercepteur personnalisé.

// security.interceptor.ts — Intercepteur de sécurité avancé
import { HttpInterceptorFn, HttpRequest, HttpHandlerFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { DOCUMENT } from '@angular/common';

// Lire un cookie par son nom
function getCookie(name: string): string | null {
    const matches = document.cookie.match(
        new RegExp(`(?:^|; )${name.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1')}=([^;]*)`)
    );
    return matches ? decodeURIComponent(matches[1]) : null;
}

export const securityInterceptor: HttpInterceptorFn = (
    req: HttpRequest<unknown>,
    next: HttpHandlerFn
) => {
    const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];

    // Ne pas modifier les requêtes en lecture seule
    if (SAFE_METHODS.includes(req.method)) {
        return next(req);
    }

    // Lire le token CSRF depuis le cookie
    const csrfToken = getCookie('XSRF-TOKEN');

    if (!csrfToken) {
        console.warn('CSRF token manquant — vérifier la configuration serveur');
        return next(req);
    }

    // Ajouter les headers de sécurité
    const secureReq = req.clone({
        headers: req.headers
            .set('X-XSRF-TOKEN', csrfToken)
            .set('X-Requested-With', 'XMLHttpRequest') // signal AJAX
            .set('Cache-Control', 'no-store'),          // données sensibles non cachées
    });

    return next(secureReq);
};

// Enregistrer dans app.config.ts
// provideHttpClient(withInterceptors([securityInterceptor]))

Tester la protection CSRF

La protection CSRF ne sert à rien si tu ne la testes pas. Ces tests vérifient que les requêtes sans token valide sont bien rejetées.

// csrf.test.ts — Tests avec Supertest (Express)
import request from 'supertest';
import app from './server';

describe('CSRF Protection', () => {

    it('GET /api/data — doit passer sans token CSRF', async () => {
        const res = await request(app).get('/api/data');
        expect(res.status).toBe(200);
    });

    it('POST sans token — doit retourner 403', async () => {
        const res = await request(app)
            .post('/api/users/profile')
            .send({ name: 'Test' })
            // Pas de X-XSRF-TOKEN header
        expect(res.status).toBe(403);
        expect(res.body.error).toBe('CSRF_MISSING_TOKEN');
    });

    it('POST avec token invalide — doit retourner 403', async () => {
        const res = await request(app)
            .post('/api/users/profile')
            .set('Cookie', 'XSRF-TOKEN=valid_token_123')
            .set('X-XSRF-TOKEN', 'faux_token_attaquant')
            .send({ name: 'Test' });
        expect(res.status).toBe(403);
    });

    it('POST avec token valide — doit réussir', async () => {
        const token = 'valid_csrf_token_32_bytes_min';
        const res = await request(app)
            .post('/api/users/profile')
            .set('Cookie', `XSRF-TOKEN=${token}`)
            .set('X-XSRF-TOKEN', token)
            .send({ name: 'Test User' });
        expect(res.status).toBe(200);
    });
});

Vérification manuelle dans les DevTools

  • Ouvrir Network → filtrer par XHR/Fetch → effectuer une action POST/DELETE
  • Inspecter les headers de la requête → vérifier la présence de X-XSRF-TOKEN
  • Comparer avec le cookie XSRF-TOKEN dans l'onglet Application → Cookies
  • Les deux valeurs doivent être identiques
  • Tester avec curl sans header → doit retourner 403 : curl -X POST /api/resource -d '{}'

Partager