Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

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 :

html
<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 :

html
<!-- 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>
Page piégéepirate.exampleNavigateurdu comptableAppliFraisfrais.gsb.fr1. connexioncookie de session2. ouvre le lien reçu3. formulaire POST caché4. POST valider.php + cookie5. fiche 1203 validéeAvec un jeton anti-CSRFPOST + cookie, sans jeton403 Requête refusée
CSRF : le site piégé utilise la session que le navigateur de la victime a déjà ouverte

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.

php
// À la connexion (ou au premier besoin) : un jeton par session
if (empty($_SESSION['csrf'])) {
    $_SESSION['csrf'] = bin2hex(random_bytes(32));
}
php
<!-- 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>
php
// 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.

ValeurLe cookie est envoyé…Effet contre la CSRF
StrictSeulement pour les requêtes initiées depuis le même siteBloque toute CSRF, mais l'utilisateur qui arrive par un lien externe n'est pas reconnu au premier clic
LaxPour 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
NoneToujours, y compris depuis d'autres sites ; exige l'attribut SecureAucune 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, vaut same-origin, same-site, cross-site ou none (saisie directe de l'URL).
js
// 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).

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.

AttributRôleValeur recommandée pour une session
SecureLe cookie n'est envoyé qu'en HTTPSToujours
HttpOnlyLe cookie n'est pas lisible par JavaScript (document.cookie)Toujours : limite le vol par XSS
SameSiteEnvoi ou non depuis d'autres sitesLax, ou Strict pour une application sensible
PathChemins pour lesquels le cookie est envoyé/
DomainDomaines destinatairesÀ omettre : le cookie reste limité au domaine exact, sans les sous-domaines
Max-Age / ExpiresDuré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.

text
Set-Cookie: __Host-session=9f3c…e1; Path=/; Secure; HttpOnly; SameSite=Lax

En PHP, avant session_start() :

php
session_set_cookie_params([
    'lifetime' => 0,
    'path' => '/',
    'secure' => true,
    'httponly' => true,
    'samesite' => 'Lax',
]);
session_name('__Host-session');
session_start();

Avec Express et express-session :

js
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êteCe qu'il faitValeur courante
Content-Security-PolicyListe 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-OptionsInterdit au navigateur de deviner le type d'un fichier : un texte déposé ne sera pas exécuté comme un scriptnosniff
frame-ancestors (dans la CSP) ou X-Frame-OptionsInterdit 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-PolicyLimite 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-PolicyDésactive les fonctions du navigateur inutiles à l'applicationcamera=(), microphone=(), geolocation=()
Cache-ControlEmpê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 :

js
import helmet from 'helmet';
app.use(helmet());                    // à adapter, notamment la CSP, selon l'application

Avec nginx, devant l'application :

nginx
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

bash
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.

  1. La suppression d'une ligne de frais se fait par GET /lignes/supprimer?id=55, avec un cookie de session sans attribut SameSite.
  2. L'API d'AppliFrais attend Authorization: Bearer <jeton> ; le jeton est gardé en mémoire par le front React.
  3. Le changement d'adresse mail se fait par un formulaire POST protégé par un jeton anti-CSRF vérifié côté serveur.
  4. 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
  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.
  2. Non : le navigateur n'ajoute jamais l'en-tête Authorization de lui-même ; un site tiers ne peut pas le forger.
  3. Non : la requête forgée n'aura pas le bon jeton, que le site tiers ne peut pas lire.
  4. Oui : le cookie est joint à toutes les requêtes, même venues d'un autre site, puisque SameSite=None. HttpOnly protège contre le vol par XSS, pas contre la CSRF. Il faut SameSite=Lax ou Strict et 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é.

  1. Écris le code qui génère le jeton anti-CSRF et l'insère dans le formulaire.
  2. Écris la vérification au début de changer-email.php.
  3. Pourquoi utiliser hash_equals plutôt que === ?
  4. 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
  1. Génération (une fois par session) et insertion :
php
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>
  1. Vérification :
php
if (!isset($_POST['csrf']) || !hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'])) {
    http_response_code(403);
    exit('Requête refusée');
}
  1. hash_equals compare 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.
  2. 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=/.

  1. Quels attributs manquent, et quel risque chacun couvre-t-il ?
  2. Réécris l'en-tête complet, avec le préfixe __Host-.
Voir la réponseMasquer la réponse de l'exercice 3
  1. Secure manque : le cookie peut circuler en HTTP et être intercepté. HttpOnly manque : un script injecté (XSS) peut le lire. SameSite manque : selon le navigateur, le cookie peut être joint aux requêtes venues d'autres sites (CSRF).
  2. Set-Cookie: __Host-session=8a2f…; Path=/; Secure; HttpOnly; SameSite=Lax (sans attribut Domain, 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 :

text
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é ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Conception sécurisée (A06) et protection des données dès la conceptionRevoir le sommaire du cours