Uma vulnerabilidade IDOR (insecure direct object reference) acontece quando a sua aplicação pega um identificador da requisição, carrega o registro correspondente e devolve sem verificar se o usuário atual pode vê-lo. O atacante não precisa de nenhum exploit sofisticado. Ele entra com uma conta comum, abre /api/invoices/1001, troca para /api/invoices/1002 e lê a fatura de outro cliente.
É um dos bugs mais fáceis de explicar e um dos que mais chegam em produção. Neste guia você vai ver o que é IDOR, como ele se relaciona com BOLA, como fica o código vulnerável e o corrigido em cinco frameworks, onde ele se esconde (GraphQL, endpoints em lote, SaaS multi-tenant), como testar e como impedir que ele volte.
O que é IDOR
Uma "referência direta a objeto" é qualquer valor enviado pelo cliente que aponta para algo interno: um ID do banco, um nome de arquivo, um número de conta, um UUID. A referência em si não é o problema. O problema é a pergunta que falta no servidor: este usuário pode acessar este objeto específico?
IDOR é uma falha de autorização, não de autenticação. O atacante normalmente está logado e é um usuário legítimo. A autenticação responde "quem é você?"; a autorização responde "no que você pode mexer?". O IDOR mora no espaço entre as duas.
Existem duas variantes:
- Horizontal: um usuário acessa dados de outro usuário com o mesmo perfil (pedidos, mensagens ou documentos de outro cliente).
- Vertical: um usuário chega a um objeto ou ação de um perfil superior, por exemplo um relatório de admin buscado por ID.
IDOR e BOLA: o mesmo bug com dois nomes
No OWASP Top 10 2025, o IDOR fica dentro de A01:2025 Broken Access Control, que continua em primeiro lugar. A OWASP cita como exemplo típico permitir ver ou editar a conta de outra pessoa informando o identificador único dela ("insecure direct object references") e mapeia a falha para a CWE-639, Authorization Bypass Through User-Controlled Key. Para o panorama completo da categoria, veja nosso guia de broken access control.
No OWASP API Security Top 10 (edição 2023), a mesma falha se chama BOLA, Broken Object Level Authorization, e é a API1:2023, o primeiro item. A OWASP descreve o ataque como a manipulação do ID de um objeto enviado na requisição. Ou seja, "bola vulnerability" e "broken object level authorization" são, na prática, IDOR em uma API.
Exemplos do mundo real
IDs sequenciais
O caso clássico: GET /api/orders/48213. IDs inteiros são previsíveis, então um script percorre de 1 a 50.000 e baixa todos os pedidos do sistema em minutos. O mesmo vale para downloads como /receipts/48213.pdf servidos de um storage sem checagem.
IDs no body, não na URL
Muitos IDORs se escondem no JSON: {"user_id": 812, "email": "novo@exemplo.com"} enviado para um endpoint de atualização de perfil. O handler confia no user_id do body em vez de pegá-lo da sessão, e qualquer pessoa troca o e-mail de qualquer outra e depois reseta a senha.
UUID não é autorização
Uma falsa correção muito comum é migrar para UUIDs. A OWASP recomenda IDs aleatórios e imprevisíveis, mas como defesa em profundidade, não como o controle. UUIDs vazam por todo lado: links compartilhados, histórico do navegador, logs, analytics, payloads de webhooks, exports em CSV e outras respostas da API (uma listagem que devolve objetos dos colegas de time, por exemplo). Assim que o atacante tem um, um endpoint sem checagem entrega sem reclamar. Difícil de adivinhar não é o mesmo que autorizado.
Código vulnerável e corrigido em 5 frameworks
O padrão é igual em todo lugar: pare de carregar objetos de forma global e passe a carregá-los através do usuário ou do tenant atual. Se o objeto não está no escopo do usuário, para ele o objeto não existe, e você retorna 404.
Rails
# Vulnerável: qualquer usuário logado lê qualquer fatura
def show
@invoice = Invoice.find(params[:id])
render json: @invoice
end
# Corrigido: busca pela associação (RecordNotFound, ou seja, 404)
def show
@invoice = current_user.invoices.find(params[:id])
render json: @invoice
endPara algo além de uma associação, centralize a regra em uma policy. Com Pundit:
class InvoicePolicy < ApplicationPolicy
def show? = record.account_id == user.account_id
class Scope < ApplicationPolicy::Scope
def resolve = scope.where(account_id: user.account_id)
end
end
# controller
def show
@invoice = policy_scope(Invoice).find(params[:id])
authorize @invoice
endCom after_action :verify_authorized, o Pundit faz uma checagem esquecida falhar alto em vez de passar em silêncio. No CanCanCan, o equivalente é can :read, Invoice, account_id: user.account_id na ability e load_and_authorize_resource no controller.
Express / Node.js
// Vulnerável
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
const invoice = await Invoice.findByPk(req.params.id);
res.json(invoice);
});
// Corrigido: a condição de dono faz parte da query
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
const invoice = await Invoice.findOne({
where: { id: req.params.id, accountId: req.user.accountId },
});
if (!invoice) return res.status(404).json({ error: 'Not found' });
res.json(invoice);
});Django
# Vulnerável
def invoice_detail(request, pk):
invoice = get_object_or_404(Invoice, pk=pk)
return JsonResponse(invoice.as_dict())
# Corrigido
def invoice_detail(request, pk):
invoice = get_object_or_404(Invoice, pk=pk, account=request.user.account)
return JsonResponse(invoice.as_dict())
# Django REST Framework: filtre o queryset, não cada view
class InvoiceViewSet(viewsets.ModelViewSet):
serializer_class = InvoiceSerializer
def get_queryset(self):
return Invoice.objects.filter(account=self.request.user.account)Laravel
// Vulnerável: o route model binding carrega qualquer fatura
public function show(Invoice $invoice)
{
return $invoice;
}
// Corrigido: uma policy decide, o controller pergunta
public function show(Invoice $invoice)
{
Gate::authorize('view', $invoice);
return $invoice;
}
// app/Policies/InvoicePolicy.php
public function view(User $user, Invoice $invoice): bool
{
return $user->account_id === $invoice->account_id;
}Spring Boot
// Vulnerável
@GetMapping("/invoices/{id}")
public Invoice get(@PathVariable Long id) {
return repo.findById(id).orElseThrow();
}
// Corrigido: um método do repositório que exige o dono
@GetMapping("/invoices/{id}")
public Invoice get(@PathVariable Long id, @AuthenticationPrincipal AppUser user) {
return repo.findByIdAndAccountId(id, user.getAccountId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}Onde ele se esconde: GraphQL e endpoints em lote
GraphQL. Um único schema expõe objetos por vários caminhos: invoice(id:), o campo genérico node(id:), campos aninhados como customer { invoices { ... } } e mutations como updateInvoice(id:). É comum proteger a query de primeiro nível e esquecer o resolver aninhado ou a mutation. Coloque a autorização na camada de dados que todos os resolvers usam (um loader com escopo ou uma policy), não em cada resolver separadamente.
Endpoints em lote e de export.POST /api/invoices/export com {"ids": [1, 2, 3]} muitas vezes roda Invoice.where(id: ids). Uma checagem só no primeiro ID, ou nenhuma, e o atacante exporta milhares de registros alheios em uma chamada. Filtre o conjunto inteiro: current_account.invoices.where(id: ids).
Escritas, não só leituras. Handlers de update e delete estão igualmente expostos, e é pior: uma requisição que define account_id no body pode mover um objeto para outro tenant. Nunca aceite campos de ownership vindos do cliente.
Armadilhas comuns em SaaS multi-tenant
- Checar o usuário mas não o tenant. O usuário é da conta A, o projeto é da conta B, e o código só verifica se ele está logado e se é admin de algum projeto.
- Confiar em um header de tenant. Um
X-Account-Idenviado pelo frontend é input do cliente. Tire o tenant da sessão ou do token e verifique a associação. - Objetos filhos.
/projects/7/tasks/99verifica que o projeto 7 é seu e depois carrega a tarefa 99 de forma global. Carregue o filho pelo pai:project.tasks.find(99). - Jobs em background e webhooks. Um job enfileirado com um ID cru, ou uma URL assinada que nunca expira, pula completamente as checagens do controller.
- Ferramentas internas de admin e suporte. Endpoints internos reaproveitados pelo app de clientes são uma fonte clássica de IDOR vertical.
Row-level security no banco (por exemplo, policies RLS do PostgreSQL ligadas a uma variável de tenant) é uma ótima segunda camada: mesmo que um escopo seja esquecido na aplicação, não dá para atravessar de tenant.
Como testar IDOR
- Crie duas contas (de preferência em dois tenants diferentes) e alguns objetos em cada uma.
- Passe o tráfego pelo Burp Suite enquanto usa a conta A. Toda requisição que carrega um ID no path, na query string, no body ou em um header é candidata.
- Repita essas requisições com a sessão da conta B e os IDs da conta A. Para poucos casos, o Burp Repeater resolve; extensões como a Autorize automatizam a troca de sessão em todas as requisições.
- Teste todos os verbos: GET, PUT, PATCH, DELETE, além de exports, anexos e mutations de GraphQL.
- Compare as respostas, não só os status codes. Um 200 com body vazio pode esconder uma escrita bem-sucedida; um 403 diferente de um 404 confirma para o atacante que o objeto existe.
Isso é trabalho padrão em qualquer pentest e no pentest de APIs assistido por IA.
Por que o SAST baseado em padrões não pega IDOR
O SAST clássico segue o input não confiável até um sink perigoso. Isso funciona para SQL injection, onde req.query.sort acaba concatenado em uma string de query. Um IDOR não tem sink perigoso: Invoice.find(params[:id]) é uma query parametrizada e totalmente segura. O que está errado é uma condição ausente. Nenhuma regex casa com uma linha que não existe.
A análise que raciocina encara o código como um revisor faria. Ela percebe que InvoicesController#show filtra por current_user e InvoicesController#download não, que existe uma policy mas uma action nunca chama authorize, ou que uma mutation de GraphQL pula o loader usado por todas as queries. Essa comparação entre arquivos e convenções é onde um modelo de linguagem ajuda, e é por isso que o code review seguro sempre achou IDORs que os scanners não viam.
A Nurbak segue esse caminho: você conecta o GitHub e escaneia um repositório, e o próprio modelo de IA self-hosted da Nurbak analisa o código (a análise não envia seu código para a OpenAI nem para a Anthropic). Ela reporta vulnerabilidades exploráveis, incluindo IDOR/BOLA e checagens de autorização ausentes, com arquivo, linha e explicação em linguagem simples, além de CVEs em dependências e segredos no histórico do git, tudo resumido em um score de 0 a 100. Veja como funciona na página do scanner de segurança de APIs ou leia mais sobre como encontrar vulnerabilidades no código.
Escreva testes de regressão de autorização
O próprio guia da OWASP sobre BOLA recomenda escrever testes para o mecanismo de autorização e não fazer deploy de mudanças que os façam falhar. Um IDOR corrigido sem teste costuma voltar no próximo refactor. O teste é curto: dois usuários, um objeto, e você garante que o estranho não recebe nada.
# spec/requests/invoices_spec.rb
RSpec.describe "Invoices authorization", type: :request do
let(:owner) { create(:user) }
let(:stranger) { create(:user) } # outra conta
let!(:invoice) { create(:invoice, account: owner.account) }
it "hides another account's invoice" do
sign_in stranger
get "/api/invoices/#{invoice.id}"
expect(response).to have_http_status(:not_found)
end
it "does not let a stranger delete it" do
sign_in stranger
delete "/api/invoices/#{invoice.id}"
expect(Invoice.exists?(invoice.id)).to be(true)
end
endO mesmo formato funciona com supertest no Node, pytest com o test client do Django, Pest ou PHPUnit no Laravel e MockMvc no Spring. Transforme em hábito: todo endpoint novo que recebe um ID sai com o teste do "estranho". Quando a Nurbak encontra um problema assim, ela pode abrir um Pull Request com a correção e um teste de regressão de segurança desse tipo (a correção é gerada com Claude, só com o seu consentimento explícito).
Checklist para prevenir IDOR
- Carregue objetos através do usuário ou do tenant atual, nunca de forma global.
- Pegue identidade e tenant da sessão ou do token, nunca do body nem de headers.
- Centralize as regras em policies e faça uma checagem ausente falhar (negar por padrão).
- Retorne 404 para objetos fora do escopo do usuário.
- Cubra recursos aninhados, endpoints em lote, exports, downloads de arquivos, resolvers e mutations de GraphQL.
- Use IDs aleatórios como defesa em profundidade, não como o controle.
- Adicione um teste de regressão com duas contas para cada endpoint que recebe um ID.
Resumo
IDOR não tem nada de exótico. É um where que falta, repetido em dezenas de endpoints, e entrega ao atacante exatamente o que ele quer: dados de outras pessoas, sem disparar nenhum erro. Filtre cada query, centralize suas policies, teste com duas contas e trate endpoints escritos por IA com a mesma desconfiança que os escritos por pessoas (nosso guia de revisão de segurança com Claude Code cobre esse fluxo). O scan grátis da Nurbak mostra completos os 3 achados mais importantes, e os planos começam em USD 79 por mês se você quiser cobertura contínua. Comece pelo scanner de segurança de APIs no repositório que serve a sua API.
