CSRF e SameSite Cookie: Mecanismos de defesa no Spring Security

1. Visão geral

CSRF (Cross-Site Request Forgery) é uma das vulnerabilidades web clássicas, mas ainda causa confusão: muitas pessoas pensam que basta ativar SameSite=Strict e pronto, não precisa mais de CSRF token. Este artigo explica o mecanismo de ataque CSRF, o papel real do cookie SameSite, e como configurar CSRF protection corretamente no Spring Security. Ao terminar, você saberá quando precisa de CSRF token, quando SameSite é suficiente, e como escrever testes para verificar.

2. Como o CSRF funciona

Suponha que você tenha um PaymentService com um endpoint de transferência, autenticado via session cookie:

@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();
    }
}

Se o customer já está conectado (o browser tem o session cookie), uma página maliciosa evil.com apenas precisa embutir este formulário:

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

Quando o customer acessa evil.com, o browser anexa automaticamente o cookie de bank.example.com à requisição — porque o browser só se preocupa com o domínio de destino, não com de onde a requisição foi originada. O servidor recebe a requisição como válida em termos de sessão e executa a transferência. Esta é a essência do CSRF: explorar o fato de o browser enviar automaticamente credenciais (cookie) junto com a requisição, independentemente de qual página iniciou a requisição.

Nota: CSRF é perigoso apenas com mecanismos de autenticação “ambientes” como cookies. Se você usar Authorization: Bearer <token> header e o cliente tiver que defini-lo manualmente (JS não pode fazer isso cross-site por causa do CORS), CSRF clássico não se aplica.

SameSite é uma flag em um cookie que instrui o browser: se enviar esse cookie junto com requisições cross-site ou não. Existem 3 valores:

Valor Comportamento
Strict Cookie é enviado apenas quando a requisição é do mesmo site (first-party navigation). Não é enviado nem quando o usuário clica em um link de outro site.
Lax Cookie é enviado com GET de navegação de nível superior (clique do usuário em link), mas não é enviado com POST/subrequest de outro site.
None Cookie sempre é enviado cross-site, obrigatoriamente junto com Secure.

Configure no Spring Boot via application.yml:

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

Com same-site: lax, a requisição CSRF no exemplo acima (form POST auto-submit de evil.com) não levará o session cookie — browsers modernos bloqueiam automaticamente. Esta é a razão pela qual Lax se tornou o padrão do Chrome desde 2020 e bloqueia a maioria dos ataques CSRF do tipo form-POST.

3.1. Por que SameSite não é suficiente

SameSite não é uma solução completa por alguns motivos:

  • Subdomain takeover: se um subdomínio do seu site, como blog.example.com, for comprometido, ele ainda será considerado “mesmo site” que app.example.com — o cookie ainda será enviado.
  • Browsers antigos: alguns navegadores/versões não suportam completamente SameSite, tratam como se a flag não existisse, voltando ao envio de cookie como antes.
  • GET requests com efeito colateral: se seu endpoint permitir acidentalmente que GET execute ações de escrita (anti-pattern mas ainda existe), Lax ainda permite que GET de navegação de nível superior leve o cookie.
// Anti-padrão: GET com efeito colateral, Lax não bloqueia
@GetMapping("/orders/{id}/cancel")
public ResponseEntity<Void> cancelOrder(@PathVariable Long id) {
    orderService.cancel(id); // perigoso se exposto via <img src="...">
    return ResponseEntity.ok().build();
}

Portanto, SameSite deve ser visto como uma camada de defesa adicional (defense in depth), não como substituto para CSRF token.

4. CSRF token: mecanismo de proteção principal

CSRF token funciona de acordo com o princípio double-submit / synchronizer token: o servidor gera um token aleatório, o coloca na sessão ou em um cookie separado, e exige que todas as requisições que modificam dados (POST/PUT/DELETE) enviem esse token no corpo ou no header. O atacante em evil.com não consegue ler o token (porque a same-origin policy bloqueia a leitura de response/cookie de outro domínio), portanto não consegue forjar uma requisição válida.

4.1. Adicionar Spring Security

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

Por padrão, Spring Security 6.x ativa CSRF protection para todas as requisições que não sejam GET/HEAD/OPTIONS/TRACE.

Para SPA (React, Vue) que chama API via AJAX, use CookieCsrfTokenRepository para que o JS leia o token do cookie e o coloque no header:

@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();
    }
}

Explicação:

  • withHttpOnlyFalse(): o cookie XSRF-TOKEN deve ser legível por JS (document.cookie), então não pode ter HttpOnly. Este é um trade-off intencional — este token não é tão sensível quanto o session cookie, sua perda não é suficiente para atacar se o atacante não conseguir lê-lo cross-site.
  • CsrfTokenRequestAttributeHandler: manipula o carregamento lazy do token, evitando ter que renderizar o token na primeira requisição (otimizado para SPA).

No lado do cliente, cada requisição precisa ler o cookie XSRF-TOKEN e definir o header 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 })
});

O atacante em evil.com não consegue ler o cookie XSRF-TOKEN de bank.example.com (same-origin policy), portanto não consegue definir o header corretamente. Um formulário auto-submit não consegue definir headers customizados, então a requisição é bloqueada mesmo com SameSite=None.

4.3. Configurar com formulário tradicional (server-side render)

Se usar Thymeleaf, o token é renderizado diretamente no formulário:

<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">Criar Pedido</button>
</form>

Spring MVC injeta automaticamente _csrf no modelo por meio de CsrfRequestDataValueProcessor, sem necessidade de código adicional.

5. Combinar SameSite e CSRF token: defense in depth

Essas duas camadas resolvem problemas diferentes, portanto devem ser usadas juntas:

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 no session cookie bloqueia a maioria das requisições cross-site diretamente no browser, antes que a requisição chegue ao servidor.
  • CSRF token bloqueia casos que SameSite não cobre (browsers antigos, subdomain comprometido, ou você usa SameSite=None intencionalmente porque precisa suportar embedding cross-site).

6. Escrever testes para verificar CSRF protection

Use MockMvc com spring-security-test para verificar que endpoints são bloqueados quando falta o token:

<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());
    }
}

Use AssertJ para lógica de negócio não relacionada à camada HTTP, por exemplo, verificar que o token é gerado aleatoriamente por sessão:

@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());
}
  • O primeiro teste confirma que uma requisição POST sem token recebe 403 Forbidden — comportamento padrão do Spring Security.
  • with(csrf()) é um auxiliar de spring-security-test que anexa automaticamente um token válido à requisição simulada.
  • O terceiro teste garante que o token não seja reutilizado/adivinhado entre sessões.

7. Erros comuns e quando você não precisa de CSRF token

Erro 1 — Desabilitar CSRF globalmente para API “para facilitar testes”:

// Anti-padrão: não faça isso em produção
http.csrf(csrf -> csrf.disable());

Isso é razoável apenas se sua API for completamente stateless — autentica via JWT no header Authorization, não usa cookie para autenticação. Nesse caso, não há nenhuma “credencial ambiente” para o atacante explorar.

Erro 2 — Usar SameSite=None sem entender as consequências:

server:
  servlet:
    session:
      cookie:
        same-site: none  # necessário para iframe cross-site, mas reabre risco CSRF
        secure: true

Se você precisar usar None (por exemplo, incorporar a app em um iframe de terceiros), obrigatoriamente deve ter CSRF token, não pode pular.

Erro 3 — Aplicar CSRF a GET requests com efeito colateral:

Não projete GET para excluir/atualizar dados. O filtro CSRF do Spring Security por padrão não protege GET (porque GET é considerado um método seguro segundo RFC 7231), portanto se você violar os princípios REST, você mesmo abre a vulnerabilidade.

Nota: Se seu backend serve tanto web app (baseada em cookie) quanto mobile/third-party API (baseada em token) no mesmo domínio, separe a config de CSRF por RequestMatcher — ative CSRF apenas para paths que usam cookie, desative para paths que usam Bearer token puro.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.csrf(csrf -> csrf
        .ignoringRequestMatchers("/api/mobile/**") // autenticado via JWT, não cookie
    );
    return http.build();
}

8. Conclusão

CSRF token e SameSite cookie resolvem dois aspectos diferentes do mesmo problema: impedir que o browser envie automaticamente credenciais junto com requisições maliciosas de outro site. SameSite é uma camada de defesa no nível do browser, simples de ativar mas não completa. CSRF token é uma camada de defesa no nível da aplicação, mais confiável mas requer código adicional no servidor e no cliente. Para sistemas que usam session cookie, use ambos ao mesmo tempo; para APIs stateless autenticadas com Bearer token, você pode dispensar o filtro CSRF mas ainda manter SameSite para prevenir outros riscos relacionados a cookies.

web-securitycsrfspring-securitycookiespring-boot