Ir para o conteúdo
CodeAuditAgent
Todos os artigos

IDOR e controle de acesso quebrado: um guia prático

Como IDOR e controle de acesso quebrado se infiltram em REST, GraphQL e route handlers do Next.js, como testar e as correções que se sustentam no código real.

· 7 min de leitura · Lina Source LLC

Controle de acesso quebrado é a classe de falha que sobrevive a toda atualização de framework. Seu ORM escapa o SQL, seu motor de templates escapa o HTML, mas nada na stack sabe que a fatura 4812 pertence à Alice e não ao Bob. Esse conhecimento vive no seu código, e quando um handler esquece de aplicá-lo, qualquer usuário autenticado consegue ler ou alterar os dados de outra pessoa.

A forma mais comum é a referência direta insegura a objeto, ou IDOR: o cliente envia um identificador, o servidor carrega o registro com aquele identificador e ninguém verifica se quem chamou tem permissão para vê-lo. Explorar isso não exige ferramenta nenhuma. Um navegador, uma segunda conta e um número trocado na URL bastam. Scanners que procuram chamadas de funções perigosas raramente pegam o problema, porque o código vulnerável não contém nada de perigoso: uma consulta ao banco perfeitamente comum simplesmente está sem uma condição.

As três CWEs que você vai encontrar

  • CWE-639, desvio de autorização por chave controlada pelo usuário: o IDOR clássico. O registro é selecionado por um ID que o atacante controla, e a propriedade nunca é verificada.
  • CWE-862, autorização ausente: o handler não faz verificação de autorização alguma. Muitas vezes é um endpoint administrativo ou interno que se presumia inalcançável.
  • CWE-285, autorização inadequada: existe uma verificação, mas ela está errada. Checa o campo errado, checa permissão de leitura numa escrita, ou confia em um papel enviado pelo cliente.

A distinção importa na hora de corrigir. Uma verificação ausente significa acrescentar uma; uma verificação inadequada significa que o modelo de quem pode fazer o quê está errado, e o mesmo engano provavelmente se repete em outros lugares. Quando encontrar qualquer um dos dois, procure os irmãos antes de fechar o ticket. Falhas de controle de acesso raramente são casos isolados; elas seguem os padrões que um time copia de handler em handler.

Como isso acontece num route handler do Next.js

Aqui está o padrão na sua forma mais comum. O handler autentica o usuário, o que dá uma sensação de segurança, e então carrega o registro apenas pelo ID. A verificação de sessão responde quem está chamando; nada responde se quem chama pode ver esta fatura.

// app/api/invoices/[id]/route.ts  (vulnerável)
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const session = await auth();
  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { id } = await params;
  // Qualquer usuário autenticado lê qualquer fatura trocando o ID
  const invoice = await db.invoice.findUnique({ where: { id } });
  return NextResponse.json(invoice);
}

A correção é tornar a propriedade parte da própria consulta, e não um passo separado que pode ser esquecido. Se o registro não pertence a quem chamou, o banco não devolve nada e o handler responde 404.

// app/api/invoices/[id]/route.ts  (corrigido)
export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const session = await auth();
  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { id } = await params;
  const invoice = await db.invoice.findFirst({
    where: { id, userId: session.user.id },
  });
  if (!invoice) {
    return NextResponse.json({ error: "Not found" }, { status: 404 });
  }
  return NextResponse.json(invoice);
}

Devolver 404 em vez de 403 é proposital. Um 403 confirma que o registro existe, o que permite ao atacante enumerar IDs válidos mesmo sem conseguir lê-los. O mesmo vale para tempos de resposta e mensagens de erro: a resposta para o registro de outra pessoa deve ser indistinguível da resposta para um registro que nunca existiu.

REST: os endpoints que as pessoas esquecem

Os times costumam proteger o GET por ID, que é o óbvio. As falhas se escondem nos outros verbos e nas bordas da API:

  • Handlers PATCH e DELETE copiados do handler GET antes de a verificação de propriedade ser acrescentada.
  • Rotas aninhadas como /projects/:projectId/tasks/:taskId, em que o projeto é verificado mas a tarefa é carregada apenas por taskId e pode pertencer a outro projeto.
  • Endpoints em lote que aceitam um array de IDs e verificam apenas o primeiro.
  • Downloads de arquivos e jobs de exportação, que muitas vezes passam por um serviço separado com verificações próprias e mais fracas.
  • Payloads de atualização que aceitam ownerId, organizationId ou role vindos do corpo da requisição e os gravam direto no banco (mass assignment).

O GraphQL amplia a superfície

No GraphQL, o mesmo objeto pode ser alcançado por muitos caminhos. Uma verificação no nível da query em invoice(id) não ajuda se a mesma fatura também é alcançável por customer { invoices }, por uma busca node(id) ou pelo tipo de retorno de uma mutation. Todo resolver que devolve um objeto é um ponto de entrada. Camadas de batching como o DataLoader acrescentam outra armadilha: um loader indexado apenas pelo ID vai devolver registros alegremente para qualquer viewer, e seu cache pode servir os dados de um usuário a uma requisição posterior se for compartilhado entre requisições.

A abordagem confiável é autorizar na camada de dados que os resolvers chamam, e não nos resolvers em si. Se todo caminho até uma fatura passa por uma única função que recebe o viewer e escopa a consulta, acrescentar um campo ou um relacionamento novo não consegue burlá-la. Verifique também as entradas de mutations: um campo como ownerId em um input type é um convite para reatribuir registros. Por fim, lembre que a introspecção e as mensagens de erro revelam seu schema, então presuma que os atacantes conhecem cada campo e relacionamento que você expõe.

A mesma falha em Python

O formato é idêntico no FastAPI com SQLAlchemy. A versão vulnerável chama db.get(Document, doc_id); a versão corrigida filtra pelo dono na mesma instrução.

from fastapi import Depends, FastAPI, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session

app = FastAPI()

@app.get("/documents/{doc_id}")
def get_document(
    doc_id: int,
    user: User = Depends(current_user),
    db: Session = Depends(get_db),
):
    # Vulnerável: doc = db.get(Document, doc_id)
    doc = db.scalar(
        select(Document).where(
            Document.id == doc_id,
            Document.owner_id == user.id,
        )
    )
    if doc is None:
        raise HTTPException(status_code=404, detail="Not found")
    return doc

Correções que se sustentam

Escope toda consulta por dono ou tenant

Coloque o ID do usuário ou da organização na cláusula WHERE de toda leitura e escrita. Isso transforma a autorização em uma propriedade da consulta, o que é fácil de enxergar na revisão. Em aplicações multi-tenant, o row-level security do Postgres pode reforçar a fronteira do tenant como uma segunda camada, de modo que um filtro esquecido devolva nada em vez das linhas de outro cliente. O mesmo escopo vale para escritas. Uma atualização deve ser uma única instrução filtrada por ID e por dono, como um updateMany com as duas condições seguido de uma verificação de que exatamente uma linha mudou, em vez de uma leitura, uma verificação e uma escrita separada que podem correr entre si.

Centralize a decisão

Ifs espalhados acabam divergindo. Um pequeno conjunto de helpers, um por recurso, mantém a regra em um só lugar e faz um handler sem chamada ao helper saltar aos olhos.

// lib/authz.ts
type Role = "owner" | "member" | "viewer";
type Action = "read" | "update" | "delete";

const policy: Record<Role, ReadonlySet<Action>> = {
  owner: new Set<Action>(["read", "update", "delete"]),
  member: new Set<Action>(["read", "update"]),
  viewer: new Set<Action>(["read"]),
};

export class NotFoundError extends Error {}

export async function requireProject(
  userId: string,
  projectId: string,
  action: Action
) {
  const membership = await db.membership.findFirst({
    where: { userId, projectId },
    include: { project: true },
  });
  // Negar por padrão: sem vínculo ou sem permissão parecem iguais
  if (!membership || !policy[membership.role as Role]?.has(action)) {
    throw new NotFoundError();
  }
  return membership.project;
}

Negue por padrão

Papéis desconhecidos, vínculos ausentes e ações inesperadas devem todos cair em uma negação. Em frameworks com middleware, exija autenticação para tudo e marque as rotas públicas explicitamente, em vez do contrário. Uma rota nova deve nascer trancada até alguém decidir o contrário. Nunca pegue o papel, o tenant ou o ID de usuário do corpo da requisição ou de um cabeçalho definido pelo cliente; derive-os sempre da sessão verificada no servidor.

Identificadores aleatórios como UUIDs valem a pena, mas não são uma correção. IDs vazam por URLs, logs, links compartilhados e cabeçalhos de referrer. Trate-os como impossíveis de adivinhar apenas no sentido de que atrasam a enumeração, nunca como a verificação de acesso.

Como testar

Testar IDOR é simples e repetitivo, e é justamente por isso que vale automatizar depois de fazer à mão uma vez. Comece por um inventário: liste toda rota, resolver e job em segundo plano que aceite um identificador, incluindo IDs escondidos em corpos de requisição, query strings e cabeçalhos.

  • Crie duas contas, A e B, de preferência em duas organizações separadas. Crie um registro como A e anote o ID.
  • Repita toda requisição que referencie esse ID com a sessão de B: GET, PATCH, DELETE, downloads, exportações e qualquer query ou mutation GraphQL que toque nele.
  • Espere 404 em todas. Qualquer 200, e qualquer 403 que confirme a existência, é um achado.
  • Transforme a verificação manual em um teste de integração por recurso, para que um handler novo sem consulta escopada quebre no CI.
  • Procure por buscas feitas só pela chave primária, como findUnique({ where: { id } }) ou db.get(Model, id), e justifique cada uma.

A revisão de código pega o que os testes deixam passar, porque a verificação ausente está visível no fonte mesmo quando ninguém escreveu um teste para aquela rota. O CodeAuditAgent lê um repositório público do GitHub ou um trecho colado e reporta lacunas de controle de acesso com a CWE, a linha citada, um cenário de exploração e uma correção proposta, o que é uma forma rápida de passar uma segunda vez por todos os handlers de uma vez.

Um checklist curto

  • Toda consulta que recebe um ID vindo do cliente também filtra pelo usuário ou tenant de quem chamou.
  • A autorização vive em helpers compartilhados ou na camada de dados, não em ifs copiados e colados.
  • Casos desconhecidos negam; rotas públicas são a exceção explícita.
  • Operações de escrita são verificadas com o mesmo cuidado que as leituras, incluindo rotas em lote e aninhadas.
  • Existem testes com duas contas para cada recurso e eles rodam no CI.