Controle de Acesso Quebrado e IDOR: Quando Estar Logado Não É o Mesmo que Ter Permissão
Django Security Series — Post 6 | Série II: Broken Access Control
OWASP A01:2021 — Broken Access Control | Tempo de leitura: ~20 min
🧪 Rode você mesmo. Este ataque vem como um laboratório executável no django-security-lab: uma view de nota sob
@login_requiredque busca um objeto pela chave primária sem escopo de propriedade, e uma versão segura que escopa a busca aorequest.user. Tudo é exercitado pela linha de comando — faça login comoalice, leia a nota do bob pelo id em/idor/vulnerable/<pk>/e veja a flag vazar, depois receba um404indistinguível em/idor/secure/<pk>/. Clone e reproduza cada passo.
A Série I foi sobre injeção: dados cruzando para código em uma fronteira de interpretador. Ao longo de cinco posts o interpretador mudou (o motor SQL, o navegador, o motor de templates, o shell do SO, o parser XML) mas a causa raiz nunca mudou: entrada não confiável era analisada como sintaxe em vez de tratada como um valor. A Série II abre um modo de falha completamente diferente. O código está sintaticamente correto, as queries são parametrizadas, os templates estão escapados, o shell nunca é invocado. Mas a aplicação ainda entrega a um usuário os dados de outro, porque verificou quem você é sem nunca verificar o que você tem permissão de tocar.
O cenário ao qual eu sempre voltava enquanto aprendia esta classe é uma plataforma de petições cívicas — petições pertencem a seus criadores, assinaturas às pessoas que as assinaram. Um cidadão logado abre a página de edição da sua própria petição em /petition/41/edit/, muda o 41 para 42, e a view — guardada por nada além de @login_required — lhe entrega a petição de outra pessoa para editar. Nenhum exploit, nenhum payload, um dígito trocado. Troque esse registro vazado por uma assinatura — um nome completo, um CPF, um e-mail — e o cenário muda completamente: divulgar dados de outra pessoa sem autorização não é apenas um bug de lógica, mas um bug jurídico, do tipo que a LGPD trata como um incidente de segurança notificável (os Incidentes Reais abaixo mostram o que isso custa na prática). É essa a lacuna de que trata este post — a distância entre autenticado e autorizado tem a largura de um filtro de queryset esquecido, e na tabela errada ela tem peso judicial.
Isto é Broken Access Control (Controle de Acesso Quebrado), a categoria de risco #1 da OWASP, e a forma concreta que ela assume com mais frequência em uma aplicação Django é o IDOR (Insecure Direct Object Reference, Referência Direta Insegura a Objeto). O padrão é enganosamente simples. Uma view aceita um identificador de objeto na URL (geralmente a chave primária do banco de dados), busca o objeto e o retorna. A view está atrás de @login_required, então visitantes não autenticados são redirecionados para a página de login. Mas uma vez logado, qualquer usuário logado pode solicitar qualquer objeto, porque a busca nunca pergunta se o usuário solicitante é o dono dele ou tem qualquer relação com ele. Mude /invoice/41/ para /invoice/42/ e você está lendo a fatura de outra pessoa. O ORM faz exatamente o que você mandou, get_object_or_404(Invoice, pk=42), e o que você mandou não tem conceito de propriedade.
A razão pela qual esta é a vulnerabilidade web #1 e não a #10 é que a lacuna é invisível em todos os estágios do desenvolvimento normal. O código é limpo, os testes passam (foram escritos pelo mesmo desenvolvedor que escreveu a view, usando o mesmo usuário), a revisão de código vê um decorator @login_required e segue em frente, e a funcionalidade trabalha perfeitamente em cada demonstração porque demonstrações não envolvem um segundo usuário tentando os IDs do primeiro. A falha não é uma biblioteca ausente ou uma configuração errada; é um conceito ausente. A diferença entre autenticação ("quem é você?") e autorização ("você tem permissão para fazer isso com este objeto específico?"). O Django te dá autenticação quase de graça e deixa a autorização quase inteiramente por sua conta, e a lacuna entre as duas é onde o IDOR vive.
Neste post veremos como o IDOR funciona na camada de aplicação, por que o sistema de autenticação do Django não o cobre, os padrões específicos de Django REST Framework (DRF) e views baseadas em classe que tornam fácil publicá-lo, e como fechar a lacuna escopando cada queryset ao usuário autenticado — além da armadilha do has_object_permission do DRF que silenciosamente pula a verificação que você pensou ter.
O Ataque: O Que É e Como Funciona
Uma view Django recebe uma requisição para /invoice/42/. Ela chama get_object_or_404(Invoice, pk=42) e retorna o resultado. A view é decorada com @login_required, então visitantes anônimos são redirecionados para a página de login. Parece seguro, certo? Só que o Usuário A pode mudar a URL de /invoices/41/ para /invoices/42/ e ler a fatura do Usuário B sem nenhuma resistência. O ORM retornou a linha porque pk=42 existe. O decorator confirmou que alguém está logado. Ninguém perguntou se esse alguém é dono da fatura 42. A chave primária é uma referência direta: mapeia um-para-um a uma linha do banco de dados, e o usuário a controla pela URL.
Essa pergunta ausente é a lacuna entre autenticação (quem está fazendo esta requisição?) e autorização (essa identidade tem permissão para realizar esta ação neste recurso?). Broken Access Control é qualquer falha na segunda etapa, e sua forma concreta mais comum é o IDOR (Insecure Direct Object Reference): uma referência direta a um objeto interno, geralmente uma chave primária do banco de dados, é exposta na URL, e a aplicação nunca verifica se o usuário autenticado tem qualquer relação com o objeto para o qual ela aponta. O desenvolvedor confundiu "autenticado" (você tem uma sessão válida) com "autorizado" (você pode acessar este objeto específico). Essa confusão é toda a vulnerabilidade.
A variante mais comum é o IDOR horizontal: o Usuário A acessa recursos do Usuário B no mesmo nível de privilégio. Faturas, prontuários médicos, mensagens. O atacante é um usuário normal, autenticado, que fez login legitimamente, tem uma sessão válida e simplesmente muda o ID na URL, no parâmetro de query ou no corpo da requisição. Nenhum toolkit de exploits, nenhum payload de injeção, nenhum bypass. Apenas um número diferente. Existe também o IDOR vertical, em que um usuário comum alcança um recurso de privilégio mais alto (um painel administrativo, um registro exclusivo de staff), mas isso é mais raro em aplicações Django típicas e se sobrepõe fortemente à escalação de privilégios, que o próximo post cobre.
O ataque não exige nada mais do que um navegador e curiosidade básica. Um atacante que pode ver sua própria fatura em /invoice/41/ tenta /invoice/40/, /invoice/42/ e assim por diante; se a view retorna dados para IDs que ele não possui, a aplicação é vulnerável — e na prática tudo isso é automatizado em segundos. O atacante faz login legitimamente, anota a URL do seu próprio recurso (digamos /api/documents/1053/) e aponta um script simples — ou o Intruder do Burp Suite — para uma faixa de IDs, enviando uma requisição GET autenticada para cada um com seu cookie de sessão anexado automaticamente. Qualquer resposta que volte 200 OK com um corpo é dado de outro usuário; um 404 ou 403 significa que aquela view está protegida, mas em uma aplicação vulnerável todo ID retorna 200.
O ataque escala trivialmente porque chaves primárias são inteiros sequenciais por padrão. O atacante não precisa adivinhar — basta contar. Mesmo quando uma aplicação usa UUIDs, um único UUID vazado (em um link de e-mail, uma URL compartilhada, um cabeçalho referrer ou uma resposta de API que lista recursos sem escopo) dá ao atacante uma referência válida para tentar, e a ausência de uma verificação de propriedade significa que funciona.
Incidentes Reais
First American Financial Corporation — Exposição de Dados por IDOR (2019)
Em maio de 2019 o jornalista de segurança Brian Krebs reportou que a First American Financial Corporation, uma das maiores empresas de seguros de títulos dos Estados Unidos, havia exposto aproximadamente 885 milhões de registros datando desde 2003 por meio de uma vulnerabilidade IDOR direta. O site da empresa permitia que agentes de títulos compartilhassem imagens de documentos via URL direta — e essa URL continha um número de documento sequencial e previsível. Mudar o número retornava o documento de outro cliente. Nenhuma autenticação era necessária: qualquer pessoa com o padrão da URL podia recuperar qualquer documento iterando o ID. Os registros expostos incluíam números de Seguro Social, imagens de carteiras de motorista, números de contas bancárias, registros fiscais e comprovantes de transferências eletrônicas — tudo necessário para roubo de identidade em escala industrial. A vulnerabilidade havia sido identificada em uma revisão interna de segurança meses antes e nunca foi remediada. O Departamento de Serviços Financeiros de Nova York multou a empresa, e a Comissão de Valores Mobiliários dos EUA (SEC) acusou a First American de falhas em controles de divulgação, porque emitiu declarações públicas sobre o incidente sem que seus executivos sêniores soubessem que a vulnerabilidade já havia sido sinalizada internamente.
A lição para desenvolvedores Django é que o IDOR não é um ataque exótico — é a vulnerabilidade mais mundana que existe. Não houve SQL Injection, nem zero-day, nem cadeia de exploits sofisticada. Toda a violação foi uma verificação de autorização ausente em uma view que servia documentos por ID sequencial. Uma única linha — o equivalente a adicionar owner=request.user a uma busca de queryset — teria prevenido a exposição de 885 milhões de registros. O acesso inicial mapeia para MITRE ATT&CK T1190 (Exploit Public-Facing Application), e a coleta de dados mapeia para T1213 (Data from Information Repositories) — o atacante simplesmente leu documentos que a aplicação servia a qualquer um que pedisse. A exposição regulatória não é exclusiva dos Estados Unidos: no Brasil, o mesmo vazamento de dados pessoais — um nome, um CPF, um e-mail — por meio de uma busca sem escopo é um incidente de segurança sob a LGPD, obrigando o controlador a comunicar a ANPD e os titulares afetados (Art. 48), de modo que um owner=request.user esquecido é uma violação notificável, não apenas um bug.
Proteções Padrão do Django
O framework de autenticação do Django é excelente, e esse é precisamente o problema, porque sua excelência cria uma falsa sensação de completude. Eis o que o Django de fato te dá:
@login_required/LoginRequiredMixin— redireciona usuários não autenticados para a página de login. Isso é autenticação: estabelece identidade. Não diz nada sobre o que o usuário autenticado pode acessar.request.user.is_authenticated— o booleano que você verifica em um template ou view. Mesmo escopo: identidade, não permissão.- O sistema de permissões
auth(has_perm,PermissionRequiredMixin,@permission_required) — verifica se um usuário possui uma permissão no nível do modelo comoblog.change_post. Isso é autorização de granularidade grossa: "este usuário pode alterar qualquer post?" Não responde "este usuário pode alterar este post específico?" - Rotação de chave de sessão no login (
login()chamacycle_key()) — previne fixação de sessão (Post 12 nesta série) mas é irrelevante para autorização.
Eis o que o Django não te dá:
- Autorização em nível de objeto. O ORM vai alegremente retornar qualquer linha que você pedir.
Invoice.objects.get(pk=42)retorna a fatura 42 independentemente de quem está pedindo. Não há filtro embutido "retornar apenas objetos que este usuário possui" — o desenvolvedor deve adicioná-lo a cada queryset em cada view. A própria documentação do Django é explícita sobre isso: o framework de autenticação fornece a base para permissões de objeto —has_perm()aceita um argumentoobjopcional — mas não traz nenhuma implementação concreta no core, então essas verificações sempre retornamFalse(ou uma lista vazia) até que um backend de autenticação customizado forneça a lógica. Esse backend é exatamente o que pacotes de terceiros como odjango-guardianfornecem. - Escopo automático de queryset. Diferente de alguns frameworks que aplicam filtros de tenant ou usuário no nível do model manager, o manager padrão do Django retorna
all(). Toda view começa da tabela inteira a menos que o desenvolvedor a restrinja. - Enforcement de
has_object_permissiondo DRF em views de lista. O sistema de permissões do DRF tem um design sutil mas crítico:has_object_permission()é chamado apenas quandoself.get_object()é chamado, o que acontece em retrieve, update e delete, mas nunca em list. UmModelViewSetcujoget_queryset()retornaModel.objects.all()vai listar cada linha da tabela para cada usuário autenticado, mesmo quehas_object_permissiontivesse negado acesso a cada uma individualmente. A permissão que você pensou ter é silenciosamente pulada.
Esse último me pegou de surpresa. Eu inicialmente assumi que se eu definisse has_object_permission() em uma classe de permissão DRF, o viewset estava totalmente protegido. Não foi até eu ler o código-fonte do DRF para este post que percebi que list() nunca chama get_object(), então o método de permissão que eu escrevi nunca era invocado no endpoint que retorna mais dados. A ação de lista chama self.get_queryset() diretamente, itera as linhas e as serializa. Sua permissão em nível de objeto não faz parte desse caminho. É o tipo de lacuna que passa em toda revisão de código porque a permissão existe — ela só nunca executa.
O Django trata a autenticação de fábrica e deixa a autorização em nível de objeto inteiramente para o desenvolvedor. Todo IDOR em uma aplicação Django vive nessa lacuna.
Padrão Vulnerável: O Que NÃO Fazer
Padrão 1 — Uma view de detalhe que verifica autenticação mas não propriedade
# INSEGURO — qualquer usuário autenticado pode ler qualquer fatura mudando o PK
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, render
@login_required
def invoice_detail(request, pk):
invoice = get_object_or_404(Invoice, pk=pk) # sem verificação de propriedade
return render(request, 'billing/invoice_detail.html', {'invoice': invoice})
O que dá errado: O Usuário A está logado e visualiza sua fatura em /invoices/41/. Ele muda a URL para /invoices/42/ e recebe a fatura do Usuário B. O decorator @login_required confirmou que alguém está logado — nunca perguntou se esse alguém é dono da fatura 42. O ORM retornou a linha porque pk=42 existe, e a view a serviu sem questionar.
Padrão 2 — Um ModelViewSet DRF com queryset sem escopo
Este é mais sutil que o Padrão 1, porque o design baseado em classes do DRF parece que deveria lidar com permissões automaticamente. O desenvolvedor define permission_classes = [permissions.IsAuthenticated], talvez até adiciona um has_object_permission customizado na classe de permissão, e assume que o viewset está protegido. Mas queryset = Document.objects.all() entrega ao DRF toda linha da tabela. O endpoint de lista (GET /api/documents/) retorna todos os documentos de todos os usuários. E aqui está a parte que me pegou: o DRF só chama has_object_permission quando get_object() é executado, o que significa que a ação de lista ignora a verificação inteiramente.
# INSEGURO — queryset retorna todo documento do banco de dados
from rest_framework import viewsets, permissions
from .models import Document
from .serializers import DocumentSerializer
class DocumentViewSet(viewsets.ModelViewSet):
queryset = Document.objects.all() # toda linha, todo usuário
serializer_class = DocumentSerializer
permission_classes = [permissions.IsAuthenticated]
Padrão 3 — Uma view de atualização que modifica o objeto de outro usuário
O lado de escrita do IDOR é pior que o lado de leitura. O Usuário A navega para /profiles/42/edit/ e envia um POST. A view busca o perfil 42, vincula o formulário a ele, salva os dados do atacante sobre o registro do usuário legítimo. Um decorator @login_required ficou no portão e deixou tudo passar.
# INSEGURO — qualquer usuário autenticado pode editar o perfil de qualquer outro usuário
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, redirect
from .forms import ProfileForm
from .models import Profile
@login_required
def edit_profile(request, pk):
profile = get_object_or_404(Profile, pk=pk) # ← este é o problema inteiro
if request.method == 'POST':
form = ProfileForm(request.POST, instance=profile)
if form.is_valid():
form.save() # salva dados do atacante sobre registro da vítima
return redirect('profile_detail', pk=pk)
else:
form = ProfileForm(instance=profile)
return render(request, 'accounts/edit_profile.html', {'form': form})
Implementação Segura: O Jeito Django
Regra 1 — Escope cada queryset ao usuário autenticado
A correção primária é um conceito aplicado em todo lugar: nunca busque um objeto da tabela inteira — sempre filtre por propriedade primeiro. Quando o queryset é escopado ao usuário solicitante, um ID que pertence a outro usuário simplesmente não existe no conjunto de resultados, e a busca retorna um 404 — exatamente a mesma resposta de um ID genuinamente inexistente. O atacante não aprende nada, e os dados permanecem privados.
# SEGURO — o queryset é escopado ao usuário solicitante; faturas de outros usuários não existem
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, render
@login_required
def invoice_detail(request, pk):
invoice = get_object_or_404(Invoice, pk=pk, owner=request.user)
return render(request, 'billing/invoice_detail.html', {'invoice': invoice})
A correção é a adição de owner=request.user à busca. get_object_or_404 agora consulta Invoice.objects.filter(pk=pk, owner=request.user) — se a fatura pertence a um usuário diferente, o filtro retorna um queryset vazio, e o Django lança um 404. Nenhuma informação sobre se a fatura 42 existe é divulgada; o atacante vê a mesma resposta que receberia para um ID inexistente.
Para operações de escrita, o mesmo princípio se aplica — escope a busca antes de vincular o formulário:
# SEGURO — edit_profile escopado a request.user
@login_required
def edit_profile(request, pk):
profile = get_object_or_404(Profile, pk=pk, user=request.user)
if request.method == 'POST':
form = ProfileForm(request.POST, instance=profile)
if form.is_valid():
form.save()
return redirect('profile_detail', pk=pk)
else:
form = ProfileForm(instance=profile)
return render(request, 'accounts/edit_profile.html', {'form': form})
Em muitos casos você pode ir além e eliminar o PK da URL inteiramente. Um usuário editando seu próprio perfil não precisa fornecer um ID — a view já sabe quem ele é:
# SEGURO — sem PK na URL; o perfil é resolvido a partir da sessão
@login_required
def edit_my_profile(request):
profile = get_object_or_404(Profile, user=request.user)
# ... manipulação de formulário inalterada ...
Regra 2 — No DRF, sobrescreva get_queryset() e implemente has_object_permission()
O ModelViewSet do DRF é o vetor de IDOR mais comum em aplicações Django modernas porque seus padrões ergonômicos — um queryset no nível da classe e um ModelSerializer — tornam trivialmente fácil expor cada linha de uma tabela. A correção tem duas partes, e ambas são necessárias:
Parte A — Sobrescreva get_queryset() para escopar o queryset base. Isso protege a ação de lista (que nunca chama get_object() e portanto nunca dispara has_object_permission):
# SEGURO — toda consulta é escopada ao usuário solicitante
from rest_framework import viewsets, permissions
from .models import Document
from .serializers import DocumentSerializer
class DocumentViewSet(viewsets.ModelViewSet):
serializer_class = DocumentSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return Document.objects.filter(owner=self.request.user)
Agora GET /api/documents/ retorna apenas os documentos do usuário solicitante, e GET /api/documents/42/ retorna 404 se o documento 42 pertence a outra pessoa — porque não está no queryset.
Parte B — Adicione has_object_permission() como camada de defesa em profundidade. Isso pega qualquer caminho de código que chame get_object() (retrieve, update, partial_update, destroy) caso um refatoramento futuro mude o queryset:
# SEGURO — permissão em nível de objeto como uma segunda porta
from rest_framework import permissions
class IsOwner(permissions.BasePermission):
"""Permissão em nível de objeto: apenas o dono pode acessar o objeto."""
def has_object_permission(self, request, view, obj):
return obj.owner == request.user
Conecte ao viewset:
class DocumentViewSet(viewsets.ModelViewSet):
serializer_class = DocumentSerializer
permission_classes = [permissions.IsAuthenticated, IsOwner]
def get_queryset(self):
return Document.objects.filter(owner=self.request.user)
O get_queryset() escopado é o controle primário; IsOwner.has_object_permission() é a rede de segurança. Juntos fecham tanto o caminho de lista quanto o de detalhe.
Regra 3 — Use UserPassesTestMixin para views baseadas em classe que precisam de verificações flexíveis
Quando o modelo de propriedade é mais complexo do que um único FK owner — por exemplo, um documento compartilhado com uma equipe, ou um registro acessível por papel — UserPassesTestMixin permite expressar a verificação como um método:
# SEGURO — view baseada em classe com teste explícito de propriedade
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.views.generic import DetailView
from .models import MedicalRecord
class MedicalRecordDetailView(LoginRequiredMixin, UserPassesTestMixin, DetailView):
model = MedicalRecord
def test_func(self):
record = self.get_object()
return (
record.patient == self.request.user
or self.request.user.groups.filter(name='doctors').exists()
)
UserPassesTestMixin chama test_func() antes da view rodar. Se retornar False, o usuário recebe um 403 Forbidden. Esta é a forma mais limpa de expressar autorização multi-condição no sistema de views baseadas em classe do Django sem recorrer a uma biblioteca de terceiros.
Regra 4 — Trate UUIDs como obscuridade, não como segurança
UUIDs eliminam a enumeração casual (você não pode simplesmente incrementar de /invoice/41/ para /invoice/42/), e isso vale a pena fazer. Mas eles vazam constantemente: em URLs, cabeçalhos Referer, links de e-mail, respostas de API que listam objetos relacionados, histórico do navegador, logs. Uma vez que um único UUID é conhecido, a ausência de verificação de propriedade significa que ele funciona tão bem quanto um inteiro sequencial. Use-os como identificadores públicos, mas o queryset ainda precisa ser escopado ao usuário:
# UUIDs são bons como identificador público, mas a verificação de propriedade continua necessária
import uuid
from django.db import models
class Document(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey('auth.User', on_delete=models.CASCADE)
# ...
# A view AINDA escopa por dono — o UUID sozinho não é o controle de acesso
@login_required
def document_detail(request, pk):
doc = get_object_or_404(Document, pk=pk, owner=request.user)
return render(request, 'docs/detail.html', {'document': doc})
Checklist de Prevenção de IDOR
| Controle | O que cobre |
|---|---|
Escopar cada queryset por request.user (ou pela org/equipe do usuário) |
O vetor primário de IDOR — uma busca sem escopo retorna qualquer objeto por PK |
No DRF, sobrescrever get_queryset() (não apenas queryset) |
O bypass da ação de lista — has_object_permission nunca é chamado em list |
Adicionar has_object_permission() como camada de defesa em profundidade |
O caminho de detalhe/atualização/exclusão — pega regressões se o escopo do queryset mudar |
Retornar 404 (não 403) para objetos fora do escopo do usuário |
Vazamento de informação — um 403 confirma que o objeto existe; um 404 não revela nada |
| Eliminar PKs das URLs onde possível (resolver a partir da sessão) | Reduz a superfície de ataque — nenhum ID para enumerar se a view resolve a partir de request.user |
| Usar UUIDs como identificadores públicos (defesa em profundidade, não controle primário) | Enumeração casual — eleva a barreira para varredura de força bruta, mas não substitui a verificação de propriedade |
Um porém sobre a escolha de 404 em vez de 403 do checklist, porque é uma bifurcação real: um 403 em um ID enumerável ainda vaza informação — confirma que o registro existe, apenas não é seu — então, em um PK sequencial, uma sondagem consegue mapear exatamente quais registros são reais. Escopar o queryset e retornar 404 tanto para "não existe" quanto para "não é seu" é o padrão mais forte, e é o que as views seguras acima já fazem. O único preço é um usuário legítimo que digita errado a própria URL receber um 404 seco — e isso é um problema de texto a resolver na página 404, não um motivo para rebaixar para um 403 que vaza a existência.
A Visão do Analista
O IDOR é o exemplo mais claro desta série da distinção que o CySA+ faz entre autenticação e autorização — e de por que o controle de acesso é um controle preventivo que você projeta desde o início em vez de acoplar depois. O Django te dá autenticação quase de graça; a autorização é sua para impor em cada acesso a objeto, e a imposição pertence à query (owner=request.user), não a um if posterior que uma refatoração futura pode silenciosamente derrubar. Escopar o queryset é o controle preventivo; retornar um 404 indistinguível é uma pequena camada compensatória por cima (nega ao atacante até a confirmação de que um objeto existe); e uma verificação has_object_permission() do DRF é um segundo portão, de defesa em profundidade, nos caminhos que de fato chamam get_object().
O que torna o IDOR um problema distintamente de analista é o que acontece depois que você encontra um: não há assinatura para casar nem CVE para corrigir. O achado é "este endpoint retorna objetos que não deveria", e confirmá-lo significa raciocinar sobre quem deveria alcançar o quê e então testar a fronteira com uma segunda conta. É por isso que os scanners da próxima seção conseguem apontar a busca suspeita mas não conseguem dar o veredito — o modelo de propriedade vive na sua cabeça e nos seus dados, não na sintaxe do código. O que você pode automatizar é "uma busca de objeto sem escopo de dono"; o julgamento, "e esse objeto é de outra pessoa", continua humano.
Detectando Automaticamente
Testando Sua Defesa
A prova que fecha um IDOR precisa de dois usuários — um que é dono do objeto e um que não é — e afirma que o dono recebe 200 enquanto o estranho recebe 404 (não 200 com os dados de outra pessoa, e não 403, que confirmaria que o objeto existe). O teste unitário exercita a view diretamente:
# tests/test_idor.py
from django.test import TestCase
from django.contrib.auth.models import User
from billing.models import Invoice
class IDORTests(TestCase):
def setUp(self):
self.user_a = User.objects.create_user('alice', password='testpass123')
self.user_b = User.objects.create_user('bob', password='testpass123')
self.invoice_a = Invoice.objects.create(
owner=self.user_a, amount=100, reference='INV-001'
)
self.invoice_b = Invoice.objects.create(
owner=self.user_b, amount=200, reference='INV-002'
)
def test_user_can_access_own_invoice(self):
"""Um usuário autenticado pode visualizar sua própria fatura."""
self.client.login(username='alice', password='testpass123')
response = self.client.get(f'/invoices/{self.invoice_a.pk}/')
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'INV-001')
def test_user_cannot_access_other_users_invoice(self):
"""O Usuário A NÃO deve poder visualizar a fatura do Usuário B — a view
deve retornar 404, não 200 com os dados de outro usuário."""
self.client.login(username='alice', password='testpass123')
response = self.client.get(f'/invoices/{self.invoice_b.pk}/')
self.assertEqual(response.status_code, 404)
def test_unauthenticated_user_is_redirected(self):
"""Uma requisição não autenticada deve redirecionar para login, não servir dados."""
response = self.client.get(f'/invoices/{self.invoice_a.pk}/')
self.assertEqual(response.status_code, 302)
self.assertIn('/login', response.url)
def test_user_cannot_update_other_users_invoice(self):
"""O Usuário A NÃO deve poder modificar a fatura do Usuário B."""
self.client.login(username='alice', password='testpass123')
response = self.client.post(
f'/invoices/{self.invoice_b.pk}/edit/',
{'amount': 0, 'reference': 'HACKED'},
)
self.assertEqual(response.status_code, 404)
self.invoice_b.refresh_from_db()
self.assertEqual(self.invoice_b.amount, 200) # inalterado
self.assertEqual(self.invoice_b.reference, 'INV-002') # inalterado
A mesma forma vale para uma API DRF — e acrescenta a verificação da ação de lista que pega o bypass do get_queryset(), onde um viewset serializa cada linha para cada usuário autenticado:
# tests/test_idor_api.py
from rest_framework.test import APITestCase
from django.contrib.auth.models import User
from documents.models import Document
class DocumentAPIIDORTests(APITestCase):
def setUp(self):
self.user_a = User.objects.create_user('alice', password='testpass123')
self.user_b = User.objects.create_user('bob', password='testpass123')
self.doc_a = Document.objects.create(owner=self.user_a, title='Alice doc')
self.doc_b = Document.objects.create(owner=self.user_b, title='Bob doc')
def test_list_returns_only_own_documents(self):
"""GET /api/documents/ deve retornar apenas os documentos do usuário solicitante."""
self.client.force_authenticate(user=self.user_a)
response = self.client.get('/api/documents/')
titles = [d['title'] for d in response.data]
self.assertIn('Alice doc', titles)
self.assertNotIn('Bob doc', titles)
def test_retrieve_other_users_document_returns_404(self):
"""GET /api/documents/<pk do bob>/ deve retornar 404, não 200."""
self.client.force_authenticate(user=self.user_a)
response = self.client.get(f'/api/documents/{self.doc_b.pk}/')
self.assertEqual(response.status_code, 404)
def test_delete_other_users_document_returns_404(self):
"""DELETE /api/documents/<pk do bob>/ deve retornar 404 e deixar a linha intacta."""
self.client.force_authenticate(user=self.user_a)
response = self.client.delete(f'/api/documents/{self.doc_b.pk}/')
self.assertEqual(response.status_code, 404)
self.assertTrue(Document.objects.filter(pk=self.doc_b.pk).exists())
Você também pode provar isso à mão contra uma instância em execução — faça login como um usuário, solicite o id de outro e espere um 404:
# Faça login como usuário A, note um ID de fatura que pertence ao usuário A
curl -c cookies.txt -X POST https://staging.example.com/login/ \
-d "username=alice&password=testpass123&csrfmiddlewaretoken=..."
# Solicite a fatura do próprio usuário A — espere 200
curl -b cookies.txt https://staging.example.com/invoices/41/
# Solicite a fatura do usuário B — espere 404 (não 200 com dados do usuário B)
curl -b cookies.txt https://staging.example.com/invoices/42/
Fazendo a Varredura
Rode os scanners padrão em que esta série se apoia desde o Post 1 contra as duas views do laboratório e algo instrutivo acontece: nenhum dos dois encontra o bug. O IDOR é a primeira classe da série que as ferramentas de prateleira genuinamente perdem — não por má configuração, mas porque não há nenhuma chamada perigosa para sinalizar.
O Bandit — que percorre a AST do Python procurando construções arriscadas por nome — não reporta nada em nenhuma das views. Suas verificações são todas sobre operações perigosas (eval, subprocess … shell=True, mark_safe, yaml.load), e get_object_or_404(Note, pk=pk) é uma chamada perfeitamente comum. A única coisa que o Bandit sinaliza no módulo inteiro é uma senha embutida no arquivo de teste — o labpass do usuário de fixture — que é ruído sem relação com a classe, e um lembrete útil de que um relatório limpo sobre uma view genuinamente vulnerável é um miss, não um passe.
Os pacotes da comunidade do Semgrep (p/django, p/python, p/owasp-top-ten) também não reportam nada — 156 regras Python, zero achados. A própria documentação do Semgrep explica por quê, e serve também como licença para escrever a nossa própria regra: o IDOR "é a ausência de uma verificação de autorização … o código vulnerável parece sintaticamente correto", e a resposta recomendada é "escrever regras personalizadas para a sua aplicação que descrevam a lógica de controle de acesso que você espera". A view vulnerável e a corrigida diferem por um único predicado específico da aplicação — owner=request.user — e nenhuma regra genérica pode saber que uma Note tem um dono que deveria corresponder ao solicitante.
Por isso este post traz a terceira regra Semgrep personalizada do repositório companheiro, rules/idor.yaml. Ela é deliberadamente sintática: sinaliza uma busca get_object_or_404 / get_list_or_404 que não carrega escopo owner= ou user=, então dispara na view vulnerável e fica em silêncio na correção escopada ao dono — o teste dispara-na-vulnerável / silencia-na-segura que as ferramentas padrão não conseguiram dar, imposto em um job de CI hermético contra uma fixture pareada pelo nome do arquivo. Seus limites honestos (um campo de escopo que ela não reconhece; um Model.objects.get(pk=...) cru fora do atalho) estão documentados nessa fixture.
# as ferramentas padrão — perdem a classe
bandit -r labs/post_06_idor/
semgrep scan --config p/django --config p/python --config p/owasp-top-ten labs/post_06_idor/
# a regra personalizada — pega
semgrep scan --config rules/idor.yaml labs/post_06_idor/views_vulnerable.py # 1 achado (linha 23)
semgrep scan --config rules/idor.yaml labs/post_06_idor/views_secure.py # 0 achados
Aqui não há, deliberadamente, DAST de apertar-o-botão. O IDOR é uma classe de runtime, então um scanner black-box consegue alcançá-la — mas um crawler percorrendo /idor/vulnerable/1/, /2/, /3/ vê três 200s e nenhum erro, sem como saber que a nota 2 é do Bob e deveria estar fora do alcance da Alice. O teste de controle de acesso do OWASP ZAP e do Burp pode ajudar — conduzir duas sessões autenticadas e comparar o que cada uma alcança — mas um humano ainda fornece a intenção de propriedade. As execuções capturadas de Bandit, Semgrep e da regra personalizada estão versionadas em scans/ para você ler exatamente o que cada uma reportou; o tests.py continua sendo a prova executável do vazamento em si.
A lição a que eu volto sempre: todo get_object_or_404 e todo Model.objects.get(pk=...) precisa responder uma pergunta. Este queryset está escopado ao usuário solicitante? Se não está, a view é um IDOR. UUIDs dificultam a exploração mas não corrigem a lógica; verificações de propriedade corrigem a lógica.
O Post 7 leva a mesma falha na vertical: Privilege Escalation (Escalação de Privilégios), em que o atacante não apenas lê dados de outro usuário mas se promove a um papel superior, alterando is_staff ou is_superuser através de um formulário ou serializer que nunca deveria ter exposto esses campos.
Leitura Complementar
- django-security-lab — o laboratório executável deste post (
labs/post_06_idor/) - A regra Semgrep personalizada deste post —
rules/idor.yaml(com sua fixture de teste,rules/idor.py) - Django Docs — Permissions and Authorization
- Django Docs — Custom Permissions
- DRF Docs — Object-Level Permissions
- PortSwigger Web Security Academy — Insecure Direct Object References (IDOR)
- OWASP A01:2021 — Broken Access Control
- OWASP — IDOR Prevention Cheat Sheet
- OWASP — Authorization Cheat Sheet
- MITRE ATT&CK — T1190 Exploit Public-Facing Application
- Web Security for Developers: Real Threats, Practical Defense (Malcolm McDonald, No Starch Press) — Capítulo 11: Access Control
Next in this series → Post 7: Escalação de Privilégios: Como fields = '__all__' Entrega ao Atacante as Chaves do Seu Django Admin