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
end

Para 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
end

Com 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-Id enviado pelo frontend é input do cliente. Tire o tenant da sessão ou do token e verifique a associação.
  • Objetos filhos./projects/7/tasks/99 verifica 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

  1. Crie duas contas (de preferência em dois tenants diferentes) e alguns objetos em cada uma.
  2. 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.
  3. 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.
  4. Teste todos os verbos: GET, PUT, PATCH, DELETE, além de exports, anexos e mutations de GraphQL.
  5. 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
end

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

Leituras relacionadas