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:2021 — Broken Access Control | Tempo de leitura: ~15 min
Os dois primeiros posts da Série II trataram de um atacante que já tinha sua própria conta e estava testando os limites — lendo objetos de outro usuário (Post 6) ou escrevendo seu próprio papel (Post 7). Em ambos os casos o atacante fez a requisição ele mesmo, do próprio navegador, com sua própria sessão. O Post 8 inverte isso completamente. O atacante nunca toca na sua aplicação diretamente. Ele não precisa de sessão, senha ou conta. Em vez disso, ele engana o navegador da vítima para fazer a requisição por ele — e o navegador, obedecendo fielmente sua programação, anexa o cookie de sessão da vítima a uma requisição que a vítima jamais pretendeu fazer.
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 %}. Esse foi o momento em que o middleware se tornou visível para mim — uma parede invisível que eu nem sabia que existia. Quando construí o Petição Brasil o token já era memória muscular, mas foi só pesquisando para este post que entendi a mecânica completa de por que o Django te obriga a tropeçar nele. O 403 é irritante; a alternativa (aceitar silenciosamente todo POST cross-origin) é pior de formas que não são óbvias até você ver o ataque.
Cross-Site Request Forgery não se trata de roubar credenciais. O atacante nunca descobre a senha ou o ID de sessão da vítima. Em vez disso, ele explora um comportamento fundamental do navegador para fazer o próprio navegador da vítima submeter uma requisição que a vítima nunca pretendeu. A mecânica está na próxima seção; a versão curta é que o servidor não consegue distinguir uma requisição que o usuário quis enviar de uma que o navegador foi enganado a enviar.
CSRF é classificado sob A01 (Broken Access Control) no OWASP 2021 porque o servidor aceita uma requisição que altera estado quando deveria tê-la rejeitado. A resposta do Django é o CsrfViewMiddleware, que vem habilitado por padrão. A proteção é ativada desde o início, portanto toda a superfície CSRF em uma aplicação Django é o conjunto de lugares onde um desenvolvedor a desligou.
O Ataque: O Que É e Como Funciona
CSRF explora a confiança que uma aplicação web deposita no navegador do usuário. A mecânica se baseia em um único comportamento do navegador: quando o navegador envia uma requisição para um domínio, ele automaticamente anexa todos os cookies daquele domínio — incluindo cookies de sessão — independentemente de qual site iniciou a requisição. Isso é intencional; cookies são escopados por domínio, não por origem. O navegador não consegue distinguir "o usuário clicou num botão em banco.com" de "um script em evil.com gerou uma submissão de formulário para banco.com."
Eis o que acontece. A vítima faz login em banco.com e recebe um cookie de sessão. Depois (talvez minutos, talvez horas) ela visita uma página que o atacante controla: um post de fórum, um link em um email, uma rede de anúncios comprometida. Essa página contém um formulário oculto apontando para POST banco.com/transferir/. No momento em que a página carrega, JavaScript submete o formulário. O navegador, fazendo exatamente o que navegadores fazem, anexa o cookie de sessão de banco.com à requisição. O servidor recebe um POST com uma sessão válida e um corpo de requisição válido. Ele não tem mecanismo para distinguir isso de uma requisição que o usuário submeteu intencionalmente. A transferência é executada.
O atacante nunca vê o cookie de sessão, nunca lê a resposta, e nunca precisa de uma conta na aplicação alvo. Ele só precisa que a vítima visite uma página que ele controla enquanto estiver logada no alvo. A maioria dos usuários permanece logada em suas aplicações web por horas ou dias, então essa condição é quase sempre atendida.
O mecanismo de entrega mais perigoso é um formulário HTML oculto que se auto-submete ao carregar a página. Ele envia um POST (que é o que endpoints de alteração de estado devem exigir), o navegador o submete como application/x-www-form-urlencoded (um tipo de conteúdo "simples" que não dispara preflight CORS), e a vítima nunca vê nada acontecer. Tags de imagem (<img src="https://banco.com/transferir?para=atacante">) também funcionam, mas apenas contra endpoints que realizam alterações de estado via GET, que não deveriam existir. JavaScript também pode enviar POSTs cross-origin via fetch, embora tipos de conteúdo não simples disparem um preflight que bloqueia a requisição. O atacante cria o formulário com os nomes exatos dos campos que o endpoint alvo espera:
<body onload="document.getElementById('csrf-form').submit();">
<form id="csrf-form" action="https://banco.com/transferir/" method="POST">
<input type="hidden" name="conta_destino" value="iban-atacante" />
<input type="hidden" name="valor" value="10000" />
</form>
</body>
Quando a vítima visita esta página, o formulário é submetido instantaneamente. O navegador anexa o cookie de sessão de banco.com. O servidor vê um POST para /transferir/ com uma sessão válida e o processa. A transferência é concluída antes que a vítima perceba o que aconteceu.
Como os Atacantes Exploram
A exploração prática de CSRF requer conhecer a URL do endpoint alvo e os parâmetros esperados — o que geralmente é trivial. O atacante inspeciona os formulários da aplicação (visíveis para qualquer um com uma conta), lê a documentação da API (se pública), ou simplesmente lê o código open-source. Ele então constrói o formulário oculto e o hospeda em qualquer domínio que controle.
O atacante nem precisa que a vítima clique em um link — embutir o payload em um <iframe> em uma página que a vítima provavelmente visitará (um fórum, uma rede social, um blog comprometido) é suficiente. O ataque não tem indicador visível para a vítima: a submissão do formulário acontece em segundo plano, a página pode redirecionar imediatamente para algo inócuo, e o navegador da vítima não mostra nenhum aviso.
O MITRE ATT&CK mapeia o resultado como T1565.001 — Data Manipulation: Stored Data Manipulation (a alteração de estado altera dados armazenados na aplicação alvo) ou, quando o alvo são configurações de conta especificamente, T1098 — Account Manipulation (o atacante modifica credenciais ou configurações de conta usando a sessão da vítima). O lado da entrega não mapeia de forma limpa para as táticas de acesso inicial do ATT&CK, que assumem comprometimento do endpoint; CSRF abusa de uma sessão autenticada, não do sistema do usuário.
Incidentes Reais
Netflix — Tomada de Conta via CSRF (2006)
Em outubro de 2006, o pesquisador de segurança Dave Ferguson publicou um advisory na lista Full Disclosure demonstrando vulnerabilidades CSRF em múltiplos endpoints de gerenciamento de conta do Netflix. Uma página maliciosa podia hospedar formulários ocultos que faziam POST para os endpoints de configurações do Netflix, alterando credenciais de login, endereço de entrega, fila de DVDs e nome da conta da vítima. O navegador anexava o cookie de sessão automaticamente, nenhum token anti-CSRF era exigido, e as requisições tinham sucesso. O atacante jamais precisava da senha da vítima — apenas que a vítima visitasse a página do atacante enquanto estivesse logada no Netflix.
Dadas as sessões de longa duração do Netflix e a prevalência de navegação com múltiplas abas, essa condição era quase garantida. Jeremiah Grossman subsequentemente popularizou a divulgação, chamando CSRF de "o gigante adormecido" das vulnerabilidades web — uma caracterização que pegou porque a classe de ataque era conhecida há anos mas amplamente ignorada pela indústria. O advisory do Netflix se tornou uma das primeiras demonstrações de alto perfil de que CSRF não era teórico, mas uma classe de ataque prática e explorável contra grandes aplicações web.
A lição: CSRF transforma cada endpoint que altera estado em uma superfície de 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.
Fonte: Dave Ferguson — Netflix CSRF Advisory (Full Disclosure, Outubro 2006)
Proteções Padrão do Django
O Django é um dos poucos frameworks web que entrega proteção CSRF habilitada por padrão. A proteção é o CsrfViewMiddleware — adicionado à lista MIDDLEWARE em todo novo projeto gerado por django-admin startproject — e a template tag {% csrf_token %}. Juntos, eles implementam um padrão de Double Submit Cookie mascarado (o segredo vive em um cookie, e o formulário submete uma cópia mascarada dele; o Django só usa armazenamento real server-side/sessão quando CSRF_USE_SESSIONS = True):
- Quando uma página renderiza
{% csrf_token %}(ou qualquer código chamaget_token()), o middleware define um cookie CSRF (csrftoken) contendo um segredo aleatório. Este cookie éHttpOnly=Falsepor padrão (o JavaScript precisa lê-lo para requisições AJAX) e carregaSameSite=Lax, o que significa que o navegador não o enviará em requisições POST cross-site — então mesmo que a comparação de token de alguma forma falhasse, o próprio cookie estaria ausente da requisição forjada do atacante. - Em cada requisição insegura (POST, PUT, PATCH, DELETE), o middleware exige um token correspondente — seja como campo de formulário chamado
csrfmiddlewaretoken(renderizado por{% csrf_token %}) ou como cabeçalho HTTPX-CSRFToken. O middleware desmascara o token submetido e compara o segredo subjacente com o segredo do cookie (o mascaramento é uma mitigação contra BREACH — os dois valores não são idênticos byte a byte). - Se o token estiver ausente ou os segredos não corresponderem, o middleware retorna
403 Forbidden— a requisição nunca alcança a view.
Isso significa que o formulário oculto de um atacante em evil.com não pode produzir um token CSRF válido para banco.com. O atacante pode fazer o navegador enviar o cookie (navegadores fazem isso automaticamente), mas não pode ler o valor do cookie de outra origem (a política de mesma origem impede), e portanto não pode incluir o campo de formulário correspondente. O token prova que a requisição veio de uma página na mesma origem.
Uma coisa que me surpreendeu: o cookie de sessão do Django tem HttpOnly=True por padrão, o que bloqueia JavaScript de lê-lo. O cookie CSRF não. CSRF_COOKIE_HTTPONLY é False por padrão.
Por quê? Por causa de como requisições AJAX funcionam. Quando JavaScript envia um POST via fetch, ele não pode submeter um campo de formulário, então precisa de outra forma de provar que a requisição é legítima. A solução: JavaScript lê o valor do cookie CSRF via document.cookie e inclui esse valor como cabeçalho HTTP X-CSRFToken. O middleware aceita tanto o campo de formulário quanto esse cabeçalho. Mas se o cookie fosse HttpOnly, JavaScript não poderia lê-lo, e todo esse fluxo AJAX quebraria.
A alternativa é ler o token do <input> oculto que {% csrf_token %} renderiza no HTML da página, ao invés de ler do cookie. Se seu projeto faz isso (o meu faz), então CSRF_COOKIE_HTTPONLY = True é seguro de habilitar e estritamente melhor: significa que se um atacante encontrar uma vulnerabilidade XSS, ele não pode usá-la para roubar o token CSRF do cookie. Só percebi que o padrão era False quando verifiquei minhas configurações de produção enquanto escrevia este post.
O que o Django protege automaticamente:
- Toda requisição POST, PUT, PATCH e DELETE processada por uma view sob CsrfViewMiddleware exige um token válido.
- Os próprios LoginView, LogoutView, PasswordResetView e a interface admin do Django todos incluem {% csrf_token %} em seus formulários.
- A chave de sessão é rotacionada no login (login() chama cycle_key() para prevenção de fixação de sessão) e o token CSRF também é rotacionado (rotate_token()), então um ataque CSRF pré-autenticação não consegue reutilizar um token capturado antes do login.
O que o Django NÃO protege automaticamente:
- Views decoradas com @csrf_exempt — o middleware é explicitamente ignorado. Esta é a maior superfície CSRF em aplicações Django.
- Requisições GET que alteram estado — CsrfViewMiddleware só verifica métodos "inseguros" (POST, PUT, PATCH, DELETE). Uma view que transfere dinheiro com GET /transferir/?para=atacante&valor=1000 está completamente desprotegida, e uma simples tag <img> é suficiente para dispará-la.
- Views Django puras que lidam com autenticação dupla manualmente — se você escreve uma function view que verifica tanto cookies de sessão quanto cabeçalhos Authorization sem DRF, e aplica @csrf_exempt para evitar 403s para clientes autenticados por token, você genuinamente removeu a proteção CSRF do caminho de sessão. DRF lida com isso corretamente (seu SessionAuthentication aplica CSRF internamente independente do decorador de middleware), mas views puras não têm essa rede de segurança.
- Requisições AJAX sem o cabeçalho de token — JavaScript fazendo um fetch POST para um endpoint Django deve incluir o cabeçalho X-CSRFToken (lido do cookie csrftoken via document.cookie ou a tag {% csrf_token %} do Django). Se o desenvolvedor esquecer — ou desabilitar o cookie CSRF para "corrigir" o erro CORS que está vendo — a proteção desaparece.
Padrão Vulnerável: O Que NÃO Fazer
Padrão 1 — @csrf_exempt em uma view que altera estado
# INSEGURO — proteção CSRF desabilitada inteiramente
from django.views.decorators.csrf import csrf_exempt
from django.contrib.auth.decorators import login_required
from django.shortcuts import redirect
from django.contrib import messages
@csrf_exempt # ← toda a vulnerabilidade
@login_required
def transferir_fundos(request):
if request.method == 'POST':
conta_destino = request.POST.get('conta_destino')
valor = request.POST.get('valor')
# ... processar transferência ...
messages.success(request, f'Transferido R${valor} para {conta_destino}.')
return redirect('dashboard')
return render(request, 'banco/transferencia.html')
O decorador @csrf_exempt é toda a vulnerabilidade. Ele diz ao CsrfViewMiddleware para pular a verificação de token. Um atacante hospeda um formulário oculto que faz POST para /transferir/ com os nomes de campo corretos; o navegador da vítima envia seu cookie de sessão; o middleware, instruído a recuar, deixa passar. Repare que @login_required ainda está lá. Ele confirma que a sessão é válida. Só não tem como perguntar "você quis enviar isso?"
Padrão 2 — Endpoint GET que altera estado
# INSEGURO — alteração de estado via GET; o middleware CSRF não verifica requisições GET
from django.contrib.auth.decorators import login_required
from django.shortcuts import redirect
@login_required
def deletar_conta(request):
request.user.delete()
return redirect('home')
# urls.py
urlpatterns = [
path('deletar-conta/', views.deletar_conta, name='deletar_conta'),
]
Este é o que não precisa de formulário, não precisa de JavaScript, não precisa de nada além de uma única tag HTML. Um atacante coloca <img src="https://alvo.com/deletar-conta/"> em um post de fórum. O navegador da vítima tenta carregar a "imagem", o que significa que envia um GET para aquela URL com o cookie de sessão anexado. A conta é deletada. O middleware CSRF nem olhou para a requisição porque GET deveria ser seguro e idempotente segundo a semântica HTTP. Toda a vulnerabilidade é realizar uma ação destrutiva em um método que o middleware deliberadamente ignora.
Padrão 3 — {% csrf_token %} faltando em um template de formulário
<form method="post" action="/configuracoes/atualizar/">
<input type="text" name="email" value="{{ user.email }}">
<button type="submit">Atualizar Email</button>
</form>
O que dá errado: Isto não é uma vulnerabilidade que um atacante pode explorar — é um formulário quebrado que sempre retornará 403 Forbidden porque o middleware espera um token e o formulário não o inclui. A "correção" do desenvolvedor, infelizmente, é frequentemente @csrf_exempt na view ao invés de adicionar o token ao template — o que converte um formulário quebrado em uma vulnerabilidade CSRF.
Padrão 4 — @csrf_exempt em uma view pura que lida com autenticação dupla
# INSEGURO — csrf_exempt remove toda proteção CSRF de uma view que aceita session auth
from django.views.decorators.csrf import csrf_exempt
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
import json
@csrf_exempt # ← adicionado "porque clientes com token não têm cookie CSRF"
def transferir_fundos(request):
"""Aceita tanto session auth (navegador) quanto token auth (app mobile)."""
# Verificação manual de token para clientes mobile
auth_header = request.headers.get('Authorization', '')
if auth_header.startswith('Token '):
# ... validar token, definir request.user ...
pass
elif not request.user.is_authenticated:
return JsonResponse({'error': 'Não autenticado'}, status=401)
if request.method == 'POST':
data = json.loads(request.body)
# ... processar transferência ...
return JsonResponse({'status': 'ok'})
O desenvolvedor precisava que este endpoint funcionasse tanto para usuários no navegador (cookies de sessão) quanto para clientes mobile (token no cabeçalho Authorization). Clientes com token auth não carregam cookie CSRF, então suas requisições falhariam na verificação do middleware. O atalho do desenvolvedor: @csrf_exempt em toda a view. Mas isso genuinamente remove a proteção CSRF do caminho de sessão também. O formulário oculto de um atacante dispara um POST autenticado por sessão, o middleware é instruído a recuar, e nenhuma outra camada aplica CSRF — diferente do DRF, uma view Django pura não tem aplicação interna por classe de autenticação. A transferência é executada.
A correção certa é usar DRF, que resolve este problema no nível do framework (veja Regra 6). Se você precisa ficar com views puras, aplique CSRF manualmente para requisições autenticadas por sessão e pule apenas quando um token válido estiver presente.
Implementação Segura: O Jeito Django
Regra 1 — Nunca remova CsrfViewMiddleware; nunca use @csrf_exempt em views que alteram estado
O middleware é a fundação. Se ele está em MIDDLEWARE (está por padrão) e nenhum decorador o sobrescreve, toda requisição de alteração de estado é protegida. A base segura é simplesmente não mexer nele:
# settings.py — CsrfViewMiddleware deve estar presente (está por padrão)
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
# ...
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', # ← nunca remova isto
'django.contrib.auth.middleware.AuthenticationMiddleware',
# ...
]
Se você está tentado a usar @csrf_exempt, pare e diagnostique o problema real. Em quase todos os casos a correção é uma de:
- Formulário sem {% csrf_token %} — adicione a tag, não a isenção.
- POST AJAX sem o cabeçalho de token — leia o cookie e defina o cabeçalho X-CSRFToken (a documentação do Django fornece um trecho pronto de getCookie('csrftoken')).
- Endpoint de API que aceita tanto sessão quanto token auth — deixe o SessionAuthentication do DRF lidar com a aplicação do CSRF; não o sobrescreva com @csrf_exempt.
Regra 2 — Inclua {% csrf_token %} em todo formulário POST
Todo formulário HTML que submete via POST deve incluir o token:
<form method="post" action="/configuracoes/atualizar/">
{% csrf_token %}
<input type="text" name="email" value="{{ user.email }}">
<button type="submit">Atualizar Email</button>
</form>
A tag {% csrf_token %} renderiza um <input> oculto com name="csrfmiddlewaretoken" e um valor que corresponde ao cookie CSRF. O middleware compara os dois e permite a requisição.
Regra 3 — Nunca realize alterações de estado via GET
A semântica HTTP determina que requisições GET são seguras e idempotentes. Aplique isso no roteamento de URLs:
# SEGURO — a view só aceita POST; GET retorna uma página de confirmação
from django.views.decorators.http import require_POST
from django.contrib.auth.decorators import login_required
from django.shortcuts import render, redirect
@login_required
def deletar_conta(request):
if request.method == 'GET':
return render(request, 'accounts/confirmar_exclusao.html')
raise MethodNotAllowed('GET')
@require_POST
@login_required
def confirmar_exclusao_conta(request):
request.user.delete()
return redirect('home')
Ou, mais idiomaticamente com uma class-based view:
# SEGURO — DeleteView só processa POST/DELETE
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views import View
from django.shortcuts import render, redirect
class DeletarContaView(LoginRequiredMixin, View):
def get(self, request):
return render(request, 'accounts/confirmar_exclusao.html')
def post(self, request):
request.user.delete()
return redirect('home')
O middleware CSRF protege o POST; um atacante não pode disparar sem um token válido.
Regra 4 — Defina CSRF_COOKIE_SECURE, CSRF_COOKIE_HTTPONLY e SameSite
Proteja o próprio cookie CSRF para que ele não possa ser vazado ou sobrescrito:
# settings.py — endurecimento do cookie CSRF em produção
CSRF_COOKIE_SECURE = True # enviado apenas via HTTPS
CSRF_COOKIE_HTTPONLY = True # JavaScript não pode lê-lo (use o token do formulário)
CSRF_COOKIE_SAMESITE = 'Lax' # defesa em profundidade no nível do navegador — POST cross-site não carrega o cookie
Uma nota sobre CSRF_COOKIE_HTTPONLY: defini-lo como True significa que JavaScript não pode ler o cookie via document.cookie. Se seu código AJAX lê o cookie para definir o cabeçalho X-CSRFToken, você tem duas opções:
1. Leia o token do DOM — a tag {% csrf_token %} o renderiza como input oculto.
2. Use CSRF_USE_SESSIONS = True — o segredo CSRF é armazenado na sessão em vez de um cookie, e o token é sempre entregue via tag de formulário.
Regra 5 — Configure CSRF_TRUSTED_ORIGINS corretamente
Quando sua aplicação está atrás de um proxy ou CDN (Heroku, Cloudflare, AWS ALB), a verificação de referer do Django precisa saber em quais origens confiar:
# SEGURO — allowlist explícita de origens
CSRF_TRUSTED_ORIGINS = [
'https://meuapp.com',
'https://www.meuapp.com',
]
Nunca use um curinga mais amplo que o necessário. 'https://*.herokuapp.com' confia em todos os apps Heroku — incluindo um que o atacante controla. Se possível, restrinja ao seu domínio exato.
Regra 6 — Para endpoints com autenticação dupla, use DRF (não faça manualmente em views puras)
DRF resolve o problema de CSRF com autenticação dupla no nível do framework. Seu APIView internamente marca todas as views como csrf_exempt (contornando o middleware), e então SessionAuthentication re-aplica CSRF durante a autenticação para requisições baseadas em sessão. Requisições autenticadas por token pulam CSRF automaticamente. Isso significa que uma view DRF com SessionAuthentication e TokenAuthentication faz a coisa certa por padrão — sem decorador, sem lógica manual:
# SEGURO — DRF lida com a aplicação de CSRF por tipo de autenticação internamente
from rest_framework.views import APIView
from rest_framework.authentication import SessionAuthentication, TokenAuthentication
from rest_framework.permissions import IsAuthenticated
class TransferenciaView(APIView):
authentication_classes = [SessionAuthentication, TokenAuthentication]
permission_classes = [IsAuthenticated]
def post(self, request):
# Requisições autenticadas por sessão: CSRF aplicado pelo SessionAuthentication.
# Requisições autenticadas por token: CSRF ignorado (o token É a prova).
# DRF lida com isso internamente — sem @csrf_exempt, sem verificações manuais.
pass
Se você está tentado a construir um endpoint de autenticação dupla como uma function view Django pura com @csrf_exempt, esse é o sinal para mudar para DRF. O framework elimina a classe de erro que o Padrão 4 demonstra.
Checklist de Prevenção de CSRF
| Controle | O que cobre |
|---|---|
CsrfViewMiddleware em MIDDLEWARE (padrão) |
Validação automática de token em toda requisição insegura — a defesa primária |
{% csrf_token %} em todo formulário POST |
Fornece o token mascarado que o middleware valida contra o segredo do cookie |
Nenhum @csrf_exempt em views que alteram estado |
Impede desenvolvedores de remover a proteção por view |
| Nenhuma alteração de estado via GET | Elimina a superfície de ataque que o middleware CSRF não cobre |
CSRF_COOKIE_SECURE = True |
Previne vazamento do cookie CSRF via HTTP plano |
CSRF_COOKIE_SAMESITE = 'Lax' |
Defesa em profundidade no nível do navegador — POST cross-site não carrega o cookie |
CSRF_TRUSTED_ORIGINS explícito |
Previne bypass baseado em referer em deployments com proxy/CDN |
| DRF para endpoints com autenticação dupla | SessionAuthentication do DRF aplica CSRF internamente; views puras não |
Testando Sua Defesa
Testes Unitários
# tests/test_csrf.py
from django.test import TestCase, Client
from django.contrib.auth.models import User
class CSRFProtectionTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
'alice', email='alice@example.com', password='testpass123'
)
self.client = Client(enforce_csrf_checks=True) # ← crítico
self.client.login(username='alice', password='testpass123')
def test_post_sem_csrf_token_retorna_403(self):
"""Um POST sem token CSRF deve ser rejeitado."""
response = self.client.post('/configuracoes/atualizar/', {
'email': 'atacante@evil.com',
})
self.assertEqual(response.status_code, 403)
def test_post_com_csrf_token_valido_sucede(self):
"""Um POST com token CSRF válido deve ser aceito."""
# GET na página do formulário primeiro para obter o cookie CSRF
get_response = self.client.get('/configuracoes/atualizar/')
csrf_token = get_response.cookies.get('csrftoken')
self.assertIsNotNone(csrf_token)
response = self.client.post('/configuracoes/atualizar/', {
'email': 'alice-nova@example.com',
'csrfmiddlewaretoken': csrf_token.value,
})
self.assertIn(response.status_code, [200, 302]) # sucesso ou redirect
def test_endpoint_alteracao_estado_rejeita_get(self):
"""Endpoints de alteração de estado não devem aceitar requisições GET."""
response = self.client.get('/contas/deletar/')
# Deve retornar a página de confirmação, NÃO realizar a exclusão
self.assertTrue(User.objects.filter(username='alice').exists())
Buscando por @csrf_exempt
# Encontrar todo uso de csrf_exempt no projeto
grep -rn "csrf_exempt" --include="*.py" .
# Saída esperada para um projeto seguro: zero correspondências fora de arquivos de teste
# Qualquer correspondência em views.py ou urls.py é uma descoberta que precisa justificativa
Verificação Manual
# Tentar um ataque CSRF contra um endpoint de alteração de estado
# Passo 1: Obter o cookie de sessão fazendo login
curl -c cookies.txt -X POST https://staging.example.com/accounts/login/ \
-d "username=alice&password=testpass123&csrfmiddlewaretoken=$(curl -s -c - https://staging.example.com/accounts/login/ | grep csrftoken | awk '{print $7}')"
# Passo 2: POST sem o token CSRF — deve retornar 403
curl -b cookies.txt -X POST https://staging.example.com/configuracoes/atualizar/ \
-d "email=atacante@evil.com"
# Esperado: 403 Forbidden
# Passo 3: Verificar que a resposta contém a página de falha CSRF do Django
# O corpo conterá "Forbidden (403)" e "CSRF verification failed"
Verificando Atributos do Cookie
# Verificar flags do cookie CSRF em produção
curl -I https://seusite.com/ 2>/dev/null | grep -i set-cookie
# Esperado (produção):
# Set-Cookie: csrftoken=...; ... Secure; SameSite=Lax
# Se CSRF_COOKIE_HTTPONLY=True: também HttpOnly
O Que Encontrei nos Meus Projetos
A descoberta que realmente me fez parar não foi uma vulnerabilidade. Foi uma decisão de design que eu já tinha colocado em produção.
A view petition_share do Petição Brasil incrementa um contador de compartilhamentos via GET:
def petition_share(request, uuid):
petition = get_object_or_404(Petition, uuid=uuid)
petition.increment_share_count() # alteração de estado via GET
# ...
return JsonResponse({...})
Isso é um GET que altera estado, exatamente o padrão que este post alerta contra. O impacto é baixo (um atacante pode inflar um contador de compartilhamentos, não roubar dados), e converter para POST complicaria o fluxo de compartilhamento porque o frontend o dispara a partir de cliques em links de redes sociais. Coloquei em produção conscientemente. Mas escrever este post me deixou desconfortável com essa decisão de um jeito que não estava antes, porque agora consigo ver a forma exata da classe de vulnerabilidade a que ela pertence. Continua na minha lista de refatoração com uma nota: "converter para POST se o contador de compartilhamentos alimentar um algoritmo de ranking ou visibilidade."
O resto da auditoria estava limpo. Zero decoradores @csrf_exempt em qualquer lugar da codebase. Todas as doze instâncias de <form method="post"> em todos os templates (registro, login, redefinição de senha, criação de petição, exclusão de petição, submissão de assinatura, denúncia de petição, ambos os formulários de logout no template base) incluem {% csrf_token %}. O endpoint de denúncia até aplica @csrf_protect por cima do middleware:
@require_http_methods(["POST"])
@csrf_protect
def flag_petition(request, uuid):
"""Handle petition flagging/reporting."""
As configurações de cookie em produção estão endurecidas (CSRF_COOKIE_SECURE = True, CSRF_COOKIE_HTTPONLY = True, CSRF_COOKIE_SAMESITE = 'Lax'). A única coisa com que não estou totalmente satisfeito é CSRF_TRUSTED_ORIGINS incluindo https://*.herokuapp.com, que confia em todos os apps Heroku da plataforma. Está lá para review apps e staging, mas é mais amplo do que eu gostaria. Restringir ao nome exato do app está na lista.
A lição do CSRF é mais simples do que a mecânica do ataque sugere: o Django já resolveu este problema, e a única forma de criar a vulnerabilidade é desfazer ativamente a solução. Deixe o CsrfViewMiddleware em paz, coloque {% csrf_token %} em todo formulário, e trate qualquer impulso de usar @csrf_exempt como um sinal de que o problema real está em outro lugar.
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 Docs — Cross Site Request Forgery Protection
- Django Docs — How to Use Django's CSRF Protection
- DRF Docs — SessionAuthentication and CSRF
- OWASP A01:2021 — 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