Cross-Site Request Forgery (CSRF): Como Seu Navegador Te Trai — e Como o Token do Django Impede
Django Security Series — Post 8 | Série II: Broken Access Control
OWASP A01:2025 — Broken Access Control | Tempo de leitura: ~13 min
🧪 Rode você mesmo. Este ataque vem como um laboratório executável no django-security-lab: um endpoint de transferência onde o "dinheiro" é uma flag. A view
@csrf_exemptaceita um POST sem token (forjado) e a flag se move da vítima para o atacante; a view@csrf_protectretorna403. Reproduza o lado servidor pela linha de comando comcurl, e a mecânica cross-site no navegador com a páginaevil.htmldo README do laboratório.
Os Posts 6 e 7 trataram de um atacante que tinha sua própria conta e testava os limites dela, lendo objetos de outro usuário ou gravando o próprio papel, sempre do próprio navegador e com a própria sessão. O Post 8 inverte isso. O atacante nunca toca na sua aplicação diretamente e não precisa de sessão, senha ou conta: ele engana o navegador da vítima para fazer a requisição, e o navegador anexa o cookie de sessão da vítima a uma requisição que ela jamais pretendeu enviar.
CSRF é o ataque que me fez repensar o que "autenticado" realmente significa. Meu primeiro contato real com ele foi anos atrás, na primeira vez que construí qualquer coisa com Django. Eu tinha conectado um formulário, o POST estava sendo submetido, e o Django continuava retornando 403. Passei vinte minutos verificando a lógica da view antes de perceber que o template estava sem {% csrf_token %}. O 403 é irritante; a alternativa, aceitar silenciosamente todo POST cross-site, é pior de formas que não são óbvias até você ver o ataque. É disso que este post trata: por que o token existe, e os poucos lugares onde um desenvolvedor Django o desliga.
O Ataque: O Que É e Como Funciona
CSRF explora um único comportamento do navegador: uma requisição a um site leva os cookies daquele site, seja qual for a página que a iniciou. A vítima faz login em bank.com e recebe um cookie de sessão. Depois ela abre uma página que o atacante controla (um post de fórum, um link em um e-mail, um anúncio comprometido), e essa página contém um formulário oculto apontando para POST bank.com/transfer/, submetido por JavaScript assim que carrega. O servidor recebe um POST com uma sessão válida e um corpo válido, não tem como distingui-lo de um que o usuário enviou de propósito, e executa a transferência. O atacante nunca vê o cookie nem a resposta; ele só precisa dos nomes dos campos do formulário alvo, que qualquer titular de conta consegue ler, e de uma vítima que abra a página dele enquanto está logada. A página de ataque do laboratório é a técnica inteira:
<body onload="document.getElementById('f').submit()">
<form id="f" action="http://127.0.0.1:8000/csrf/vulnerable/transfer/" method="POST">
<input type="hidden" name="to_account" value="bob">
</form>
</body>
A submissão de um formulário é uma navegação, fora do CORS por completo, e mesmo um fetch() com esse corpo application/x-www-form-urlencoded é uma requisição "simples" que dispensa o preflight (ela só envia cookies cross-origin com credentials: 'include'): o CORS decide se uma página pode ler a resposta, não se a requisição é enviada.
Um caveat decide se isso funciona em 2026: o SameSite. O Django define SameSite=Lax nos cookies de sessão e de CSRF por padrão, e um navegador não envia um cookie Lax em um POST cross-site, então a página acima, hospedada em outro site, chega sem sessão. Uma execução do Chrome 155 headless contra uma réplica da view do laboratório, capturada em scans/samesite-browser.txt no laboratório, mostrou isso: aberta do disco, o POST da página chegou ao servidor deslogado e foi redirecionado para a página de login. Duas coisas reabrem a brecha. Um site é o esquema mais o domínio registrável, não a origem, então uma página em um subdomínio irmão ou em outra porta do mesmo host é same-site: servida de outra porta em 127.0.0.1, a mesma página fez a transferência como a vítima. E um cookie definido como SameSite=None, de que embeds cross-site e alguns fluxos de single sign-on precisam, é enviado também em requisições cross-site (os navegadores só o aceitam com Secure). O SameSite estreita o CSRF; o token o fecha.
A mesma requisição forjada tem outra porta de entrada, e uma configuração enfraquece a defesa que ela encontra. Uma view que altera estado via GET fica fora da verificação do middleware, e o Lax ainda envia o cookie de sessão em um GET cross-site de nível superior, então basta um link. E um curinga em CSRF_TRUSTED_ORIGINS, como 'https://*.herokuapp.com', deixa todo app da plataforma passar na verificação de Origin, e o token vira a única barreira.
Incidentes Reais
Netflix — Tomada de Conta via CSRF (2006)
Em 16 de outubro de 2006, o pesquisador de segurança Dave Ferguson publicou um advisory na lista Full Disclosure mostrando que as páginas de conta do Netflix aceitavam requisições forjadas. Uma página que a vítima abrisse enquanto logada podia adicionar filmes à fila de aluguel, mover um para o topo e alterar o nome e o endereço da conta; em uma janela após o login, quando o Netflix não pedia a senha atual, podia também alterar o e-mail e a senha da conta, trancando o dono do lado de fora. Nenhum token anti-CSRF era exigido, e o navegador fornecia o cookie de sessão. No mesmo mês, a Dark Reading citou Jeremiah Grossman chamando o CSRF de "o gigante adormecido" das vulnerabilidades web.
A lição: CSRF transforma cada endpoint que altera estado em um controle remoto. O atacante não quebra a autenticação; ele a empresta. O gerenciamento de sessão da própria aplicação, projetado para manter o usuário logado por conveniência, torna-se o vetor de ataque. Em termos de MITRE ATT&CK, o resultado se aproxima mais de T1098 — Account Manipulation quando a requisição forjada altera credenciais, como no caso do Netflix, ou de T1565.001 — Data Manipulation: Stored Data Manipulation quando altera dados. A entrega, uma página para a qual a vítima navega, lembra T1189 — Drive-by Compromise, mas essa técnica obtém acesso ao sistema da vítima, e o CSRF nunca o toca: ele usa uma sessão que a vítima já tem.
Fontes: Dave Ferguson — Netflix CSRF Advisory (Full Disclosure, Outubro 2006); Dark Reading — CSRF Vulnerability: A 'Sleeping Giant' (2006)
Proteções Padrão do Django
O Django entrega a proteção CSRF ligada por padrão: o django-admin startproject coloca o CsrfViewMiddleware em MIDDLEWARE, e a tag {% csrf_token %} renderiza o token nos formulários.
- Toda requisição insegura precisa do token. Para qualquer método que não seja GET, HEAD, OPTIONS ou TRACE, o middleware exige o cookie
csrftokene um token correspondente, enviado como o campo de formuláriocsrfmiddlewaretokenou o cabeçalhoX-CSRFToken, e responde403antes de a view rodar se algum dos dois faltar ou estiver errado. O segredo vive no cookie (ou na sessão, comCSRF_USE_SESSIONS = True); o formulário leva uma cópia mascarada, remascarada a cada chamada deget_token()como mitigação contra BREACH, então as duas strings nunca coincidem byte a byte. A página de um atacante consegue fazer o navegador enviar o cookie, mas não consegue lê-lo de outra origem, então não consegue produzir o token. - O cabeçalho
Origintambém é verificado. Quando o navegador o envia, ele precisa corresponder ao próprio host da requisição ou a uma entrada deCSRF_TRUSTED_ORIGINS; uma requisição HTTPS sem ele passa por uma verificação estrita deReferer. A documentação do Django aponta a fraqueza do token, um subdomínio capaz de definir cookies para o domínio inteiro, e é esta verificação que impede esse subdomínio de fazer POST na sua aplicação. - Os cookies têm
SameSite=Laxpor padrão, tanto osessionidquanto ocsrftoken: uma segunda camada, como a seção do ataque mostrou, não o controle. - O token é rotacionado no login, então um formulário renderizado antes do login leva um token vencido.
Uma coisa que me surpreendeu: o cookie de sessão do Django é HttpOnly por padrão, o que impede o JavaScript de lê-lo, e o cookie CSRF não; o CSRF_COOKIE_HTTPONLY é False por padrão. Só percebi isso quando verifiquei minhas configurações de produção enquanto escrevia este post. O motivo é o AJAX: um POST via fetch() com corpo JSON não tem campo de formulário para levar o token, então o JavaScript lê o cookie e envia o valor como o cabeçalho X-CSRFToken. Ligar a flag é permitido (o script passa a ler o token do input oculto que o {% csrf_token %} renderiza), mas a documentação do Django diz que ela "doesn't offer any practical protection": um script que consegue ler o cookie já roda na sua origem e consegue ler o token da página.
O que o middleware não cobre é onde vivem os bugs de CSRF no Django: uma view marcada com @csrf_exempt e uma API construída sobre o APIView do DRF, que se isenta do middleware e deixa a verificação para suas classes de autenticação.
O Padrão Vulnerável e a Correção
Padrão 1 — @csrf_exempt em uma View que Altera Estado
A view de transferência do laboratório é o exemplo clássico: um POST autenticado que move os bens da vítima para uma conta indicada, com a verificação CSRF desligada. A correção é o decorador:
-@csrf_exempt
@login_required
+@csrf_protect
def transfer(request):
return perform_transfer(request)
Na view vulnerável, um POST sem token levando a sessão de alice move a flag para bob; na view segura, o mesmo POST recebe 403 e nada se move. O @login_required está nas duas: ele confirma que a sessão é válida, que é exatamente a confiança que o CSRF explora. Em um projeto padrão, a correção é só a linha removida, já que o middleware global já protege a view. O laboratório roda sem esse middleware para que os outros laboratórios continuem amigáveis ao curl, e sua gêmea segura volta a ativá-lo com @csrf_protect, a mesma verificação aplicada a uma única view. As duas views estão no laboratório: views_vulnerable.py e views_secure.py.
A isenção costuma chegar como correção de outra coisa: um formulário que retorna 403 porque o template não tem {% csrf_token %}, ou uma chamada fetch() que nunca envia X-CSRFToken. O reparo é a tag ou o cabeçalho; o @csrf_exempt transforma um formulário quebrado em um formulário vulnerável.
Padrão 2 — @csrf_exempt em uma View que Aceita Autenticação por Sessão e por Token
Um endpoint atende o navegador (cookie de sessão) e um app mobile (um cabeçalho Authorization: Token). O app não tem cookie CSRF, seus POSTs falham no middleware, e o atalho é isentar a view inteira, o que tira a verificação também do caminho da sessão:
-@csrf_exempt # token clients have no CSRF cookie
-def transfer_funds(request):
- ... # checks Authorization: Token, else falls back to the session
- return JsonResponse({'status': 'ok'})
+class TransferView(APIView):
+ authentication_classes = [SessionAuthentication, TokenAuthentication]
+ permission_classes = [IsAuthenticated]
+
+ def post(self, request):
+ ... # the transfer
+ return Response({'status': 'ok'})
O APIView.as_view() do DRF devolve uma view csrf_exempt, e o SessionAuthentication recoloca a verificação nas requisições que ele autentica; o TokenAuthentication não precisa dela, porque o navegador nunca anexa um cabeçalho de token por conta própria, então uma requisição forjada não consegue levar o da vítima. Com o DRF 3.18.2, um POST de sessão sem o token CSRF recebe 403 ("CSRF Failed: CSRF cookie not set."), mesmo quando também leva um cabeçalho Authorization: Token válido, porque o SessionAuthentication roda primeiro; um POST com o cabeçalho de token e sem sessão recebe 200. Dois caveats: o SessionAuthentication só verifica CSRF nas requisições que ele autentica, então uma requisição anônima passa sem verificação, o que a documentação do DRF diz não servir para views de login; e o TokenAuthentication precisa de rest_framework.authtoken em INSTALLED_APPS. Sem DRF, uma isenção só é segura em uma view que nunca lê a sessão: dê aos clientes de token um endpoint próprio e mantenha o de sessão protegido.
Uma configuração completa o trabalho em produção. Atrás de um proxy que termina o TLS e repassa HTTP puro, o Django vê a requisição como http enquanto o Origin do navegador diz https, e todo POST das suas próprias páginas falha na verificação de Origin com 403, a menos que essa origem esteja em CSRF_TRUSTED_ORIGINS: exatamente a falha que leva alguém a um @csrf_exempt. Defina SECURE_PROXY_SSL_HEADER em vez disso, depois de confirmar que o proxy define esse cabeçalho e remove qualquer cópia enviada pelo cliente.
A Visão do Analista
CSRF é o clássico problema do confused deputy, um agente com autoridade legítima (aqui, o navegador da vítima, que guarda o cookie de sessão) enganado por outra parte para usá-la indevidamente, e ele redefine o que um analista CySA+ entende por "autenticado": o cookie de sessão prova identidade, nunca intenção, e o servidor sozinho não consegue distinguir uma requisição que o usuário quis enviar de uma que o navegador foi enganado a enviar. O token anti-CSRF fornece a prova que falta de que a requisição veio das suas próprias páginas, um segredo que a origem do atacante não consegue ler e, portanto, não consegue forjar; um script rodando na sua origem por meio de um XSS (Post 2) consegue lê-lo, então ali o token não defende nada. Isso faz dele um controle preventivo, ligado por padrão no Django; a classe de vulnerabilidade é o conjunto de lugares onde um desenvolvedor o removeu, e é por isso que a triagem começa por cada @csrf_exempt.
É também o exemplo mais limpo da série de defesa em profundidade: o SameSite=Lax barra a maioria dos POSTs cross-site antes de a verificação do Django rodar. Tenha os dois fatos em mente. O token é o controle que você impõe e testa; o SameSite é uma segunda camada que você configura, e ela não cobre uma página do atacante same-site nem um cookie SameSite=None, então nunca substitui o primeiro.
Detectando Automaticamente
Testando Sua Defesa
Um único teste prova a correção contra este payload: o cliente logado de alice envia um POST sem token à view segura, que precisa responder 403 com a flag ainda com ela. É o que o laboratório usa:
def test_exploit_is_blocked_on_secure_view(self):
# @csrf_protect rejects the tokenless POST with 403; nothing moves.
self.client.force_login(self.alice)
resp = self.client.post("/csrf/secure/transfer/", {"to_account": "bob"})
self.assertEqual(resp.status_code, 403)
self.assertEqual(self.holdings(self.alice), FLAG)
self.assertEqual(self.holdings(self.bob), "")
O detalhe que lhe dá sentido está no setUp: self.client = Client(enforce_csrf_checks=True). O cliente de teste do Django ignora as verificações CSRF a menos que seja construído assim, então sem isso a view segura aceitaria o POST e nenhum teste conseguiria enxergar a verificação. Aponte o teste para /csrf/vulnerable/transfer/ e ele falha: o POST é aceito e a flag vai para bob. O tests.py do laboratório acrescenta as outras duas metades: o exploit funciona na view vulnerável, e um POST com o token lido do formulário ainda funciona na segura. Para o Padrão 2, verifique que um POST de sessão sem o token recebe 403.
Fazendo a Varredura
A verificação mais rápida é um grep, porque o @csrf_exempt é a maior parte da superfície. Os scanners, então, dão uma lição sobre camadas de regras. O Bandit não reporta nada nas views: não tem verificação para @csrf_exempt, e seus únicos achados são as duas senhas de teste embutidas. Os pacotes da comunidade do Semgrep (p/django, p/python, p/owasp-top-ten) não reportam nada em nenhuma das views, e no entanto o Semgrep tem uma regra exatamente para isso, python.django.security.audit.csrf-exempt.no-csrf-exempt. Ela fica entre as regras security/audit do Semgrep, com confiança LOW nos metadados, já que o @csrf_exempt é a escolha certa em, por exemplo, um receptor de webhook que autentica por assinatura, e nenhum dos três pacotes a inclui. Rode-a pelo nome, ou pelo pacote p/security-audit, que a inclui, e ela separa o par:
grep -rn "csrf_exempt" --include="*.py" . # every match outside tests needs a reason
# the standard tools — miss it
bandit -r labs/post_08_csrf/
semgrep scan --config p/django --config p/python --config p/owasp-top-ten labs/post_08_csrf/views_vulnerable.py
semgrep scan --config p/django --config p/python --config p/owasp-top-ten labs/post_08_csrf/views_secure.py
# Semgrep's audit-tier rule — catches it
semgrep scan --config r/python.django.security.audit.csrf-exempt labs/post_08_csrf/views_vulnerable.py # 1 finding (@csrf_exempt)
semgrep scan --config r/python.django.security.audit.csrf-exempt labs/post_08_csrf/views_secure.py # 0 findings (@csrf_protect)
Como essa regra dispara na view vulnerável e fica em silêncio na segura, este post não escreve regra personalizada, e a CI verifica os dois resultados. Ela só enxerga o decorador, o que cobre os dois padrões, e só em uma função: @method_decorator(csrf_exempt, …) em uma view baseada em classe e csrf_exempt(view) no urls.py passam por ela em silêncio, e é por isso que o grep vem primeiro. A lição é a consciência de camadas: uma regra relevante para segurança pode estar em uma camada que a sua varredura padrão pula, e saber buscá-la é a habilidade do analista. As execuções capturadas, inclusive a do p/security-audit, estão versionadas em scans/.
O CSRF é uma classe DAST listada, e o OWASP ZAP tem uma regra passiva para isso, Absence of Anti-CSRF Tokens, que sinaliza um formulário sem campo de token. Este laboratório não traz uma execução capturada do ZAP, e essa regra não ajudaria aqui. O formulário de transferência fica atrás de @login_required, então uma execução fiel exige um contexto autenticado no ZAP (um script de login e tratamento de sessão), e, uma vez lá, o ZAP encontraria um campo csrfmiddlewaretoken nos dois formulários: o laboratório renderiza um para cada, e a view vulnerável simplesmente o ignora. O que prova a lacuna é uma requisição enviada sem o token, a verificação que o tests.py faz de forma determinística; como todo DAST aqui, uma execução do ZAP ficaria fora do CI.
O hábito que este post me deixou: todo @csrf_exempt ganha um comentário dizendo o que autentica aquela view no lugar da sessão.
O Post 9 continua a Série II com Path Traversal, onde o atacante escapa do diretório pretendido no sistema de arquivos e transforma um endpoint de download em um leitor de settings.py ou /etc/passwd.
Leitura Complementar
- django-security-lab — o laboratório executável deste post (
labs/post_08_csrf/), incl. o demo de navegadorevil.html - Django Docs — Cross Site Request Forgery Protection
- Django Docs — How to Use Django's CSRF Protection
- DRF Docs — SessionAuthentication and CSRF
- OWASP A01:2025 — Broken Access Control
- OWASP Cheat Sheet — Cross-Site Request Forgery Prevention
- PortSwigger Web Security Academy — Cross-Site Request Forgery (CSRF)
- MITRE ATT&CK — T1565.001: Data Manipulation: Stored Data Manipulation
- Web Security for Developers: Real Threats, Practical Defense (Malcolm McDonald) — Chapter 8: Cross-Site Request Forgery Attacks