CSRF et SameSite Cookie : Mécanismes de défense dans Spring Security

1. Vue d’ensemble

CSRF (Cross-Site Request Forgery) est l’une des vulnérabilités web classiques mais qui prête encore à confusion : beaucoup pensent qu’il suffit d’activer SameSite=Strict pour se protéger, et que les jetons CSRF ne sont plus nécessaires. Cet article explique le mécanisme des attaques CSRF, le vrai rôle du cookie SameSite, et comment configurer correctement la protection CSRF dans Spring Security. Après cette lecture, vous saurez quand utiliser un jeton CSRF, quand SameSite suffit, et comment écrire des tests de vérification.

2. Comment fonctionne CSRF

Supposons que vous ayez un PaymentService avec un endpoint de transfert d’argent, authentifié par cookie de session :

@RestController
@RequestMapping("/api/payments")
public class PaymentController {

    private final PaymentService paymentService;

    public PaymentController(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    @PostMapping("/transfer")
    public ResponseEntity<Void> transfer(@RequestParam String toAccount,
                                          @RequestParam BigDecimal amount,
                                          HttpServletRequest request) {
        String customerId = (String) request.getSession().getAttribute("customerId");
        paymentService.transfer(customerId, toAccount, amount);
        return ResponseEntity.ok().build();
    }
}

Si le customer est déjà connecté (le navigateur possède le cookie de session), une page malveillante evil.com n’a qu’à intégrer ce formulaire :

<form action="https://bank.example.com/api/payments/transfer" method="POST">
  <input type="hidden" name="toAccount" value="attacker-account">
  <input type="hidden" name="amount" value="5000">
</form>
<script>document.forms[0].submit();</script>

Lorsque le customer ouvre evil.com, le navigateur envoie automatiquement le cookie de bank.example.com avec la requête — car le navigateur ne fait attention qu’au domaine de destination, pas à l’origine de la requête. Le serveur reçoit une requête valide du point de vue de la session et exécute le transfert d’argent. C’est l’essence de CSRF : exploiter le fait que le navigateur envoie automatiquement les credentials (cookies) avec la requête, peu importe d’où elle provient.

Note : CSRF n’est dangereux que pour les mécanismes d’authentification « ambiants » comme les cookies. Si vous utilisez un header Authorization: Bearer <token> et que le client doit le définir lui-même (JavaScript ne peut pas le faire cross-site en raison de CORS), CSRF classique ne s’applique pas.

SameSite est un drapeau (flag) sur le cookie, qui indique au navigateur : envoyer ce cookie avec une requête cross-site ou non. Il existe 3 valeurs :

Valeur Comportement
Strict Le cookie n’est envoyé que pour les requêtes du même site (navigation first-party). Pas d’envoi même si l’utilisateur clique sur un lien depuis un autre site.
Lax Le cookie est envoyé lors d’une navigation GET de premier niveau (l’utilisateur clique sur un lien), mais n’est pas envoyé avec POST/requêtes depuis un autre site.
None Le cookie est toujours envoyé cross-site, doit être accompagné de Secure.

Configuration dans Spring Boot via application.yml :

server:
  servlet:
    session:
      cookie:
        same-site: lax
        secure: true
        http-only: true

Avec same-site: lax, la requête CSRF de l’exemple précédent (envoi auto du formulaire POST depuis evil.com) n’emportera pas le cookie de session — les navigateurs modernes le bloquent. C’est pourquoi Lax est devenu le défaut de Chrome depuis 2020 et peut bloquer la plupart des attaques CSRF de type form-POST.

3.1. Pourquoi SameSite n’est pas suffisant

SameSite n’est pas une solution complète pour plusieurs raisons :

  • Prise de contrôle de sous-domaine : si votre site dispose de blog.example.com qui est compromis, il est toujours considéré comme « same site » avec app.example.com — le cookie peut toujours être envoyé.
  • Navigateurs anciens : certains navigateurs/versions ne supportent pas entièrement SameSite, le traitent comme absent et se rabattent sur l’envoi de cookies comme avant.
  • Requête GET avec effets secondaires : si votre endpoint permet accidentellement à GET d’effectuer des actions de modification de données (anti-pattern mais qui existe toujours), Lax permet toujours à une navigation GET de premier niveau d’emporter le cookie.
// Anti-pattern : GET avec effets secondaires, Lax ne peut pas bloquer
@GetMapping("/orders/{id}/cancel")
public ResponseEntity<Void> cancelOrder(@PathVariable Long id) {
    orderService.cancel(id); // dangereux s'il est exposé via <img src="...">
    return ResponseEntity.ok().build();
}

Par conséquent, SameSite doit être considéré comme une couche de défense supplémentaire (defense in depth), non pas un remplacement du jeton CSRF.

4. Jeton CSRF : mécanisme de protection principal

Le jeton CSRF fonctionne selon le principe double-submit / synchronizer token : le serveur génère un jeton aléatoire, l’attache à la session ou à un cookie séparé, et exige que toute requête modifiant des données (POST/PUT/DELETE) envoie ce jeton dans le corps ou l’en-tête. Un attaquant sur evil.com ne peut pas lire le jeton (la politique same-origin empêche la lecture des réponses/cookies d’un domaine différent), donc ne peut pas forger une requête valide.

4.1. Ajouter Spring Security

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

Par défaut, Spring Security 6.x active la protection CSRF pour toutes les requêtes autres que GET/HEAD/OPTIONS/TRACE.

4.2. Configuration CSRF avec un référentiel de jetons basé sur les cookies

Avec une SPA (React, Vue) appelant une API via AJAX, utilisez CookieCsrfTokenRepository pour que JS lise le jeton du cookie puis l’ajoute à l’en-tête :

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
            )
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/payments/**").authenticated()
                .anyRequest().permitAll()
            );
        return http.build();
    }
}

Explication :

  • withHttpOnlyFalse() : le cookie XSRF-TOKEN doit être lisible par JS (document.cookie), donc ne peut pas être HttpOnly. C’est un compromis intentionnel — ce jeton n’est pas aussi sensible qu’un cookie de session, sa perte n’est pas suffisante pour une attaque si l’attaquant ne peut pas le lire cross-site.
  • CsrfTokenRequestAttributeHandler : gère le chargement lazy du jeton, évite de devoir rendre le jeton sur la première requête (optimisé pour les SPA).

Côté client, chaque requête doit lire le cookie XSRF-TOKEN et définir l’en-tête X-XSRF-TOKEN :

function getCsrfToken() {
  return document.cookie
    .split('; ')
    .find(row => row.startsWith('XSRF-TOKEN='))
    ?.split('=')[1];
}

fetch('/api/payments/transfer', {
  method: 'POST',
  headers: { 'X-XSRF-TOKEN': getCsrfToken(), 'Content-Type': 'application/json' },
  body: JSON.stringify({ toAccount: 'ACC123', amount: 100 })
});

Un attaquant sur evil.com ne peut pas lire le cookie XSRF-TOKEN de bank.example.com (politique same-origin), donc ne peut pas définir correctement l’en-tête. L’envoi automatique de formulaire ne peut pas définir d’en-tête personnalisé, donc la requête est bloquée même avec SameSite=None.

4.3. Configuration avec des formulaires traditionnels (rendu côté serveur)

Si vous utilisez Thymeleaf, le jeton est rendu directement dans le formulaire :

<form th:action="@{/api/orders}" method="post">
  <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
  <input type="text" name="productName"/>
  <button type="submit">Créer une commande</button>
</form>

Spring MVC injecte automatiquement _csrf dans le modèle grâce à CsrfRequestDataValueProcessor, sans code supplémentaire.

5. Combiner SameSite et jeton CSRF : défense en profondeur

Ces deux couches résolvent des problèmes différents et doivent être utilisées ensemble :

server:
  servlet:
    session:
      cookie:
        same-site: lax
        secure: true
        http-only: true
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.csrf(csrf -> csrf
        .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
    );
    return http.build();
}
  • SameSite=Lax sur le cookie de session bloque la plupart des requêtes cross-site directement au niveau du navigateur, avant que la requête ne touche le serveur.
  • Le jeton CSRF bloque les cas que SameSite ne couvre pas (navigateurs anciens, compromission de sous-domaine, ou vous utilisez intentionnellement SameSite=None car vous avez besoin de supporter l’intégration cross-site).

6. Écrire des tests pour vérifier la protection CSRF

Utilisez MockMvc avec spring-security-test pour vérifier qu’un endpoint est bloqué en l’absence de jeton :

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
</dependency>
@WebMvcTest(OrderController.class)
@Import(SecurityConfig.class)
class OrderControllerCsrfTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void shouldRejectPostWithoutCsrfToken() throws Exception {
        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"productName\":\"Laptop\"}"))
            .andExpect(status().isForbidden());
    }

    @Test
    void shouldAcceptPostWithValidCsrfToken() throws Exception {
        mockMvc.perform(post("/api/orders")
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"productName\":\"Laptop\"}"))
            .andExpect(status().isOk());
    }
}

Utilisez AssertJ pour la logique métier non liée à la couche HTTP, par exemple pour vérifier que le jeton est généré aléatoirement à chaque session :

@Test
void csrfTokenShouldBeUniquePerSession() {
    CsrfTokenRepository repository = new HttpSessionCsrfTokenRepository();
    CsrfToken token1 = repository.generateToken(new MockHttpServletRequest());
    CsrfToken token2 = repository.generateToken(new MockHttpServletRequest());

    assertThat(token1.getToken()).isNotEqualTo(token2.getToken());
}
  • Le premier test confirme qu’une requête POST sans jeton reçoit 403 Forbidden — le comportement par défaut de Spring Security.
  • with(csrf()) est un helper de spring-security-test, qui ajoute automatiquement un jeton valide à la requête simulée.
  • Le troisième test assure que le jeton n’est pas réutilisé ou prévisible entre les sessions.

7. Erreurs courantes et quand ne pas utiliser le jeton CSRF

Erreur 1 — Désactiver CSRF globalement pour une API « pour faciliter les tests » :

// Anti-pattern : ne faites pas cela en production
http.csrf(csrf -> csrf.disable());

C’est justifié uniquement si votre API est complètement stateless — l’authentification utilise JWT dans l’en-tête Authorization, pas de cookies d’authentification. Dans ce cas, il n’y a pas de « credentials ambiants » que l’attaquant puisse exploiter.

Erreur 2 — Utiliser SameSite=None sans comprendre les conséquences :

server:
  servlet:
    session:
      cookie:
        same-site: none  # Nécessaire pour les iframes cross-site, mais réintroduit le risque CSRF
        secure: true

Si vous êtes obligé d’utiliser None (par exemple, intégrer l’app dans une iframe d’un tiers), vous devez avoir un jeton CSRF, ne le saltez pas.

Erreur 3 — Appliquer CSRF aux requêtes GET avec effets secondaires :

Ne concevez pas GET pour supprimer/mettre à jour des données. Le filtre CSRF de Spring Security ne protège par défaut pas GET (car GET est considéré comme une méthode sûre selon RFC 7231), donc si vous violez les principes REST, vous créez vous-même une vulnérabilité.

Note : Si votre backend sert à la fois une webapp (basée sur cookies) et une API mobile/tiers (basée sur tokens) sur le même domaine, séparez la configuration CSRF selon RequestMatcher — n’activez CSRF que pour les chemins utilisant les cookies, désactivez-le pour les chemins utilisant les tokens Bearer purs.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.csrf(csrf -> csrf
        .ignoringRequestMatchers("/api/mobile/**") // Authentification par JWT, pas de cookies
    );
    return http.build();
}

8. Conclusion

Le jeton CSRF et le cookie SameSite résolvent deux aspects différents du même problème : empêcher le navigateur d’envoyer automatiquement les credentials avec une requête malveillante d’un site différent. SameSite est une couche de défense au niveau du navigateur, simple à activer mais pas exhaustive. Le jeton CSRF est une couche de défense au niveau de l’application, plus fiable mais nécessite du code supplémentaire côté serveur et client. Pour les systèmes utilisant des cookies de session, utilisez les deux ensemble ; pour une API stateless authentifiée par jeton Bearer, vous pouvez supprimer le filtre CSRF mais conserver SameSite pour prévenir d’autres risques liés aux cookies.

web-securitycsrfspring-securitycookiespring-boot