Pular para o conteúdo
14 min de leitura

Segurança de APIs: autorização, rate limiting e como fechar IDOR e BOLA

Por Lucas Andrade ·

Como blindar sua API REST/JSON contra as falhas do OWASP API Top 10: BOLA/IDOR, BFLA, mass assignment, rate limiting e vazamento de dados.

Neste artigo

Imagine a cena mais banal do mundo do desenvolvimento web. Você está logado numa aplicação, abre o histórico de compras e clica num pedido. A URL vira algo como GET /api/v1/orders/1043. Por curiosidade — ou por má intenção — você troca o número na barra de endereços para GET /api/v1/orders/1042 e aperta Enter. A tela carrega. Só que o pedido 1042 não é seu: é de outra pessoa, com o endereço de entrega dela, os quatro últimos dígitos do cartão dela, o valor que ela pagou. Você acabou de ler o dado de um estranho sem nenhum truque sofisticado, sem exploit, sem ferramenta de hacker. Trocou um número numa URL.

Essa cena é, ano após ano, a falha número um de segurança em APIs. No OWASP API Security Top 10 de 2023 ela tem nome e sobrenome: BOLA — Broken Object Level Authorization, também conhecida pelo nome mais antigo e genérico de IDOR (Insecure Direct Object Reference). E o mais desconfortável para quem constrói API é que ela não acontece por burrice do desenvolvedor. Acontece porque a aplicação fez tudo certo em um lugar e esqueceu de fazer em outro. Este artigo é sobre esse "outro lugar" — a segurança da API do ponto de vista de quem a escreve. Não é sobre design de API, sobre como nomear rotas, versionar recursos ou desenhar payloads elegantes. É sobre o que separa uma API que funciona de uma API que não vaza.

Autenticação não é autorização, e é aí que quase tudo desanda#

A confusão que está na raiz de metade dos incidentes é tratar duas perguntas diferentes como se fossem a mesma. Autenticação responde "quem é você?" — valida credenciais, verifica um token, estabelece uma identidade. Autorização responde "o que você pode fazer com este recurso específico?" — e é uma pergunta que precisa ser refeita a cada requisição, para cada objeto tocado.

A maioria das equipes acerta a autenticação. Elas colocam um middleware de JWT na frente de tudo, validam a assinatura, checam expiração, e a partir dali toda rota exige um token válido. O problema é que, uma vez que o token passou, muita gente considera o trabalho feito. Só que estar autenticado significa apenas que o servidor sabe que você é o usuário 4471. Não significa que o pedido 1042 seja seu. No cenário da URL trocada lá em cima, o atacante estava perfeitamente autenticado — usou o próprio token, o próprio login. O que faltou foi a segunda pergunta: este usuário tem direito a este objeto? A autenticação estava impecável; a autorização a nível de objeto simplesmente não existia.

Guarde essa distinção porque ela reaparece em quase toda vulnerabilidade que vamos discutir. A segurança de API é, em enorme parte, um problema de autorização mal resolvida.

BOLA/IDOR: o servidor precisa provar que o dono é você#

Voltando ao GET /api/v1/orders/1042. Por que a defesa correta não é "esconder o id" nem "usar UUID em vez de número sequencial"? Porque o problema não está no identificador. Está na ausência de verificação.

A defesa correta é conceitualmente simples e precisa estar em todas as rotas que recebem um identificador de recurso: o servidor pega o sujeito do token verificado — o user_id que veio de dentro do JWT validado, nunca de um parâmetro — e checa, no banco, se aquele sujeito é dono (ou tem acesso concedido) ao objeto pedido. Na prática, a query não deve ser SELECT FROM orders WHERE id = 1042. Deve ser SELECT FROM orders WHERE id = 1042 AND user_id = <sujeito do token>. Se não retornar linha, responde 404 (ou 403), e o dado de outra pessoa nunca sai. A checagem tem que estar acoplada à consulta do recurso, não como um if opcional que alguém pode esquecer de escrever no próximo endpoint.

Repare no ponto central: nunca confie no id que veio do cliente para decidir posse. O cliente pode mandar qualquer id. O que ele não pode falsificar é a identidade dentro de um token assinado corretamente. Então a posse se calcula cruzando o objeto pedido com a identidade provada — sempre no servidor.

Agora, por que id sequencial piora? Porque ele entrega o mapa de ataque de graça. Se os pedidos são 1042, 1043, 1044, um atacante enumera o banco inteiro num loop. Com sequencial, adivinhar recursos válidos é trivial. Mas — e este é o mal-entendido que precisa morrer — trocar sequencial por UUID não é autorização, é ofuscação. Um GET /api/v1/orders/9f2c8a1e-... é mais difícil de adivinhar, sim, mas se o UUID vazar de qualquer lugar (um log, um print, uma URL compartilhada, o corpo de outra resposta, o histórico do navegador de um dispositivo compartilhado) e a rota não checar posse, o dado sai igual. UUID reduz enumeração; não substitui a verificação de acesso. Se você só tem UUID e não tem checagem, você tem BOLA com passos a mais.

O caso mais perigoso dessa família é o vazamento cross-tenant em sistemas multi-tenant. Numa aplicação B2B, cada objeto pertence a uma organização (tenant_id). Se a rota GET /api/v1/invoices/{id} esquece de filtrar por tenant_id, um usuário do tenant A lê a fatura do tenant B — vaza dado de um cliente para outro cliente concorrente. A defesa é a mesma, elevada: toda query de recurso carrega o tenant_id do token na cláusula, e o tenant_id jamais vem de query string, header manipulável ou corpo. Vem do token verificado. Um X-Tenant-Id enviado pelo cliente é um convite para o atacante escolher em qual organização quer entrar.

BFLA: quando o usuário comum alcança a função de administrador#

Se BOLA é sobre acessar o objeto errado, BFLA — Broken Function Level Authorization é sobre executar a função errada. O cenário clássico: existe um DELETE /api/v1/users/{id} ou um POST /api/v1/admin/refunds que deveria ser exclusivo de administradores. A interface nunca mostra esse botão para o usuário comum — mas a interface é só pintura. A rota está lá, respondendo, e um usuário comum que descubra o caminho (lendo o JavaScript do front, a documentação, ou simplesmente adivinhando o padrão /admin/...) chama o endpoint e executa a ação privilegiada.

O erro de fundo aqui é confiar que "não aparece na tela" equivale a "não é acessível". Não equivale. A API é a fronteira de segurança real; o front é conveniência. A defesa é checar o papel exigido dentro de cada função sensível, no servidor, a partdo do papel que veio no token verificado. Endpoints administrativos e endpoints comuns idealmente vivem em superfícies separadas e explícitas, e cada handler de operação privilegiada reafirma "este sujeito tem o papel necessário para esta ação?". Não basta um gate genérico no login; a granularidade é por função. Criar um recurso, deletar, aprovar, reembolsar, promover outro usuário — cada verbo perigoso precisa da sua própria verificação de papel.

Um detalhe que morde: métodos HTTP menos óbvios. Muitas equipes protegem o GET da rota administrativa e esquecem que a mesma rota aceita PUT ou DELETE sem a mesma checagem. O atacante testa todos os verbos. A autorização precisa cobrir a operação inteira, verbo a verbo.

Mass assignment: o corpo da requisição que reescreve o que não devia#

Existe uma classe de falha que parece inofensiva até você entender o estrago. Suponha um PATCH /api/v1/users/me que atualiza o perfil do usuário. O handler, por preguiça ou por usar um binding automático do framework, pega o corpo JSON inteiro e joga direto no objeto do banco. O usuário deveria poder mudar name e avatar_url. Mas ele manda no corpo {"name": "João", "role": "admin"} — e o campo role, que nunca deveria ser editável pelo cliente, é sobrescrito. Parabéns, o usuário comum acabou de se promover a administrador. A mesma técnica reescreve owner_id, tenant_id, is_verified, balance, email_confirmed — qualquer campo que o binding cego aceite.

Isso é mass assignment, e a defesa é uma disciplina só: allowlist de campos. O servidor decide explicitamente quais campos daquele endpoint podem vir do cliente, e ignora todo o resto. Na prática isso é um DTO de entrada — uma estrutura que só tem name e avatar_url, e nada mais. O corpo é desserializado para esse DTO, os campos não reconhecidos são descartados, e só então os valores válidos são aplicados ao modelo. Nunca desserialize o corpo direto no modelo de domínio. Nunca use "atualize tudo que veio". Campos sensíveis — papel, dono, tenant, flags de estado — são de responsabilidade exclusiva do servidor e mudam por caminhos próprios e autorizados, jamais por um PATCH de perfil.

Note como isso se conecta com o tema recorrente: tenant_id e role sempre vêm do token verificado no servidor, nunca de query, body ou header do cliente. Se um desses valores aparece no corpo da requisição, a regra não é "usar o que veio validando"; a regra é ignorar o que veio e derivar do token. Validar um tenant_id de corpo ainda é confiar no cliente para uma decisão de segurança. A única fonte legítima de identidade e escopo é o token que o servidor assinou e verificou.

Rate limiting e quotas: nem toda requisição válida é bem-vinda#

Até aqui falamos de requisições que acessam o recurso errado. Mas há uma ameaça diferente: requisições perfeitamente válidas, na quantidade errada. Um endpoint de login sem limite é uma máquina de brute force — o atacante testa milhares de senhas por segundo. Um endpoint de leitura sem limite é uma ferramenta de scraping que copia sua base inteira. Um endpoint que dispara um processamento caro (gera relatório, chama um serviço pago, roda uma query pesada) sem limite é um vetor de DoS de custo — o atacante não derruba seu servidor, ele detona sua fatura de infraestrutura.

A defesa é rate limiting e quotas, aplicados por chave de API, por usuário e por IP, conforme o caso. O ponto fino é por qual dimensão você limita. Limitar só por IP é frágil porque IP é barato e compartilhável; por isso, para clientes autenticados, o limite por user_id ou por chave (X-Api-Key) é mais honesto. Quando o limite estoura, a resposta correta é 429 Too Many Requests, idealmente com um header indicando quando o cliente pode tentar de novo. Endpoints sensíveis — autenticação, recuperação de senha, qualquer coisa que gere custo — merecem limites mais apertados que endpoints comuns de leitura. E limite é diferente de quota: rate limiting controla velocidade (requisições por janela de tempo); quota controla volume total (requisições por mês, por plano). Os dois coexistem. Sem eles, sua API está exposta a abuso mesmo quando cada requisição, isolada, é legítima.

Validação de entrada e o teto do corpo#

Toda entrada é hostil até prova em contrário. Isso não é paranoia; é a postura mínima. A API precisa validar tipo, formato, faixa e presença de cada campo que recebe — um campo que deveria ser inteiro e chega como string não pode ser coagido silenciosamente; um campo de data com lixo não pode virar 500; um null inesperado não pode explodir o handler. Entrada inválida se rejeita com 400 e mensagem clara sobre o que está errado (sem vazar interno — já chegamos lá).

Um caso específico que muita gente esquece: o limite de tamanho do corpo. Um endpoint que desserializa JSON sem teto de tamanho aceita um corpo de gigabytes e estoura a memória do processo — é um DoS de OOM (out of memory) trivial de disparar. A defesa é um middleware que rejeita corpos acima de um teto sensato (alguns megabytes para a maioria das rotas; upload de arquivo é caso à parte, com seu próprio limite). O mesmo vale para paginação: um ?limit=999999999 que não seja limitado no servidor faz a query varrer a tabela inteira. O servidor clampa o limite para um máximo razoável, ignorando o valor absurdo do cliente. De novo o padrão: o cliente pede, o servidor decide o que é aceitável.

Não vazar o que se passa por dentro#

Quando algo dá errado no servidor, a tentação preguiçosa é devolver a exceção crua para o cliente "para facilitar o debug". É um erro de segurança. Uma mensagem de erro que ecoa pq: duplicate key value violates unique constraint "users_email_key" ou um stack trace com caminhos de arquivo, versões de biblioteca e trechos de SQL entrega ao atacante um mapa do que está por baixo: qual banco você usa, qual a estrutura das tabelas, quais bibliotecas e versões (e, portanto, quais CVEs testar). Isso é information disclosure.

A regra é redigir o detalhe interno na borda. Em qualquer resposta 5xx, o cliente recebe uma mensagem genérica e um identificador de correlação (trace_id) — nada do erro original. O detalhe real vai para o log, associado ao mesmo trace_id, onde a equipe consegue investigar sem expor nada. O cliente vê "erro interno, referência abc-123"; a engenharia vê o stack completo no observability. Ninguém de fora aprende a topografia do seu sistema por causa de um erro.

Superfície de ataque: a rota que você esqueceu que existe#

Cada endpoint publicado é uma porta. Versionar uma API (/api/v1, /api/v2) é bom para evolução, mas cria um risco silencioso: a v1 que ninguém mais usa continua no ar, sem as correções de segurança que a v2 recebeu. Rotas antigas, endpoints de debug esquecidos em produção, o /api/v1/internal/... que deveria estar bloqueado no gateway mas está respondendo — cada um deles é uma porta que você não está vigiando. A disciplina é manter um inventário real dos endpoints expostos (não do que a documentação diz estar exposto, mas do que a máquina realmente responde) e desligar o que é morto. Uma rota que ninguém monitora é exatamente onde o atacante vai bater, porque é onde as defesas envelheceram.

E o piso de tudo, o que nunca se negocia: segredo nunca no cliente. Chave de API, secret de assinatura de JWT, credencial de banco, token de serviço — nada disso pode viver no bundle do front, em variável NEXT_PUBLIC_/VITE_, nem em resposta de API para o browser. O prefixo público expõe por definição. Se o front precisa falar com um terceiro que exige chave secreta, essa chamada passa por um backend-for-frontend ou route handler no servidor, onde o segredo mora. Uma chave que chega ao navegador é uma chave já comprometida — basta abrir o DevTools.

Fechando o raciocínio#

Se há uma linha que costura todas essas defesas, é esta: o servidor não confia em nada que o cliente afirma sobre si mesmo ou sobre o que pode acessar. O id do objeto vem do cliente, mas a posse se prova no servidor. O papel do usuário nunca vem do corpo; vem do token verificado. O tenant_id não é escolhido por header; é derivado da identidade. O tamanho do corpo, o limite de paginação, a quantidade de requisições — o cliente propõe, o servidor impõe o teto. O erro interno fica no log, não na resposta. O segredo fica no servidor, nunca no bundle.

BOLA, BFLA, mass assignment, ausência de rate limiting, vazamento em 5xx, superfície esquecida — o OWASP API Security Top 10 de 2023 nomeia essas falhas separadamente, mas quase todas são a mesma doença em roupas diferentes: uma decisão de autorização que foi delegada, explícita ou implicitamente, ao cliente. Autenticar bem é o começo. Autorizar cada objeto, cada função e cada campo, a cada requisição, no servidor — isso é o trabalho. E é o trabalho que impede que trocar um número numa URL vire a manchete do próximo vazamento.

Leituras relacionadas

Nenhum comentário ainda

Seja o primeiro a comentar.

Deixe seu comentário

Entre com sua conta Canverly para comentar. Você pode usar a mesma conta em qualquer site da rede.

Entrar com Canverly