Pular para o conteúdo
Novo

DeepSeek V4.1 Flash grátis por 7 dias: 10M tokens por dia após qualquer recarga. Até 30/11.

Como funciona

O que é CSRF e como os cookies SameSite ajudam

CSRF usa os cookies do seu próprio navegador contra você. Veja as defesas da mais forte para a mais fraca e teste o SameSite com um laboratório Flask.

Intermediário12 min de leituraPor NotHoly

Cross-site request forgery (CSRF, falsificação de requisição entre sites) permite que uma página de um site faça o seu navegador enviar uma requisição que altera dados a outro site em que você está logado. O navegador anexa o cookie de sessão sozinho, então o outro site trata a requisição como se fosse sua. As defesas são um segredo por sessão que o atacante não consegue ler, o atributo SameSite do cookie, a checagem do Origin e a regra de que requisições GET nunca alteram estado. Este artigo cobre cada uma delas e depois roda um pequeno laboratório em Flask para você ver o cookie em movimento.

O que é CSRF

Uma sessão web normalmente vive em um cookie. Depois que você entra em bank.example, o navegador guarda um cookie para esse host e o envia em toda requisição para ele. O servidor lê o cookie, encontra a sua sessão e executa a ação.

O navegador não verifica de onde veio a requisição. Se uma página em evil.example tem um formulário que envia um POST para https://bank.example/transfer, o navegador manda esse POST com o cookie de bank.example anexado. O banco vê uma sessão válida e faz a transferência. O atacante nunca vê o seu cookie e nunca lê a resposta. O ataque só precisa que o servidor execute a ação.

A CWE-352 descreve a fraqueza assim: a aplicação executa uma ação que altera estado para um usuário sem verificar se ele quis fazer aquela requisição. A correção é permitir que o servidor distinga uma requisição feita pelo usuário de uma forjada por uma página de terceiros.

Por que isso importa

Qualquer aplicação que guarda sessão em cookie e altera dados numa requisição está exposta. As ações mais importantes são as que mudam credenciais ou configurações: trocar a senha, trocar o e-mail, criar uma chave de API, cadastrar um meio de pagamento, criar uma regra de encaminhamento. Trocar o e-mail sem autorização costuma ser o primeiro passo de uma tomada de conta, porque o atacante pode redefinir a senha pelo endereço novo.

Cookies de sessão são o padrão em muitos frameworks, e o navegador envia o cookie quer o desenvolvedor tenha pensado nisso ou não. Por isso o CSRF continua nas listas da OWASP e da CWE.

Como o ataque funciona, passo a passo

  1. A vítima entra em bank.example. O navegador guarda o cookie de sessão para esse host.
  2. A vítima abre outra página no mesmo navegador, sem sair da conta. A página pertence ao atacante.
  3. A página tem um formulário que envia um POST para https://bank.example/transfer com valores escolhidos pelo atacante. Ele pode ser enviado assim que a página carrega, então a vítima não precisa clicar em nada.
  4. O navegador envia o POST e inclui o cookie de bank.example.
  5. O servidor valida o cookie, vê um usuário logado e faz a transferência.

Repare no que o atacante não precisou fazer. Ele não roubou o cookie, não adivinhou uma senha e não leu um único byte da resposta do banco. Quem fez o trabalho foi o navegador.

Por que GET não pode alterar estado

Uma requisição GET pode ser disparada sem formulário nenhum. Uma tag <img src="https://bank.example/delete?id=7"> faz o navegador enviar um GET assim que a página renderiza. Pré-carregamento, prévias de link e crawlers também enviam GET. Se uma rota GET altera dados, o CSRF não precisa de formulário.

A regra é simples: GET e HEAD leem dados e nunca os alteram. Mudanças passam por POST, PUT, PATCH ou DELETE. A regra sozinha não é uma defesa, porque um POST também pode ser forjado, mas todas as outras defesas funcionam melhor quando ela vale.

As defesas, da mais forte para a mais fraca

1. Tokens sincronizados

O servidor cria um token aleatório para cada sessão, coloca esse token em cada formulário como campo oculto e verifica o token em toda requisição que altera estado. Uma requisição sem o token certo é recusada.

O token funciona porque a página do atacante não consegue lê-lo. A política de mesma origem impede que evil.example leia o HTML devolvido por bank.example, então o atacante não copia o campo oculto para o formulário forjado. A requisição forjada chega com o cookie e sem o token.

O token precisa ser imprevisível (use um gerador aleatório criptográfico), estar ligado à sessão do usuário e ser checado em todo método não seguro, inclusive nos endpoints que o seu JavaScript chama. A maioria dos frameworks já traz essa proteção. A cheat sheet da OWASP coloca as defesas baseadas em token em primeiro lugar por esse motivo.

Cookies de double-submit são uma variante mais fraca. O token vai num cookie e num campo da requisição, e o servidor compara os dois. Um atacante que consiga definir cookies no seu domínio, por exemplo a partir de um subdomínio irmão, consegue burlar isso. Prefira o token sincronizado quando puder manter estado no servidor.

SameSite diz ao navegador quando anexar um cookie a uma requisição que começa em outro site. O atributo tem três valores.

  • Strict envia o cookie só em requisições que começam no mesmo site. Um link vindo de um e-mail para a sua aplicação chega sem o cookie, então o usuário parece deslogado na primeira visita.
  • Lax envia o cookie em requisições do mesmo site e em navegações GET de nível superior vindas de outro site, como ao clicar em um link. Não envia o cookie em POST entre sites, nem em requisições que carregam imagens, frames e scripts de outro site. Lax bloqueia o ataque clássico com formulário.
  • None envia o cookie também em requisições entre sites. Exige o atributo Secure, então o cookie só trafega por HTTPS. Use None apenas quando um fluxo real entre sites precisa do cookie, como um widget de terceiros incorporado.

A MDN documenta o atributo e seus valores na referência de Set-Cookie. O rascunho RFC 6265bis constrói "site" sobre o domínio registrável (o sufixo público mais o rótulo à esquerda, ou seja, site.example para www.site.example). Número de porta não conta. Subdomínios contam como o mesmo site. Isso importa no laboratório, porque localhost e 127.0.0.1 são hosts diferentes e, portanto, sites diferentes.

SameSite é forte, mas não é completo. Um subdomínio irmão como forum.bank.example é do mesmo site que bank.example, então Lax não impede um ataque partindo de lá. Navegadores antigos que ignoram o atributo enviam o cookie em qualquer lugar. E Lax ainda envia o cookie em navegações GET de nível superior, por isso a regra do GET continua valendo. Trate SameSite como uma camada.

3. Checagem de Origin e Referer

Os navegadores enviam o cabeçalho Origin em requisições POST e na maioria das requisições entre sites. Ele traz o esquema, o host e a porta da página que iniciou a requisição, então o servidor pode compará-lo com a própria origem. Sec-Fetch-Site dá um sinal parecido, com valores como same-origin, same-site e cross-site.

A checagem é simples. Recuse requisições que alteram estado quando Origin existe e não bate com a sua origem. Se Origin não existir, use Referer. Se os dois faltarem numa requisição que altera estado, recuse. Interprete o cabeçalho como URL e compare a origem completa. Comparação por substring falha: Origin: https://bank.example.evil.test contém o seu domínio, mas pertence a outra pessoa.

Checagens de origem pegam requisições entre sites evidentes, mas dependem de cabeçalhos que alguns clientes omitem e que configurações de privacidade podem remover. Use como um filtro e mantenha um token para os casos em que os cabeçalhos não aparecem.

4. Nunca alterar estado no GET

Esta é uma regra, não um controle que você testa com uma única requisição. Mantenha a regra porque ela faz todas as outras defesas cobrirem mais terreno. Uma rota que altera estado só em POST dá ao atacante um formulário para forjar, não uma tag de imagem para plantar.

Você precisa de Python 3.12 e de três janelas de terminal. A execução deste artigo usou Python 3.12.10 no Windows 11 com Git Bash. O laboratório tem um app Flask em 127.0.0.1:5000 e uma página estática simples em localhost:8001. A segunda origem é o que torna a requisição entre sites.

Preparar o ambiente

Crie um ambiente virtual e instale o Flask. No Linux e no macOS, ative com . .venv/bin/activate. No Windows com Git Bash, o script de ativação fica em .venv/Scripts/activate.

bash
python3 -m venv .venv && . .venv/bin/activate && pip install flask

Nesta execução, as versões instaladas foram Flask 3.1.3 e Werkzeug 3.1.9.

Escrever o app

Salve como app.py. A rota /login define um cookie de sessão com SameSite=Lax e HttpOnly. A rota /transfer não altera nada. Ela só imprime se o cookie chegou, junto com dois cabeçalhos que ajudam a ler o resultado.

python
import os

from flask import Flask, make_response, request

app = Flask(__name__)

# Change this to "None" to compare. Browsers reject SameSite=None without Secure.
SAMESITE = os.environ.get("LAB_SAMESITE", "Lax")
SECURE = SAMESITE == "None"


@app.get("/login")
def login():
    resp = make_response("logged in\n")
    resp.set_cookie("session", "lab", samesite=SAMESITE, secure=SECURE, httponly=True)
    return resp


@app.post("/transfer")
def transfer():
    arrived = request.cookies.get("session") == "lab"
    print(
        f"POST /transfer cookie_arrived={arrived} "
        f"origin={request.headers.get('Origin')} "
        f"sec_fetch_site={request.headers.get('Sec-Fetch-Site')}",
        flush=True,
    )
    return f"cookie_arrived={arrived}\n"

O app ainda não valida nada. Isso é intencional. Você quer ver o que o navegador envia antes de adicionar uma defesa.

No primeiro terminal:

bash
flask --app app run --host 127.0.0.1 --port 5000

Em um segundo terminal, peça a rota de login e leia o cabeçalho:

bash
curl -i http://127.0.0.1:5000/login

Saída desta execução, sem a linha Date:

text
HTTP/1.1 200 OK
Server: Werkzeug/3.1.9 Python/3.12.10
Content-Type: text/html; charset=utf-8
Content-Length: 10
Set-Cookie: session=lab; HttpOnly; Path=/; SameSite=Lax
Connection: close

logged in

A linha Set-Cookie é a parte importante. HttpOnly mantém o cookie longe do JavaScript da página. Ele não impede o navegador de enviar o cookie, então HttpOnly não é uma defesa contra CSRF. SameSite=Lax é o atributo que muda o que o navegador envia entre sites.

Simular o que o servidor vê

Antes de envolver um navegador, envie as requisições você mesmo para saber o que o servidor registra:

bash
curl -s -X POST -d "amount=100" http://127.0.0.1:5000/transfer
curl -s -X POST -H "Origin: http://localhost:8001" -H "Sec-Fetch-Site: cross-site" -b "session=lab" -d "amount=100" http://127.0.0.1:5000/transfer
curl -s -X POST -H "Origin: http://127.0.0.1:5000" -H "Sec-Fetch-Site: same-origin" -b "session=lab" -d "amount=100" http://127.0.0.1:5000/transfer

Respostas desta execução, uma linha por comando:

text
cookie_arrived=False
cookie_arrived=True
cookie_arrived=True

As linhas que o terminal do Flask imprimiu para essas três requisições:

text
POST /transfer cookie_arrived=False origin=None sec_fetch_site=None
POST /transfer cookie_arrived=True origin=http://localhost:8001 sec_fetch_site=cross-site
POST /transfer cookie_arrived=True origin=http://127.0.0.1:5000 sec_fetch_site=same-origin

O curl não aplica SameSite. Ele envia o que você passar com -b. Então essas linhas provam que a rota lê o cookie e os cabeçalhos. Elas não provam o que um navegador envia. Esse é o próximo passo.

Servir um formulário de uma segunda origem

Crie uma pasta chamada attacker e salve este conteúdo em attacker/index.html:

html
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Second origin (lab)</title></head>
<body>
<h1>Form on a second origin</h1>
<form method="POST" action="http://127.0.0.1:5000/transfer">
  <input type="hidden" name="amount" value="100">
  <button type="submit">Send transfer</button>
</form>
</body>
</html>

Em um terceiro terminal, inicie um servidor estático a partir da pasta que contém attacker/:

bash
python3 -m http.server 8001 --bind 127.0.0.1

Nesta execução, uma requisição ao servidor estático retornou HTTP 200. A página então abre em http://localhost:8001/attacker/. Observe o nome do host. localhost e 127.0.0.1 são hosts diferentes, então o formulário servido por localhost:8001 envia o POST para um site diferente do host do cookie. É essa a configuração entre sites de que você precisa.

Comparar SameSite=Lax com SameSite=None

Abra http://127.0.0.1:5000/login no seu navegador antes, para que o cookie fique guardado para 127.0.0.1 nesse perfil. Depois abra http://localhost:8001/attacker/ no mesmo navegador e clique em Send transfer. Leia o terminal do Flask.

Com SameSite=Lax, um POST de formulário entre sites não deve levar o cookie. A linha de log esperada é cookie_arrived=False. O navegador omite o cookie porque Lax não o envia em POST entre sites. A etapa no navegador não foi executada para este artigo, então confira a linha de log você mesmo contra essa expectativa.

Agora reinicie o app com None:

bash
LAB_SAMESITE=None flask --app app run --host 127.0.0.1 --port 5000

Leia o cabeçalho no novo servidor:

bash
curl -i http://127.0.0.1:5000/login

Saída desta execução:

text
Set-Cookie: session=lab; Secure; HttpOnly; Path=/; SameSite=None

SameSite=None sozinho não basta. Navegadores rejeitam um cookie None sem Secure, e Secure exige HTTPS. O laboratório serve HTTP simples em 127.0.0.1, e se o navegador guarda um cookie Secure vindo dessa origem depende do navegador. Abra o cookie nas ferramentas de desenvolvedor do navegador e veja se ele existe. Se não foi guardado, o log do servidor mostra cookie_arrived=False, mas o motivo é a regra do Secure, não o SameSite. Se foi guardado, envie o formulário de novo. Uma linha cookie_arrived=True com None significa que o navegador enviou o cookie em um POST entre sites. É esse comportamento que torna None arriscado para cookies de sessão, e é por isso que você deve usar Lax ou Strict, a menos que um fluxo real entre sites precise de None.

Pare cada servidor com Ctrl+C quando terminar.

O que o laboratório não prova

O laboratório mostra como cookies e SameSite mudam o que o servidor recebe. Ele não adiciona defesa. A rota /transfer ainda aceita qualquer POST que carregue o cookie, então um cookie SameSite=None deixaria um formulário forjado ter sucesso. Para fechar a brecha, adicione um token sincronizado ao formulário e verifique-o na rota. O laboratório para aqui para que você veja primeiro o atributo sozinho.

Erros comuns

  • Confiar no CORS para bloquear CSRF. O CORS controla se uma página pode ler a resposta. Ele não impede o navegador de enviar um POST simples entre sites. A requisição ainda chega ao servidor.
  • Achar que HttpOnly bloqueia CSRF. HttpOnly esconde o cookie do JavaScript. O navegador continua anexando o cookie às requisições forjadas.
  • Checar o token só em algumas rotas. Endpoints que o AJAX chama muitas vezes pulam a verificação. O atacante procura justamente a rota que pula.
  • Colocar o token na URL. URLs aparecem em logs do servidor, cabeçalhos Referer e histórico do navegador.
  • Fazer rotas GET alterarem dados. Aí uma simples tag de imagem basta para o ataque, e SameSite=Lax deixa de proteger a rota.
  • Usar None sem Secure. Os navegadores rejeitam. Se você mudar o atributo para consertar um fluxo quebrado, pode abrir um caminho entre sites que não queria abrir.
  • Comparar Origin por substring. evil-bank.example e bank.example.evil.test contêm bank.example. Compare origens completas.
  • Confiar só no SameSite. Ele é forte contra sites não relacionados, mas subdomínios irmãos são do mesmo site, e navegadores antigos podem ignorar o atributo.

Como aprofundar

Leia a cheat sheet da OWASP do começo ao fim. Ela cobre os padrões de token, as checagens de cabeçalho e os casos que cada um deixa passar. Leia o rascunho RFC 6265bis para as regras exatas do SameSite, incluindo o padrão quando o atributo falta e como as navegações de nível superior são tratadas. A página Set-Cookie da MDN é uma referência mais rápida para os valores do atributo.

No seu próprio app, use a proteção contra CSRF que o seu framework oferece, e teste-a. Escreva um teste que envie um POST para uma rota que altera estado sem token e espere uma recusa. Escreva um segundo teste que envie um POST com um token de outra sessão e também espere uma recusa. Se usar Flask, veja a extensão CSRFProtect do Flask-WTF. Confira os avisos de cookie nas ferramentas de desenvolvedor do navegador, porque o Chrome informa problemas de SameSite no painel Issues.

Para a camada de transporte por baixo desses cookies, leia o que HTTPS e TLS fazem. O atributo Secure só tem sentido quando a conexão é criptografada. O próximo artigo desta série, como escrever seu primeiro relatório de segurança, mostra como registrar um achado como este para a equipe dona do código.

Fontes

Fontes

  1. OWASP Cross-Site Request Forgery Prevention Cheat Sheet
  2. MDN, Set-Cookie header
  3. CWE-352, Cross-Site Request Forgery (CSRF)
  4. IETF draft-ietf-httpbis-rfc6265bis, HTTP State Management Mechanism

Mais em Segurança web e de APIs

Segurança web e de APIs

OWASP Top 10 explicado para desenvolvedores

O que é o OWASP Top 10, o que cada categoria de 2025 significa, como se defender e como mapear desafios do Juice Shop.

Iniciante15 min de leitura

Segurança web e de APIs

SQL Injection explicada com o OWASP Juice Shop

Como a SQL Injection muda o sentido de uma consulta, por que consultas parametrizadas resolvem o problema e um laboratório com Juice Shop e sqlite3.

Intermediário10 min de leitura

Segurança web e de APIs

Cross-Site Scripting (XSS) explicado: tipos e defesas

Entenda XSS refletido, armazenado e baseado em DOM, por que a codificação depende do contexto e como a CSP reduz o risco, em laboratório local.

Intermediário11 min de leitura