Chapitre 917 minIntermédiaire
Faille CSRF, cookies et en-têtes de sécurité HTTP
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- expliquer une attaque CSRF pas à pas ;
- protéger un formulaire avec un jeton anti-CSRF ;
- régler les attributs d'un cookie de session ;
- poser et vérifier les en-têtes de sécurité HTTP.
4 exercices corrigés1 schéma
Une faille CSRF (cross-site request forgery, falsification de requête intersite) permet à un site malveillant de faire exécuter une action à un utilisateur connecté sur un autre site, à son insu, en profitant de ce que son navigateur y joint automatiquement ses cookies. Elle fait partie du contrôle d'accès défaillant (A01:2025, CWE-352). Ce chapitre explique l'attaque et ses trois parades (jeton anti-CSRF, attribut SameSite, vérification de l'origine), puis les attributs d'un cookie de session et les en-têtes de sécurité HTTP qu'une application doit envoyer.
C'est quoi une faille CSRF ?
Quand tu es connecté à AppliFrais, ton navigateur garde un cookie de session et le renvoie automatiquement avec chaque requête vers frais.gsb.fr, quelle que soit la page qui a déclenché la requête. Si une page d'un autre site, ouverte dans un autre onglet, envoie un formulaire vers frais.gsb.fr, le cookie part avec. Le serveur voit une requête authentifiée, venue de ton navigateur : il l'exécute.
L'attaquant ne vole rien et ne lit rien : il ne voit même pas la réponse. Il fait agir la victime. La CSRF vise donc les actions qui modifient quelque chose : valider une fiche, changer l'adresse mail du compte, effectuer un virement, créer un utilisateur.
Exemple pas à pas
La version PHP d'AppliFrais valide une fiche avec un simple formulaire :
<form method="post" action="/compta/valider.php">
<input type="hidden" name="id_fiche" value="1187">
<button>Valider</button>
</form>
Le script valider.php vérifie que l'utilisateur est connecté et qu'il est comptable, puis valide. Le contrôle de rôle est là : un visiteur ne peut pas appeler la page directement. Mais un visiteur malveillant prépare une page sur son propre site :
<!-- https://pirate.example/promo.html -->
<h1>Résultats du challenge des visiteurs 2026</h1>
<form id="f" method="post" action="https://frais.gsb.fr/compta/valider.php">
<input type="hidden" name="id_fiche" value="1203"> <!-- sa propre fiche, gonflée -->
</form>
<script>document.getElementById('f').submit();</script>
Il envoie le lien au comptable (« Les résultats du challenge sont en ligne ! »). Le comptable clique alors qu'il a une session ouverte sur AppliFrais : la page piégée envoie le formulaire, le navigateur joint le cookie, la fiche 1203 est validée. Le comptable n'a rien vu, et le journal indique que c'est lui qui a validé.
Le jeton anti-CSRF
La parade classique est le jeton de synchronisation (synchronizer token). Le serveur génère un jeton aléatoire, le garde dans la session de l'utilisateur et l'insère dans chaque formulaire. À la réception, il compare le jeton envoyé à celui de la session. Le site de l'attaquant ne peut pas connaître ce jeton : la Same-Origin Policy l'empêche de lire les pages d'AppliFrais. Sa requête forgée arrive sans jeton valide et est refusée.
// À la connexion (ou au premier besoin) : un jeton par session
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
<!-- Dans chaque formulaire qui modifie quelque chose -->
<form method="post" action="/compta/valider.php">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($_SESSION['csrf']) ?>">
<input type="hidden" name="id_fiche" value="1187">
<button>Valider</button>
</form>
// Au début de valider.php, avant toute action
if (!isset($_POST['csrf']) || !hash_equals($_SESSION['csrf'], $_POST['csrf'])) {
http_response_code(403);
exit('Requête refusée');
}
hash_equals compare en temps constant : une comparaison ordinaire s'arrête au premier caractère différent, et le temps de réponse pourrait aider à deviner le jeton.
Les frameworks intègrent ce mécanisme : Symfony ajoute un jeton aux formulaires générés par son composant Form, Laravel vérifie automatiquement le jeton de toute requête POST, PUT, PATCH ou DELETE des routes web, la directive @csrf insérant le champ caché dans le formulaire ; ASP.NET Core le fait avec [ValidateAntiForgeryToken]. Utilise-les plutôt que de réécrire le mécanisme. En Node.js, l'ancien middleware csurf est déprécié ; des bibliothèques comme csrf-csrf mettent en œuvre la variante dite du double submit cookie, où le jeton est envoyé à la fois dans un cookie signé et dans un champ ou un en-tête.
L'attribut SameSite des cookies
L'attribut SameSite dit au navigateur s'il doit joindre le cookie aux requêtes déclenchées depuis un autre site.
| Valeur | Le cookie est envoyé… | Effet contre la CSRF |
|---|---|---|
Strict | Seulement pour les requêtes initiées depuis le même site | Bloque toute CSRF, mais l'utilisateur qui arrive par un lien externe n'est pas reconnu au premier clic |
Lax | Pour le même site, et pour une navigation de premier niveau en GET depuis un autre site (clic sur un lien) | Bloque les formulaires POST et les requêtes en arrière-plan venus d'ailleurs ; c'est le bon compromis pour un cookie de session |
None | Toujours, y compris depuis d'autres sites ; exige l'attribut Secure | Aucune protection : réservé aux cas où le cookie doit vraiment circuler entre sites |
Avec SameSite=Lax sur le cookie de session, le formulaire POST de la page piégée part sans le cookie : le serveur voit une requête anonyme et la refuse. Chrome et Edge appliquent Lax à un cookie qui ne précise rien, mais tous les navigateurs ne le font pas : déclare toujours l'attribut explicitement.
Vérifier l'origine de la requête
Le serveur peut aussi vérifier d'où vient la requête :
- l'en-tête
Origin, envoyé par les navigateurs avec les requêtes POST, contient l'origine de la page qui a déclenché la requête (https://pirate.example) ; - l'en-tête
Sec-Fetch-Site, envoyé par les navigateurs actuels, vautsame-origin,same-site,cross-siteounone(saisie directe de l'URL).
// Middleware Express : refuser les requêtes de modification venues d'un autre site
app.use((req, res, next) => {
const modifie = !['GET', 'HEAD', 'OPTIONS'].includes(req.method);
const site = req.get('Sec-Fetch-Site');
if (modifie && site && !['same-origin', 'none'].includes(site)) {
return res.status(403).json({ erreur: 'Requête intersite refusée' });
}
next();
});
C'est une bonne couche supplémentaire, simple à poser, mais elle dépend d'en-têtes que de vieux navigateurs n'envoient pas : elle complète les autres mesures.
CSRF et API avec jeton JWT
Une API qui attend son jeton dans l'en-tête Authorization: Bearer … n'est pas exposée à la CSRF : le navigateur n'ajoute jamais cet en-tête de lui-même, c'est le code JavaScript du front qui le fait. Un formulaire piégé sur un autre site ne peut pas le fournir.
Mais si le jeton est rangé dans un cookie (pour le protéger d'une XSS grâce à HttpOnly), le navigateur l'envoie automatiquement, et la CSRF redevient possible. Il faut alors SameSite=Lax ou Strict et une protection anti-CSRF sur les routes qui modifient des données. Il n'y a pas de solution gratuite : chaque choix de stockage déplace le risque (session ou JWT).
Les attributs d'un cookie de session
Le cookie de session est la clé du compte : quiconque le possède est connecté à la place de l'utilisateur. Ses attributs le protègent.
| Attribut | Rôle | Valeur recommandée pour une session |
|---|---|---|
Secure | Le cookie n'est envoyé qu'en HTTPS | Toujours |
HttpOnly | Le cookie n'est pas lisible par JavaScript (document.cookie) | Toujours : limite le vol par XSS |
SameSite | Envoi ou non depuis d'autres sites | Lax, ou Strict pour une application sensible |
Path | Chemins pour lesquels le cookie est envoyé | / |
Domain | Domaines destinataires | À omettre : le cookie reste limité au domaine exact, sans les sous-domaines |
Max-Age / Expires | Durée de vie | À omettre pour un cookie de session (supprimé à la fermeture du navigateur), ou courte |
Le préfixe __Host- dans le nom du cookie (__Host-session) oblige le navigateur à refuser le cookie s'il n'est pas Secure, sans Domain et avec Path=/ : un sous-domaine compromis ne peut pas l'écraser.
Set-Cookie: __Host-session=9f3c…e1; Path=/; Secure; HttpOnly; SameSite=Lax
En PHP, avant session_start() :
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
session_name('__Host-session');
session_start();
Avec Express et express-session :
app.use(session({
name: '__Host-session',
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { secure: true, httpOnly: true, sameSite: 'lax', path: '/', maxAge: 30 * 60 * 1000 },
}));
L'oubli de Secure ou de HttpOnly est classé par l'OWASP en mauvaise configuration (A02:2025, CWE-614 et CWE-1004).
Les en-têtes de sécurité HTTP
Le serveur peut demander au navigateur d'activer des protections supplémentaires, par des en-têtes de réponse. Leur absence est une mauvaise configuration (A02:2025).
| En-tête | Ce qu'il fait | Valeur courante |
|---|---|---|
Content-Security-Policy | Liste les sources autorisées de scripts, styles, images, cadres ; deuxième ligne de défense contre la XSS (chapitre 8) | default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none' |
Strict-Transport-Security (HSTS) | Oblige le navigateur à n'utiliser que HTTPS pour ce domaine pendant la durée indiquée, même si l'utilisateur tape http:// | max-age=63072000; includeSubDomains |
X-Content-Type-Options | Interdit au navigateur de deviner le type d'un fichier : un texte déposé ne sera pas exécuté comme un script | nosniff |
frame-ancestors (dans la CSP) ou X-Frame-Options | Interdit d'afficher la page dans un cadre d'un autre site, contre le clickjacking (un bouton invisible superposé à un faux contenu) | frame-ancestors 'none' ; X-Frame-Options: DENY pour les anciens navigateurs |
Referrer-Policy | Limite l'URL d'origine transmise quand on suit un lien vers un autre site (une URL peut contenir un identifiant ou un jeton) | strict-origin-when-cross-origin ou no-referrer |
Permissions-Policy | Désactive les fonctions du navigateur inutiles à l'application | camera=(), microphone=(), geolocation=() |
Cache-Control | Empêche la mise en cache des pages sensibles (sur un poste partagé, le bouton « précédent » ne doit pas réafficher une fiche) | no-store sur les réponses qui contiennent des données personnelles |
Avec HSTS, la première visite doit tout de même se faire en HTTPS pour que le navigateur reçoive l'en-tête. La liste de préchargement HSTS (HSTS preload), intégrée aux navigateurs, supprime cette faiblesse pour les domaines qui s'y inscrivent, avec la directive preload.
Deux en-têtes sont à retirer plutôt qu'à ajouter : X-Powered-By: Express ou X-Powered-By: PHP/8.4.2 (ils annoncent la technologie et la version), et l'ancien X-XSS-Protection, abandonné par les navigateurs et à mettre à 0 s'il est présent.
Les poser avec Express ou nginx
Avec Express, helmet pose en une ligne un ensemble de valeurs par défaut sûres (CSP, HSTS, nosniff, Referrer-Policy, X-Frame-Options…) et retire X-Powered-By :
import helmet from 'helmet';
app.use(helmet()); // à adapter, notamment la CSP, selon l'application
Avec nginx, devant l'application :
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
Le mot-clé always envoie l'en-tête aussi sur les réponses d'erreur (404, 500), qui sinon en sont privées. En PHP sans framework, la fonction header() pose chaque en-tête, en tête de script.
Vérifier les en-têtes d'un site
curl -sI https://frais.gsb.fr/ | grep -iE 'strict-transport|content-security|x-content-type|referrer|set-cookie'
L'option -I ne demande que les en-têtes. L'onglet Réseau des outils de développement du navigateur montre les en-têtes de chaque réponse et les attributs de chaque cookie (onglet Application ou Stockage). Le service en ligne HTTP Observatory de MDN analyse un site public et note sa configuration ; les scanners DAST signalent aussi les en-têtes manquants.
Exercice 1 sur 4 Reconnaître une CSRF Réussi À revoir
Pour chaque scénario, dis si une CSRF est possible, et pourquoi.
- La suppression d'une ligne de frais se fait par
GET /lignes/supprimer?id=55, avec un cookie de session sans attributSameSite. - L'API d'AppliFrais attend
Authorization: Bearer <jeton>; le jeton est gardé en mémoire par le front React. - Le changement d'adresse mail se fait par un formulaire POST protégé par un jeton anti-CSRF vérifié côté serveur.
- Le jeton JWT est stocké dans un cookie
HttpOnly; Secure; SameSite=None, et aucune autre protection n'est en place.
Voir la réponseMasquer la réponse de l'exercice 1
- Oui : une simple balise
<img src="https://frais.gsb.fr/lignes/supprimer?id=55">sur n'importe quelle page déclenche la requête avec le cookie. Double erreur : un GET qui modifie, et aucune protection. - Non : le navigateur n'ajoute jamais l'en-tête
Authorizationde lui-même ; un site tiers ne peut pas le forger. - Non : la requête forgée n'aura pas le bon jeton, que le site tiers ne peut pas lire.
- Oui : le cookie est joint à toutes les requêtes, même venues d'un autre site, puisque
SameSite=None.HttpOnlyprotège contre le vol par XSS, pas contre la CSRF. Il fautSameSite=LaxouStrictet un jeton anti-CSRF.
Tu avais trouvé ?
Exercice 2 sur 4 Écrire la protection Réussi À revoir
Le script PHP changer-email.php reçoit $_POST['email'] et met à jour l'adresse du compte connecté.
- Écris le code qui génère le jeton anti-CSRF et l'insère dans le formulaire.
- Écris la vérification au début de
changer-email.php. - Pourquoi utiliser
hash_equalsplutôt que===? - Pourquoi est-il prudent de demander aussi le mot de passe actuel pour cette action précise ?
Voir la réponseMasquer la réponse de l'exercice 2
- Génération (une fois par session) et insertion :
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
?>
<form method="post" action="changer-email.php">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($_SESSION['csrf']) ?>">
<input type="email" name="email" required>
<button>Modifier</button>
</form>
- Vérification :
if (!isset($_POST['csrf']) || !hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'])) {
http_response_code(403);
exit('Requête refusée');
}
hash_equalscompare en temps constant : le temps de réponse ne révèle pas combien de caractères sont corrects, ce qui empêche de deviner le jeton caractère par caractère.- Changer l'adresse mail permet ensuite de réinitialiser le mot de passe et de voler le compte. Redemander le mot de passe (ré-authentification) protège cette action même si une session a été détournée ou si une autre protection a été oubliée.
Tu avais trouvé ?
Exercice 3 sur 4 Régler un cookie Réussi À revoir
L'application envoie : Set-Cookie: PHPSESSID=8a2f…; path=/.
- Quels attributs manquent, et quel risque chacun couvre-t-il ?
- Réécris l'en-tête complet, avec le préfixe
__Host-.
Voir la réponseMasquer la réponse de l'exercice 3
Securemanque : le cookie peut circuler en HTTP et être intercepté.HttpOnlymanque : un script injecté (XSS) peut le lire.SameSitemanque : selon le navigateur, le cookie peut être joint aux requêtes venues d'autres sites (CSRF).Set-Cookie: __Host-session=8a2f…; Path=/; Secure; HttpOnly; SameSite=Lax(sans attributDomain, exigence du préfixe__Host-).
Tu avais trouvé ?
Exercice 4 sur 4 Lire des en-têtes Réussi À revoir
Un curl -sI https://frais.gsb.fr/ renvoie notamment :
HTTP/2 200
x-powered-by: PHP/8.1.2
content-type: text/html; charset=UTF-8
set-cookie: PHPSESSID=0c1d…; path=/; secure; HttpOnly
Liste les problèmes et les en-têtes à ajouter, avec leur valeur.
Voir la réponseMasquer la réponse de l'exercice 4
Problèmes : x-powered-by annonce PHP 8.1.2, une version qui n'est plus maintenue (expose_php = Off et mise à jour de PHP) ; le cookie n'a pas d'attribut SameSite.
En-têtes à ajouter : Strict-Transport-Security: max-age=63072000; includeSubDomains ; Content-Security-Policy: default-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none' (à adapter aux ressources du site) ; X-Content-Type-Options: nosniff ; Referrer-Policy: strict-origin-when-cross-origin ; Permissions-Policy: camera=(), microphone=(), geolocation=() ; Cache-Control: no-store sur les pages qui affichent des données personnelles. Le cookie devient …; Secure; HttpOnly; SameSite=Lax.
Tu avais trouvé ?