CSRF (Cross-Site Request Forgery), também chamado de session riding, explora um detalhe simples de como a web funciona: o navegador anexa automaticamente os cookies da vítima a qualquer requisição para um domínio, não importa de onde a requisição saia. Se a sua aplicação autoriza uma ação que muda estado confiando só no cookie de sessão, um site malicioso consegue disparar essa ação em nome da vítima, sem nunca ver o cookie e sem precisar dele.

Basta que o usuário esteja autenticado na sua aplicação e abra uma página controlada pelo atacante. Ele não vê nada acontecer.

A anatomia do ataque

Imagine uma aplicação que troca o e-mail da conta com este formulário:

<form action="https://app.exemplo.com/conta/email" method="POST">
  <input name="email" value="novo@usuario.com" />
  <button>Salvar</button>
</form>

Se o servidor aceitar esse POST validando apenas o cookie de sessão, o atacante hospeda a seguinte página:

<!-- pagina-do-atacante.com -->
<form id="x" action="https://app.exemplo.com/conta/email" method="POST">
  <input type="hidden" name="email" value="atacante@evil.com" />
</form>
<script>document.getElementById('x').submit();</script>

Quando a vítima autenticada abre essa página, o formulário é enviado sozinho. O navegador anexa o cookie de sessão de app.exemplo.com, o servidor vê uma requisição “válida” e troca o e-mail da conta para o do atacante. Daí ele dispara um “esqueci minha senha” e assume a conta.

Note que o atacante nunca leu o cookie. Ele só fez o navegador da vítima usá-lo por ele.

CSRF via GET é ainda mais fácil

Se uma ação que muda estado for exposta via GET, nem formulário é preciso. Uma imagem qualquer já dispara o ataque:

<img src="https://app.exemplo.com/conta/excluir?confirm=true" />

Daí a regra de ouro: GET nunca deve alterar estado. Requisições GET precisam ser seguras e idempotentes.

Defesa 1: tokens anti-CSRF

A proteção clássica é exigir, em toda requisição que muda estado, um valor secreto que o atacante não tem como conhecer nem adivinhar.

Synchronizer token pattern

O servidor gera um token aleatório por sessão (ou por requisição), incorpora-o no formulário e o valida no recebimento:

<form action="/conta/email" method="POST">
  <input type="hidden" name="csrf_token" value="b7f3c1e9a4d28f6e..." />
  <input name="email" value="novo@usuario.com" />
  <button>Salvar</button>
</form>
# Validação no servidor (pseudo-Python)
def handle_post(request, session):
    token_form = request.form.get("csrf_token")
    token_session = session.get("csrf_token")
    if not token_session or not constant_time_compare(token_form, token_session):
        abort(403)  # rejeita
    update_email(request.form["email"])

Como o site do atacante não consegue ler o token (a same-origin policy impede a leitura da resposta de outra origem), ele fica sem como preencher o campo. Use comparação em tempo constante para não vazar o token por timing.

Variante sem estado no servidor: o token vai tanto em um cookie quanto em um campo do formulário (ou cabeçalho), e o servidor confere se os dois batem. Funciona porque o atacante não consegue ler nem ajustar o cookie de outra origem. Um cuidado: a técnica fica frágil se subdomínios não confiáveis puderem gravar cookies no domínio pai. Nesse cenário, assine o token com HMAC para impedir a forja.

Defesa 2: cookies SameSite

O atributo SameSite diz ao navegador para não enviar o cookie em requisições cross-site, o que ataca a raiz do CSRF:

Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax
  • SameSite=Lax (padrão moderno na maioria dos navegadores): o cookie acompanha navegação de topo via GET, mas não vai em POST cross-site nem em sub-requisições (imagens, iframes). Isso já neutraliza a maioria dos vetores. Confira se Secure, HttpOnly e SameSite estão setados nos seus cookies com o Analisador de Security Headers.
  • SameSite=Strict: o cookie nunca é enviado em contexto cross-site, nem em links. É mais seguro, mas pode atrapalhar a UX, porque quem chega de um link externo aparece deslogado.
  • SameSite=None; Secure: necessário para cenários cross-site legítimos, como SSO e widgets. É justamente o modo que reabre a porta para CSRF e volta a exigir tokens.

SameSite é uma ótima camada de defesa em profundidade, mas não substitui tokens. Navegadores antigos não o respeitam, e os modos têm nuances entre si. Use os dois.

Defesa 3: verificação de Origin / Referer

Em requisições que mudam estado, o servidor pode conferir se o cabeçalho Origin (ou, na ausência, Referer) corresponde à própria aplicação:

ALLOWED = {"https://app.exemplo.com"}

def is_same_origin(request):
    origin = request.headers.get("Origin") or request.headers.get("Referer")
    return origin and any(origin.startswith(o) for o in ALLOWED)

É uma defesa robusta e barata para APIs, mas trate a ausência do cabeçalho com cuidado, porque alguns proxies o removem. Combine com token em vez de confiar só nela.

Erros comuns que vemos em pentests

  • Token presente, mas não validado: o campo existe no HTML, só que o servidor nunca o confere. É segurança de mentira.
  • Token global, não atrelado à sessão: um token estático compartilhado entre usuários serve tão pouco quanto nenhum.
  • Apenas endpoints “óbvios” protegidos: o formulário de troca de senha tem token, mas o endpoint de API JSON que faz a mesma coisa não tem.
  • Aceitar Content-Type arbitrário: APIs que processam application/json mas também aceitam application/x-www-form-urlencoded deixam um form HTML simples disparar a ação.
  • GET que muda estado: logout, exclusão e toggles via GET continuam aparecendo o tempo todo.

Como testamos CSRF

  1. Identificar todas as ações que mudam estado (não só formulários: também endpoints de API).
  2. Para cada uma, remover/alterar o token e verificar se a requisição ainda é aceita.
  3. Inspecionar SameSite nos cookies de sessão e o tratamento de Origin/Referer.
  4. Montar uma PoC cross-site real (form auto-submit ou fetch) e confirmar a execução com a sessão da vítima.
  5. Avaliar impacto pela criticidade da ação sequestrável.

Checklist de mitigação

  • Token anti-CSRF por sessão, validado em toda ação que muda estado (web e API).
  • Comparação de token em tempo constante.
  • Cookies de sessão com SameSite=Lax (ou Strict) + HttpOnly + Secure.
  • Verificação de Origin/Referer em endpoints sensíveis.
  • Nenhuma ação que muda estado via GET.
  • Para SPAs/APIs: exigir cabeçalho custom (ex.: X-CSRF-Token) que o cross-site não consegue setar sem CORS.
  • Reautenticação em operações críticas (troca de e-mail, senha, MFA).

CSRF nos lembra de que “estar autenticado” e “ter consentido” são coisas diferentes. Uma defesa madura empilha token, SameSite e checagem de origem, de modo que nenhuma falha isolada consiga reabrir a porta sozinha.