Comment protéger un formulaire avec un jeton CSRF ?¶
Une attaque CSRF (Cross-Site Request Forgery) exploite la confiance qu'un site accorde au navigateur de l'utilisateur connecté.
C'est quoi une attaque CSRF ?¶
L'utilisateur de votre application est connecté et possède donc une session active sur votre site. Un site malveillant visited pendant la même session peut déclencher, à l'insu de l'utilisateur, une requête HTTP vers votre site (Exemple : une image cachée, un formulaire soumis automatiquement en JavaScript).
Comme le navigateur envoie automatiquement le cookie de session avec cette requête, le serveur croit reconnaître l'utilisateur légitime et exécute l'action demandée (Exemple : changer son email, supprimer des données, faire un virement ...).
sequenceDiagram
autonumber
actor U as Utilisateur
participant S as Votre site
participant M as Site malveillant
U->>S: Connexion
Note over U,S: Le navigateur enregistre le cookie de session
U->>M: Visite du site malveillant
M-->>U: Page piégée (image cachée, auto-submit en JS)
M->>S: Requête forgée
Note over M,S: Le cookie de session est envoyé automatiquement !
alt Sans protection CSRF
S-->>M: La requête est exécutée
else Avec un jeton CSRF
S-->>M: 403 - Requête rejetée
end
Le principe de protection : le Synchronizer Token¶
Le framework fournit le service Studoo\EduFramework\Core\Security\CsrfService basé sur trois principes :
- Génération : le serveur génère un jeton aléatoire imprévisible (
random_bytes) et le stocke en session - Transmission : le formulaire envoie ce jeton dans un champ caché
_csrf_token - Validation : le serveur compare le jeton reçu avec le jeton de la session (
hash_equals, protégé contre les attaques temporelles)
Un site malveillant ne peut pas lire le jeton (il n'a pas accès au contenu de la page de votre site), sa requête forgée est donc rejetée avec une erreur HTTP 403.
Le service applique deux renforcements :
- Cloisonnement par route : chaque nom de route possède son propre jeton.
Le jeton du formulaire
/medecin/importn'est pas accepté sur la route/user/{id}/update - Rotation : le jeton est renouvelé après chaque validation réussie, ce qui limite la fenêtre de rejeu d'un jeton intercepté
Protéger un formulaire en 2 étapes¶
Étape 1 : le jeton dans le fichier twig¶
Ajoutez la fonction twig csrf_token() dans votre formulaire avec le nom de la route
défini dans le fichier app/Config/routes.yaml :
{% extends "base.html.twig" %}
{% block content %}
<form action="{{ getNameToPath('ville') }}" method="post">
+ {{ csrf_token('ville') | raw }}
<label for="nom_ville">Ville</label>
<input type="text" id="nom_ville" name="nom_ville">
<input type="submit" value="Envoyer">
</form>
{% endblock %}
La fonction génère le champ caché du jeton :
<input type="hidden" name="_csrf_token" value="6734a87fa57df870bb485735ceca7fd17e9fc114decc80cc358562003724080f">
Étape 2 : la validation dans le controller¶
Dans le traitement du formulaire, validez le jeton avec la méthode isCsrfTokenValid()
de la classe Request en utilisant le même nom de route :
public function execute(Request $request): string|null
{
+ if ($request->getHttpMethod() === "POST"
+ && $request->isCsrfTokenValid('ville') === false) {
+ throw new ErrorHttpStatusException("Jeton CSRF invalide ou expiré", 403);
+ }
+
if ($request->get('nom_ville') !== null) {
// Traitement du formulaire ...
}
Attention
Le nom de route passé à csrf_token() dans le template et à isCsrfTokenValid()
dans le controller doit être identique, sinon la soumission est toujours rejetée (403).
Si le jeton est invalide, la classe ErrorHttpStatusException déclenche la page
d'erreur HTTP 403 via le pipeline d'erreurs du framework.
Si le jeton est valide, il est immédiatement renouvelé pour la prochaine soumission.
Astuce
Après une soumission réussie, redirigez vers une page GET (pattern POST/Redirect/GET) plutôt que de re-afficher le formulaire : l'utilisateur ne pourra pas soumettre un formulaire périmé en rafraîchissant la page.
Exemple complet¶
Vous pouvez tester la protection sur la route /medecin/import de l'application de démonstration.
Le controller MedecinController::import() valide le jeton avant de traiter le téléversement du fichier :
if ($request->getHttpMethod() === "POST") {
// Sécurité : le jeton CSRF du formulaire doit être valide
if ($request->isCsrfTokenValid('medecin_import') === false) {
throw new ErrorHttpStatusException("Jeton CSRF invalide ou expiré", 403);
}
// Traitement du formulaire ...
}
Le formulaire app/Template/medecin/import.html.twig contient le champ caché du jeton :
<form action="{{ getNameToPath('medecin_import') }}" method="post" enctype="multipart/form-data">
{{ csrf_token('medecin_import') | raw }}
<input type="file" name="fichier">
<input type="submit" value="Importer">
</form>
Les appels AJAX¶
Pour une requête JavaScript qui ne passe pas par un formulaire,
transmettez le jeton dans l'en-tête HTTP X-CSRF-TOKEN :
const token = document.querySelector('input[name="_csrf_token"]').value;
fetch('/medecin/import', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': token
},
body: formData
});
La classe CsrfService¶
Le service est utilisable directement (sans passer par la classe Request) :
| Méthode | Rôle |
|---|---|
CsrfService::getToken(string $routeName) |
Génère (au premier appel) et renvoie le jeton de la route |
CsrfService::validate(string $routeName, ?string $token) |
Valide le jeton reçu, renvoie true/false (rotation en cas de succès) |
CsrfService::getTokenField(string $routeName) |
Renvoie le champ HTML caché du jeton de la route |
Les jetons sont stockés en session dans $_SESSION['_csrf_tokens'][nomDeRoute].
Limites de la rotation
Comme le jeton est renouvelé après chaque validation réussie :
- le même formulaire ouvert dans deux onglets : la soumission du premier onglet invalide le jeton du second (erreur 403)
- le bouton retour du navigateur suivi d'une nouvelle soumission est rejeté
C'est le comportement attendu : l'utilisateur doit re-afficher le formulaire pour obtenir un jeton frais. Deux formulaires de routes différentes ouverts simultanément restent valides (le cloisonnement par route limite l'impact de la rotation).