• CSRF no Spring Security

    Depois que escrevi sobre CSRF no Laravel, veio a pergunta óbvia: e no Spring, tem isso? Tem. E a resposta curta é que o Spring Security protege contra CSRF por padrão, exatamente como o Laravel. Você não precisa escrever nenhuma linha pra isso funcionar.

    A resposta longa é mais interessante, porque o Spring resolve o mesmo problema com duas peças a mais que o Laravel não tem, e são justamente essas duas peças que quebram a cabeça de quem está montando uma SPA.

    #Relembrando o ataque em uma frase

    Se você não leu o post anterior, o resumo é esse: o navegador anexa o cookie de sessão em toda requisição para um domínio, não importa de qual site ela partiu. Então um site malicioso consegue disparar um POST para a sua aplicação, e o servidor recebe uma requisição perfeitamente autenticada que o usuário nunca quis fazer.

    A defesa, em qualquer framework, é a mesma ideia: exigir em toda requisição que altera estado um token secreto que só a sua aplicação conhece e que o site do atacante não tem como ler.

    #Quem faz o trabalho no Spring

    No Laravel, o middleware é o VerifyCsrfToken. No Spring Security, é o CsrfFilter, que já entra na filter chain assim que você adiciona o spring-boot-starter-security no projeto.

    Você pode declarar a configuração padrão explicitamente, mas ela é exatamente o que já acontece sem você fazer nada:

     1@Configuration
     2@EnableWebSecurity
     3public class SecurityConfig {
     4
     5    @Bean
     6    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
     7        http
     8            // ...
     9            .csrf(Customizer.withDefaults());
    10
    11        return http.build();
    12    }
    13}
    

    O CsrfFilter faz basicamente três coisas: verifica se a requisição precisa de proteção, carrega o token esperado, e compara com o token que o cliente mandou. Igual ao Laravel, a verificação só vale para métodos que alteram estado. GET, HEAD, TRACE e OPTIONS passam direto.

    Quando o token não bate ou não vem, o Spring lança uma InvalidCsrfTokenException (ou MissingCsrfTokenException), que são subclasses de AccessDeniedException. Na prática, você recebe um 403 Forbidden. É o "419 Page Expired" do Laravel com outro número na frente.

    #Onde o token fica guardado

    O Laravel guarda o token na sessão e ponto. O Spring te dá a escolha através da interface CsrfTokenRepository.

    O padrão é o HttpSessionCsrfTokenRepository: token na HttpSession, mesma estratégia do Laravel.

    A alternativa é o CookieCsrfTokenRepository, que grava o token num cookie chamado XSRF-TOKEN e o lê de volta do header X-XSRF-TOKEN ou do parâmetro _csrf. Esses nomes vêm do Angular, que já os envia automaticamente:

     1http
     2    .csrf((csrf) -> csrf
     3        .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
     4    );
    

    Repara no withHttpOnlyFalse(). Ele é necessário justamente pra que o JavaScript consiga ler esse cookie. Se a sua aplicação não precisa disso, use new CookieCsrfTokenRepository() e deixe o cookie como HttpOnly.

    Aqui vale uma pausa, porque a pergunta aparece sempre: guardar o token num cookie não anula a proteção, já que o navegador manda cookie sozinho? Não. O ataque continua barrado porque o atacante precisa ler o valor do cookie pra copiá-lo no header, e a Same-Origin Policy impede que ele leia cookies do seu domínio. O navegador até envia o cookie sozinho, mas não envia o header. E é o header que o filtro compara.

    #No formulário

    No Laravel você escreve @csrf. No Spring, se você usa Thymeleaf, não escreve nada:

     1<form th:action="@{/perfil}" method="post">
     2    <input type="text" name="nome">
     3    <button>Salvar</button>
     4</form>
    

    Usando th:action, o Thymeleaf injeta sozinho o campo escondido:

     1<input type="hidden" name="_csrf" value="4bfd1575-3ad1-4d21-96c7-4ef2d9f86721">
    

    Isso funciona porque o Thymeleaf se integra com o RequestDataValueProcessor do Spring, e o Spring Security fornece a implementação CsrfRequestDataValueProcessor. A form tag library do Spring MVC faz o mesmo.

    O detalhe que pega gente desatenta: a mágica só acontece com th:action. Se você escrever action="/perfil" na mão, o Thymeleaf não tem como interceptar nada, e o seu POST vai tomar 403.

    Se a sua view não tiver essa integração, o token está exposto como um atributo da request chamado _csrf, e você o insere manualmente:

     1<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
    

    #A primeira peça a mais: proteção contra BREACH

    Aqui o Spring passa a divergir do Laravel.

    A partir do Spring Security 6, o handler padrão é o XorCsrfTokenRequestAttributeHandler. O que ele faz é embaralhar o token com um valor aleatório antes de renderizá-lo, de forma que o valor entregue na resposta muda a cada requisição, mesmo que o token persistido na sessão continue o mesmo. Quando o token volta, ele é decodificado e comparado com o valor real.

    O motivo é o BREACH, um ataque que explora compressão HTTP: observando o tamanho de respostas comprimidas, dá pra ir adivinhando um segredo que se repete no corpo delas. Se o token nunca muda, ele é um alvo estável. Com a randomização, não é.

    O Laravel não faz isso. E na maior parte do tempo essa diferença é invisível — até você tentar montar uma SPA.

    #A segunda peça a mais: carregamento adiado

    O Spring Security 6 também passou a carregar o token de forma adiada (deferred). A ideia é performance: se a requisição não precisa de verificação, não faz sentido tocar na sessão só pra montar um token que ninguém vai usar.

    O efeito colateral é que, se nada na sua requisição "puxar" o token, ele nunca é gerado — e o cookie XSRF-TOKEN que a sua SPA está esperando simplesmente não aparece na resposta.

    #Juntando as duas: por que a SPA quebra

    Agora dá pra entender o problema clássico. Sua SPA lê o cookie XSRF-TOKEN e manda o valor no header X-XSRF-TOKEN. Mas o cookie contém o token cru, enquanto o handler padrão espera receber o valor embaralhado. Resultado: 403 em toda requisição, com um token que parece certíssimo quando você olha no DevTools.

    Some a isso o carregamento adiado e o fato de que o CsrfAuthenticationStrategy e o CsrfLogoutHandler limpam o token depois do login e do logout — ou seja, logo depois de autenticar, sua SPA fica sem cookie válido justamente quando vai começar a fazer POSTs.

    No Spring Security 6, a receita oficial é um handler híbrido: se o token veio num header, resolve como valor cru; se veio como parâmetro de formulário, resolve como valor embaralhado. E força o carregamento do token pra que o cookie sempre saia na resposta.

     1final class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler {
     2
     3    private final CsrfTokenRequestHandler plain = new CsrfTokenRequestAttributeHandler();
     4    private final CsrfTokenRequestHandler xor = new XorCsrfTokenRequestAttributeHandler();
     5
     6    @Override
     7    public void handle(HttpServletRequest request, HttpServletResponse response, Supplier<CsrfToken> csrfToken) {
     8        this.xor.handle(request, response, csrfToken);
     9        // força o token adiado a ser carregado, garantindo o cookie na resposta
    10        csrfToken.get();
    11    }
    12
    13    @Override
    14    public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
    15        String headerValue = request.getHeader(csrfToken.getHeaderName());
    16        return (StringUtils.hasText(headerValue) ? this.plain : this.xor)
    17            .resolveCsrfTokenValue(request, csrfToken);
    18    }
    19}
    

    E na configuração:

     1http
     2    .csrf((csrf) -> csrf
     3        .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
     4        .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())
     5    );
    

    Se você está no Spring Security 7, esse bloco inteiro virou uma linha:

     1http.csrf((csrf) -> csrf.spa());
    

    Guarde essa: é o tipo de coisa que custa uma tarde inteira de depuração pra quem não sabe que a randomização existe.

    #E numa aplicação multipágina?

    Se o seu front é JavaScript espalhado sobre páginas renderizadas no servidor, você não precisa de cookie nenhum. Dá pra jogar o token em meta tags, do mesmo jeito que se faz no Laravel:

     1<meta name="_csrf" th:content="${_csrf.token}">
     2<meta name="_csrf_header" th:content="${_csrf.headerName}">
    

    E lê de lá na hora de montar a requisição. Como o token vai renderizado no HTML pelo handler padrão, ele já sai embaralhado corretamente, e o problema da seção anterior nem existe.

    #E as APIs? Precisam de token CSRF?

    Mesma resposta do Laravel: APIs stateless autenticadas por token — JWT, OAuth2 com bearer token — não precisam. O ataque depende de o navegador anexar a credencial sozinho, e ele não faz isso com o header Authorization.

    A diferença é que o Laravel já separa isso pra você, deixando as rotas de API fora do middleware web. No Spring, é você quem decide:

     1http.csrf((csrf) -> csrf.disable());
    

    Só que disable() desliga tudo. Se a sua aplicação serve as duas coisas — páginas com sessão por cookie e uma API com bearer token — desligar de forma global abre um buraco real na parte que usa sessão. Nesse caso, o certo é isentar apenas o que precisa:

     1http.csrf((csrf) -> csrf
     2    .ignoringRequestMatchers("/api/**")
     3);
    

    Ou, melhor ainda, separar em duas SecurityFilterChain com securityMatcher, uma para a API e outra para o app web. Aí cada uma carrega a política que faz sentido pra ela.

    Vale dizer: csrf.disable() é provavelmente a linha mais copiada e colada do ecossistema Spring. Ela aparece em todo tutorial de "como fazer login com JWT", e muita gente leva ela pra dentro de aplicações que continuam usando sessão. Se você tem JSESSIONID no navegador, não desligue.

    #Laravel x Spring, lado a lado

    Fechando o paralelo entre os dois frameworks:

    • Ligado por padrão: nos dois. Nem Laravel nem Spring Security exigem configuração pra proteger.
    • Componente que verifica: VerifyCsrfToken no Laravel, CsrfFilter no Spring.
    • Onde o token fica: sessão no Laravel; sessão (padrão) ou cookie no Spring.
    • No formulário: @csrf no Blade; th:action no Thymeleaf, que injeta o campo sozinho.
    • Nome do campo: _token no Laravel, _csrf no Spring.
    • Header aceito: X-CSRF-TOKEN no Laravel; X-CSRF-TOKEN ou X-XSRF-TOKEN no Spring.
    • Erro quando falha: 419 Page Expired no Laravel, 403 Forbidden no Spring.
    • Token muda a cada requisição: só no Spring, por causa da proteção contra BREACH.
    • Isentar rotas: deixar fora do middleware web no Laravel; ignoringRequestMatchers() no Spring.

    #Resumindo

    Sim, o Spring tem o equivalente ao @csrf, e vem ligado sem você pedir. Num app tradicional com Thymeleaf, a experiência é tão transparente quanto no Laravel: você usa th:action e esquece que o assunto existe.

    O que muda é a camada extra. O Spring embaralha o token a cada resposta pra se defender do BREACH e só o carrega quando alguém precisa dele. As duas decisões são boas do ponto de vista de segurança e performance, mas explicam quase todo 403 misterioso que aparece quando se conecta uma SPA a um backend Spring. Sabendo disso, o conserto é uma configuração — no Spring Security 7, literalmente csrf.spa().

    E se a sua vontade for resolver tudo com csrf.disable(), para e pergunta antes: essa aplicação autentica alguém por cookie de sessão? Se a resposta for sim, você não está resolvendo o problema, está só apagando o alarme.

    profile image of Lucien Risso Coreia

    Lucien Risso Coreia

    Engenheiro de Software, entusiasta de tecnologia e criador de conteúdo.

    Mais posts de Lucien Risso Coreia