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
- L'utilisateur est connecté à
app-banque.fr(cookie de session actif) - Il reçoit un email frauduleux et clique sur un lien vers
site-pirate.com - La page pirate contient :
<img src="https://app-banque.fr/api/virement?montant=1000&vers=pirate">(balise invisible) - Le navigateur envoie la requête GET avec les cookies de session — le virement est exécuté
- Version form-based (POST) :
<form action="https://app-banque.fr/api/virement" method="POST">auto-soumis via JS
| Vecteur d'attaque | Méthode HTTP | Exemple |
|---|---|---|
| Balise image | GET | <img src="bank.com/api/transfer?..."> |
| Formulaire auto-soumis | POST | Form avec JS submit() au chargement |
| Fetch/XMLHttpRequest | POST/PUT/DELETE | Requête depuis script cross-origin |
| Lien hypertexte | GET | <a href="bank.com/api/delete-account"> |
Mécanismes de défense disponibles
| Mécanisme | Protection | Complexité | Angular natif |
|---|---|---|---|
| Double Submit Cookie | Faible | Oui | |
| SameSite=Strict | Très faible | Côté serveur | |
| Token synchronisé | Moyenne | Oui | |
| Origin/Referer header check | Faible | Côté serveur | |
| Custom request header | Faible | Via intercepteur |
Protection XSRF native d'Angular
Angular implémente le pattern Double Submit Cookie via son intercepteur HTTP intégré. Le cycle complet :
- 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) - 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 - Le serveur compare la valeur du cookie avec la valeur de l'en-tête — elles doivent correspondre
- 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 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 SameSite | Comportement | Protection CSRF | Impact UX |
|---|---|---|---|
Strict | Cookie jamais envoyé cross-site | Maximale | Perd session si lien externe |
Lax | Cookie envoyé pour navigation top-level GET | Bonne | Recommandé (défaut navigateurs) |
None | Toujours 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-TOKENdans l'onglet Application → Cookies - Les deux valeurs doivent être identiques
- Tester avec curl sans header → doit retourner 403 :
curl -X POST /api/resource -d '{}'