Niveau Niveau confirmé

Se protéger des CSRF

Articledéveloppement

Publié par (membre du staff)
le , mis à jour le (55 lectures)

sécurité owasp csrf

Parmi les vulnérabilités web qui font les beaux jours de ceux qui passent leurs nuits à chercher des failles dans vos applications, le CSRF (Cross-Site Request Forgery, ou falsification de requête inter-sites pour satisfaire l'Académie) est l'une des plus élégantes (c'est à dire sournoise et terriblement efficace).

L'OWASP (Open Worldwide Application Security Project) la classe depuis des années dans son top 10 des vulnérabilités les plus critiques, et pourtant elle reste souvent négligée.

Qu'est-ce qu'une CSRF ?

Schéma d'attaque CSRF

CSRF repose sur un principe simple : le navigateur envoie toujours automatiquement les cookies associés à un domaine, quelle que soit l'origine de la requête. C'est son fonctionnement naturel (et il n'est pas prévu de le changer). Autrement dit, si vous êtes connecté à kiwi-banque.fr, et qu'un site tiers envoie une requête vers kiwi-banque.fr en votre nom, votre navigateur va joindre votre cookie de session à cette requête, sans vous demander votre avis.

🍪 Le serveur reçoit une requête avec un cookie valide, il considère que c'est vous qui avez agi, et exécute l'action. Mission accomplie pour l'attaquant, qui n'a même pas eu besoin de connaître votre mot de passe puisque c'est votre cookie qui a été exploité pour vous reconnaître.

Chat pirate

Un scénario concret

Imaginons que vous soyez connecté à votre espace client sur kiwi-banque.fr. Quelqu'un vous envoie un lien vers un site apparemment anodin sur les prouesses sportives de capybaras. Ce site contient quelque part dans son code HTML, discrètement :

<img src="https://kiwi-banque.fr/virement?montant=1000&vers=compte-pirate" width="0" height="0">

Votre navigateur charge silencieusement cette fausse "image". Il envoie donc une requête GET à votre banque, avec votre cookie de session. Si le script de virement ne vérifie pas l'origine de la requête, le virement est effectué.

💡 En réalité les pages et scripts d'un service bancaire sont un peu plus complexes que cela et n'acceptent pas un montant de virement dans un simple paramètre, mais c'est pour favoriser la lisibilité de cet article.

Remplacez "virement" ici par "changement d'adresse email", "publication d'un post", "suppression d'un compte", "modification de mot de passe", et ça redevient crédible pour beaucoup d'applications du quotidien.

GET vs POST : une fausse sécurité

On pourrait se dire : "il suffit d'utiliser des requêtes POST pour les actions sensibles, pas des GET". C'est un chouia mieux mais pas suffisant. Un formulaire sur un site tiers peut très bien soumettre une requête POST vers votre domaine :

<!-- Sur le site de l'attaquant -->
<form method="POST" action="https://monapp.fr/compte/supprimer" id="vilain-form">
  <input type="hidden" name="confirm" value="oui">
</form>
<script>
  document.getElementById('vilain-form').submit();
</script>

La page se charge, le script s'exécute tout seul, le formulaire est soumis en douce... et on ne voit rien. Ou peut-être une fraction de seconde de redirection.

Utiliser une méthode POST est une bonne pratique de base (les actions qui modifient des données ne devraient en général pas passer par GET), mais ça ne protège pas de CSRF.

Le token CSRF

La défense la plus répandue et la plus fiable, c'est d'utiliser un token CSRF :

  1. Le serveur génère un token aléatoire (= une chaîne de caractères), unique par session ou par formulaire.
  2. Ce token est inclus dans chaque formulaire HTML en champ caché <input type="hidden">.
  3. Lors de la soumission, le serveur vérifie que le token reçu correspond bien à celui qu'il a généré initialement.
  4. Un attaquant sur un autre domaine ne peut pas connaître ce token par avance (car il ne peut pas lire le contenu de votre page), donc la requête forgée sera rejetée.

Diagramme du flux token CSRF

En PHP

Exemple minimaliste :

// Démarrage de session
session_start();

// Génération du token (une seule fois par session)
function csrf_generate_token(): string {
    if (empty($_SESSION['csrf_token'])) {
        $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }
    return $_SESSION['csrf_token'];
}

// Fonction de vérification du token
function csrf_verify_token(string $token): bool {
    if (empty($_SESSION['csrf_token'])) {
        return false;
    }
    return hash_equals($_SESSION['csrf_token'], $token);
}

Dans le formulaire HTML :

<form method="POST" action="/mon-action">
    <input type="hidden" name="csrf_token" value="<?= htmlspecialchars(csrf_generate_token()) ?>">
    <!-- Vos autres champs -->
    <button type="submit">Valider</button>
</form>

Côté traitement :

session_start();

// On vérifie
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $token = $_POST['csrf_token'] ?? '';

    if (!csrf_verify_token($token)) {
        http_response_code(403);
        die('Requête invalide.');
    }

    // À partir d'ici, on peut traiter les données en toute sécurité
}

Quelques points importants :

  • la plupart des frameworks modernes incluent déjà ces techniques, donc vérifiez avant d'écrire du code maison en doublon, il suffit parfois d'une option à activer ou une ligne de code
  • random_bytes(32) génère 32 octets vraiment aléatoires, il ne faut jamais utiliser rand() ou mt_rand()
  • hash_equals() fait une comparaison en temps constant pour éviter une attaque temporelle
  • pour une sécurité renforcée, vous pouvez régénérer le token à chaque requête mais attention à la gestion des onglets multiples

Côté JavaScript / API ?

Les applications modernes communiquent souvent via des APIs REST ou GraphQL, sans formulaire HTML traditionnel c'est à dire qu'on envoie des requêtes asynchrones en arrière-plan (par exemple avec fetch). La logique reste la même, mais l'implémentation change légèrement.

L'en-tête HTTP custom

Une technique courante consiste à vérifier côté serveur la présence d'un en-tête HTTP personnalisé dans les requêtes asynchrones. Parce qu'un formulaire HTML standard ou une balise <img> ne peuvent pas envoyer des en-têtes personnalisés. Seul JavaScript le peut, et JavaScript sur un autre domaine est bloqué par la politique CORS (à condition qu'elle soit bien configurée).

Côté client :

// Toutes vos requêtes sensibles incluent cet en-tête
async function apiFetch(url, options = {}) {
    const defaultOptions = {
        headers: {
            'Content-Type': 'application/json',
            'X-Requested-With': 'XMLHttpRequest', // L'en-tête magique
        },
        credentials: 'include', // Pour envoyer les cookies
    };

    const mergedOptions = {
        ...defaultOptions,
        ...options,
        headers: { ...defaultOptions.headers, ...options.headers },
    };

    return fetch(url, mergedOptions);
}

// Utilisation
apiFetch('/api/user/delete', {
    method: 'DELETE',
    body: JSON.stringify({ userId: 42 }),
});

Côté serveur PHP :

function is_ajax_request(): bool {
    return isset($_SERVER['HTTP_X_REQUESTED_WITH'])
        && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest';
}

if (!is_ajax_request()) {
    http_response_code(403);
    die('Forbidden');
}

Combinaison token + header pour les APIs

Pour les APIs avec authentification par cookie, la meilleure pratique est de combiner les deux approches : token CSRF dans un cookie non-HttpOnly (c'est-à-dire lisible par JavaScript) + en-tête custom. C'est la technique utilisée par de nombreux frameworks :

// Lecture du token depuis le cookie
function getCsrfToken() {
    const match = document.cookie.match(/csrf_token=([^;]+)/);
    return match ? match[1] : null;
}

// Inclusion dans chaque requête sensible
async function securePost(url, data) {
    return fetch(url, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'X-CSRF-Token': getCsrfToken(),
        },
        credentials: 'include',
        body: JSON.stringify(data),
    });
}

Et les frameworks / CMS ?

La bonne nouvelle, c'est que les frameworks et CMS modernes intègrent la protection CSRF nativement.

WordPress

WordPress génère des nonces (number used once) pour protéger ses formulaires et requêtes. Ce n'est pas exactement un token CSRF au sens strict (les nonces WP sont liés à l'utilisateur et à l'action, avec une durée de vie), mais ils remplissent la même fonction.

// Dans votre template ou plugin : générer le nonce
<form method="POST">
    <?php wp_nonce_field('mon_action', 'mon_nonce'); ?>
    <button type="submit">Valider</button>
</form>
// Dans le handler : vérifier le nonce
function handle_mon_formulaire() {
    if (!isset($_POST['mon_nonce']) 
        || !wp_verify_nonce($_POST['mon_nonce'], 'mon_action')) {
        wp_die('Sécurité : requête invalide.');
    }

    // Traitement sécurisé
}
add_action('admin_post_mon_action', 'handle_mon_formulaire');

Pour AJAX avec WordPress, même si c'est un peu ancien :

// wp_localize_script vous donne accès à ajaxurl et au nonce
jQuery.post(ajaxurl, {
    action: 'mon_action_ajax',
    nonce: monScript.nonce, // passé via wp_localize_script
    data: 'mes données',
});
// Côté serveur WordPress + AJAX
add_action('wp_ajax_mon_action_ajax', function() {
    check_ajax_referer('mon_action', 'nonce');
    // Traitement...
    wp_send_json_success(['message' => 'OK']);
});

Laravel

Laravel inclut automatiquement la vérification CSRF pour toutes les routes POST, PUT, PATCH et DELETE grâce au middleware VerifyCsrfToken. Il suffit d'inclure la directive dans vos formulaires Blade :

<form method="POST" action="/profil">
    @csrf
    <!-- vos champs -->
</form>

La directive @csrf génère un champ caché avec le token. Pour les requêtes AJAX, Laravel recommande d'inclure le token dans un meta tag et de le lire en JS :

<meta name="csrf-token" content="{{ csrf_token() }}">
// Configuration globale pour Axios
axios.defaults.headers.common['X-CSRF-TOKEN'] = 
    document.querySelector('meta[name="csrf-token"]').getAttribute('content');

Symfony

Symfony propose le composant Security avec le CSRF intégré. Dans un formulaire Symfony :

use Symfony\Component\Form\Extension\Core\Type\FormType;

$form = $this->createFormBuilder($data)
    ->add(/* vos champs */)
    ->getForm();
// Le token CSRF est ajouté automatiquement si csrf_protection est activé (par défaut)

Pour une vérification manuelle :

use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Security\Csrf\CsrfToken;

public function action(Request $request, CsrfTokenManagerInterface $csrfTokenManager): Response
{
    $token = new CsrfToken('mon_action', $request->request->get('_token'));

    if (!$csrfTokenManager->isTokenValid($token)) {
        throw $this->createAccessDeniedException('Token CSRF invalide.');
    }

    // Traitement sécurisé
}

L'attribut SameSite

Depuis quelques années, les navigateurs supportent l'attribut SameSite sur les cookies. C'est une couche de protection supplémentaire contre le CSRF, mais pas un remplacement de la vérification côté serveur.

// En PHP, définition d'un cookie sécurisé
setcookie('session', $session_id, [
    'expires'  => time() + 3600,
    'path'     => '/',
    'secure'   => true,       // HTTPS uniquement
    'httponly' => true,       // Inaccessible en JS
    'samesite' => 'Strict',   // Ne jamais envoyer en cross-site
]);

Les trois valeurs possibles :

Valeur Comportement
Strict Le cookie n'est jamais envoyé dans les requêtes cross-site, même en naviguant depuis un lien externe.
Lax Le cookie est envoyé lors des navigations "de haut niveau" (clic sur un lien), mais pas dans les sous-requêtes (images, iframes, formulaires POST). C'est la valeur par défaut dans les navigateurs modernes.
None Le cookie est toujours envoyé (comportement historique). Requiert l'attribut Secure.

Lax (qui vient d'une traduction de "relâché" ou "relaxé" et non pas de laxatif) est un bon compromis pour la plupart des cas. Strict est plus sécurisé mais peut casser certains flux (par exemple, un lien depuis un email vers votre app qui vous redirige vers votre tableau de bord connecté).

⚠️ SameSite=Lax réduit les possibilités d'attaques, mais ne couvre pas tous les cas ; la vérification du token reste nécessaire.

Ce qu'il ne faut pas faire

👎 Vérifier l'en-tête Referer ou Origin uniquement. Ces en-têtes peuvent être absents (certains proxys les suppriment) ou, dans des cas rares, manipulés. Ils peuvent servir de vérification complémentaire, au mieux.

👎 Stocker le token en localStorage et l'envoyer en query string. Le localStorage est accessible par n'importe quel script sur le domaine, et un token en query string se retrouve dans les logs serveur, l'historique du navigateur, les en-têtes Referer... bref, beaucoup trop d'endroits.

👎 Générer un token non aléatoire. Un token basé sur l'ID utilisateur, le timestamp, ou un hash prévisible n'est pas vraiment un token. Si quelqu'un peut le calculer, la protection ne sert à rien.

Références

Rodolphe fait partie de l'agence Alsacréations, contactez-nous pour des projets de qualité !

Commenter

Vous devez être inscrit et identifié pour utiliser cette fonction.

Connectez-vous

Oubli de mot de passe ? Pas de panique, on va le retrouver

Pas encore inscrit ? C'est très simple et gratuit.