Controle de Acesso Quebrado e IDOR: Quando Estar Logado Não É o Mesmo que Ter Permissão

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: ~15 min

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 e 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.

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 é desarmantemente 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 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

Controle de acesso é um processo de duas etapas: a autenticação estabelece a identidade (quem está fazendo esta requisição?), e a autorização estabelece a permissão (essa identidade tem permissão para realizar esta ação neste recurso?). Broken Access Control é qualquer falha na segunda etapa. A forma concreta mais comum é o IDOR — Insecure Direct Object Reference — em que uma referência direta a um objeto interno (uma chave primária do banco de dados, um nome de arquivo, um número sequencial de pedido) é exposta ao usuário, e a aplicação nunca verifica se o usuário autenticado tem direito ao objeto para o qual aquela referência aponta.

A mecânica é direta. Uma view Django recebe uma requisição para /invoice/42/. Ela chama Invoice.objects.get(pk=42) — ou, equivalentemente, get_object_or_404(Invoice, pk=42) — e retorna o resultado. A chave primária 42 é uma referência direta: ela mapeia um-para-um a uma linha do banco de dados, e o usuário a controla pela URL. A view é decorada com @login_required, então um visitante anônimo é redirecionado para a página de login. Mas uma vez logado, qualquer usuário autenticado pode fornecer qualquer inteiro, e o ORM vai retornar qualquer linha que corresponda — independentemente de quem a criou, quem é o dono, ou se o usuário solicitante tem alguma razão para vê-la. O desenvolvedor confundiu autenticado (você tem uma sessão válida) com autorizado (você pode acessar este objeto específico), e a lacuna é toda a vulnerabilidade.

O IDOR vem em duas variedades, e a distinção importa porque o impacto difere:

Tipo O que o atacante faz Exemplo
Horizontal Acessa recursos de outro usuário no mesmo nível de privilégio Usuário A lê as faturas, prontuários médicos, mensagens do Usuário B
Vertical Acessa recursos de um papel com privilégio mais alto Um usuário comum acessa um endpoint de painel administrativo ou modifica um registro exclusivo de staff

O IDOR horizontal é de longe a variante mais comum em aplicações Django. O atacante é um usuário normal, autenticado — ele 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.

Como os Atacantes Exploram

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. Na prática, o processo é automatizado em segundos:

  1. O atacante faz login legitimamente e anota a URL do seu próprio recurso — por exemplo, /api/documents/1053/.
  2. Ele escreve um script simples (ou usa o Intruder do Burp Suite) para iterar sobre uma faixa de IDs: 1 a 10000.
  3. Para cada ID, ele envia uma requisição GET autenticada (seu cookie de sessão é anexado automaticamente).
  4. Qualquer resposta que retorne 200 OK com um corpo de resposta é dado de outro usuário. Um 404 ou 403 significa que a 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.

O mapeamento MITRE ATT&CK relevante é T1190 — Exploit Public-Facing Application para a exploração inicial da aplicação web, combinado com a falha mais ampla de controle de acesso que o OWASP Top 10 captura como A01. Quando o IDOR é usado para exfiltrar dados em escala (como no incidente abaixo), T1530 — Data from Cloud Storage ou T1213 — Data from Information Repositories também podem se aplicar, dependendo do backend de armazenamento.


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.

Source: KrebsOnSecurity — First American Financial Corp. Leaked Hundreds of Millions of Title Insurance Records


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 como blog.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() chama cycle_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 argumento obj opcional — mas não traz nenhuma implementação concreta no core, e aponta para backends de terceiros como django-guardian para aplicações que precisam disso.
  • 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_permission do DRF em views de lista. O sistema de permissões do DRF tem um design sutil mas crítico: has_object_permission() é chamado apenas quando self.get_object() é chamado — o que acontece em retrieve, update e delete, mas nunca em list. Um ModelViewSet cujo get_queryset() retorna Model.objects.all() vai listar cada linha da tabela para cada usuário autenticado, mesmo que has_object_permission tivesse negado acesso a cada uma individualmente. A permissão que você pensou ter é silenciosamente pulada.

A lacuna é simples de enunciar: 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

# 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]

O que dá errado: A classe de permissão IsAuthenticated garante uma sessão válida, mas queryset = Document.objects.all() entrega ao DRF toda linha da tabela Document. O endpoint de lista (GET /api/documents/) retorna todos os documentos de todos os usuários. O endpoint de detalhe (GET /api/documents/42/) retorna qualquer documento por PK. O desenvolvedor pode ter adicionado um has_object_permission customizado na classe de permissão, mas o DRF só o chama no get_object() — a ação de lista o ignora inteiramente.

Padrão 3 — Uma view de atualização que modifica o objeto de outro usuário

# 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)    # sem verificação de propriedade
    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})

O que dá errado: Este é o lado de escrita do IDOR, e é pior que o lado de leitura. O Usuário A navega para /profiles/42/edit/ e envia um POST — ele agora está editando o perfil do Usuário B. A view buscou o perfil 42, vinculou o formulário a ele e salvou os dados do atacante sobre o registro do usuário legítimo. Um decorator @login_required ficou no portão e deixou tudo passar.


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

Uma resposta comum mas equivocada ao IDOR é substituir PKs sequenciais inteiras por UUIDs, com a teoria de que um atacante não consegue adivinhar um identificador aleatório de 128 bits. Isso é defesa em profundidade no melhor caso e falsa segurança no pior:

  • UUIDs vazam. Eles aparecem em URLs, em cabeçalhos Referer, em links de e-mail, em respostas de API que listam objetos relacionados, no histórico do navegador e em logs. Uma vez que um UUID é conhecido, a falta de verificação de propriedade significa que ele funciona.
  • UUIDs não compõem com autorização. Um UUID diz ao servidor qual objeto o cliente quer, não se o cliente tem permissão de tê-lo. A verificação de autorização continua ausente.
  • UUIDs removem a enumerabilidade, o que desacelera uma varredura de força bruta, mas não impedem um atacante que obtenha uma única referência válida de explorá-la.

Use UUIDs para identificadores voltados ao público se quiser — eles são uma camada razoável de obscuridade que eleva a barreira para enumeração casual — mas nunca os trate como substituto da verificação de propriedade. O queryset ainda deve 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

Testando Sua Defesa

Testes Unitários

# 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

Testes de API DRF

# 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())

Verificação Manual

# 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/

Verificação Automatizada

O OWASP ZAP e o Burp Suite incluem regras de varredura ativa que detectam IDOR reproduzindo requisições com uma sessão autenticada diferente e comparando respostas. Em um pipeline de CI, a política activeScan do ZAP com o add-on "Access Control Testing" pode sinalizar views que retornam 200 para acesso a objetos entre usuários. Uma verificação mais simples, específica do projeto: escreva um management command ou teste que itere cada padrão de URL que aceita um argumento <pk> ou <int:pk> e assira que a requisição de um segundo usuário retorna 404 ou 403.


O Post 6 abre a Série II com a forma mais comum da vulnerabilidade web mais comum: uma busca de objeto que verifica identidade mas não direito. O hábito a levar adiante é um reflexo de revisão de código de uma linha — todo get_object_or_404, todo Model.objects.get(pk=...) e todo get_queryset() do DRF devem responder à pergunta: este queryset está escopado ao usuário solicitante? Se não está, a view é um IDOR. Trate uma busca sem escopo em dados de propriedade do usuário como um achado que bloqueia a mesclagem, da mesma forma que a Série I tratou uma query raw() não parametrizada ou um etree.fromstring puro.

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

Next in this series → Post 7: Escalação de Privilégios: Como fields = '__all__' Entrega ao Atacante as Chaves do Seu Django Admin

← Voltar para todos os posts