Existe uma falha de segurança que não exige ferramenta, não exige conhecimento técnico e não exige nem má intenção pra ser descoberta. Basta curiosidade.
A pessoa está em /pedidos/1042, troca pra /pedidos/1041 e vê o pedido de outro cliente.
Isso se chama IDOR, referência direta insegura a objeto, e é a categoria número um em relatório de teste de invasão de aplicação web ano após ano. Não porque é difícil de evitar. Porque é fácil de esquecer.
Por que persiste
Autenticação é centralizada. Um guard, um middleware, testado uma vez, aplicado em todo lugar.
Autorização é distribuída. Uma decisão por endpoint, por recurso, às vezes por campo. Ela se espalha pelo código, e o que se espalha se esquece.
O endpoint abaixo exige token válido e vaza a base inteira:
@UseGuards(JwtAuthGuard)
@Get("invoices/:id")
findOne(@Param("id") id: string) {
return this.invoices.findById(id);
}
O guard fez o trabalho dele. A porta da rua está trancada, e dentro do prédio todo mundo entra em todas as salas.
Onde ele se esconde
O endpoint óbvio de leitura é o menos perigoso, porque é o que alguém eventualmente revisa. Os que passam batido:
Exportação e relatório. GET /reports/export?org=123. Costuma ter menos cuidado que o CRUD porque foi feito às pressas pra uma demanda específica.
Anexo e arquivo. GET /files/9f2b.... O UUID dá a sensação de proteção. Não dá: UUID vaza em link compartilhado, em log, em histórico do navegador e em e-mail. Identificador difícil de adivinhar não é controle de acesso.
Endpoint de atualização. Todo mundo lembra de proteger a leitura e esquece que PATCH /invoices/:id também aceita ID.
Campo no corpo. Esse é o mais sutil:
PATCH /orders/1042
{ "status": "paid", "organizationId": "outra-org" }
Se o DTO aceitar organizationId, o usuário move um pedido pra organização de outra pessoa. whitelist: true no ValidationPipe resolve a maior parte disso ao descartar campo não declarado, e é por isso que forbidNonWhitelisted deveria ser padrão.
Identificador em campo aninhado. POST /payments { "invoiceId": "..." }. Não está na URL, e é exatamente a mesma falha.
A correção que resiste ao tempo
Adicionar uma verificação no controller funciona hoje e falha no décimo quinto endpoint. A correção estrutural é fazer o escopo parte da consulta:
async findOneForUser(id: string, user: User): Promise<Invoice> {
const invoice = await this.repo.findOne({
where: { id, ...this.scopeFor(user) },
});
if (!invoice) throw new NotFoundException();
return invoice;
}
Três ganhos. A regra mora num lugar só. Um endpoint novo que use esse método já nasce protegido. E um que use findById direto fica visivelmente diferente do resto, o que dá ao code review uma chance real de pegar.
Em ORM que suporta, dá pra ir além e aplicar o filtro de tenant no nível da conexão, de forma que esquecer se torne impossível em vez de improvável. Postgres com row level security faz isso no banco, e é a defesa mais forte que existe pra multi-tenant.
404, não 403
Detalhe pequeno com efeito real.
Responder 403 confirma que o recurso existe e pertence a outra pessoa. Isso entrega informação: dá pra enumerar IDs válidos e descobrir quantos clientes a empresa tem, ou se um documento específico existe.
404 nos dois casos não conta nada. Custa uma linha.
Como encontrar no seu sistema hoje
Um script de vinte minutos que vale mais que uma auditoria genérica:
Crie dois usuários em organizações diferentes. Faça o usuário A criar um recurso de cada tipo. Depois, com o token do usuário B, chame todos os endpoints com os IDs do usuário A. GET, PATCH, DELETE, exportação, anexo.
Cada resposta que não for 404 é um achado.
Isso vira teste automatizado com pouco esforço:
describe.each(RESOURCE_ENDPOINTS)("%s isolado por organização", (endpoint) => {
it("não vaza entre organizações", async () => {
const res = await request(app)
.get(endpoint.replace(":id", resourceFromOrgA.id))
.set("Authorization", `Bearer ${tokenOrgB}`);
expect(res.status).toBe(404);
});
});
Uma tabela de endpoints e um teste parametrizado cobrem o sistema inteiro. É a coisa com melhor relação entre esforço e risco eliminado que eu conheço em segurança de aplicação.
O que não resolve
UUID em vez de sequencial. Ajuda contra enumeração casual e não é controle de acesso.
Esconder o botão no frontend. É usabilidade.
Assinar o ID. Reinventa sessão, com mais chance de errar.
Scanner automático. Ele não sabe quem deveria ver o quê, porque isso é regra de negócio. Por isso IDOR sobrevive em empresa que compra ferramenta cara: a ferramenta não tem como saber.
Sobre a diferença conceitual entre as duas coisas, escrevi em autenticação e autorização.