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.
3. Atributo SameSite de cookie
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” queapp.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),
Laxainda 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.
4.2. Configurar CSRF com repositório de token baseado em cookie
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 cookieXSRF-TOKENdeve ser legível por JS (document.cookie), então não pode terHttpOnly. 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=Laxno 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=Noneintencionalmente 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 despring-security-testque 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.