BOLA: a falha de segurança que a autenticação não resolve (e como eu blindei meu SaaS multi-tenant)
Meses atrás eu estava fazendo uma varredura de segurança no sistema multi-tenant da minha empresa, procurando vulnerabilidades. Afinal, já é um sistema que conta com diversos usuários e que a cada mês vem crescendo mais.
Cyber security não é a minha área, então pedi um relatório com todos os achados pro nosso querido amigo Fable 5. O primeiro item veio destacado em vermelho, caixa alta, com a categoria mais grave possível: BOLA (Broken Object Level Authorization). Eu nunca tinha visto esse termo, e ele estava registrado como uma falha grave que eu nem sabia que existia.
O sistema nasceu antes do advento da IA escrever código do jeito como faz hoje. Começou comigo codando na mão; meu foco era fazer funcionar para melhorar depois. Com a chegada da IA, parei de escrever código e passei a revisar e garantir qualidade. Só que, naquele relatório, eu não entendia metade dos erros listados. Não conhecia os nomes e muito menos sabia por que eram tão graves. E aí me caiu a ficha: eu sou um legítimo impostor. (Pelo menos era o que eu pensava e sentia.)
Escrever código era onde eu sentia o esforço, a prova concreta de estar construindo algo com a minha criatividade. Como o Brooks escreve em O Mítico Homem-Mês: "a programação é divertida, porque satisfaz anseios criativos acalentados profundamente dentro de nós". Minha prioridade era resolver o problema, mas, pra resolver, eu tinha que entender onde estava pisando. O que é esse tal BOLA, e por que é tão grave? Este artigo é o que eu descobri.
O que é BOLA
A OWASP, que mantém a lista das falhas de segurança de API mais críticas, coloca o BOLA em primeiro lugar no ranking de 2023: a mais comum e a mais fácil de explorar. Ou seja, está em todo lugar e não exige um gênio pra abusar.
A ideia central é simples. Toda vez que uma API recebe o ID de um objeto e faz alguma coisa com ele, seja POST, GET, DELETE ou o que for, ela precisa checar uma pergunta antes de responder: o usuário logado tem permissão pra acessar este objeto específico? Quando essa checagem falta, temos um BOLA. O atacante troca o ID na requisição e acessa o que não é dele.
Dois exemplos que a própria OWASP cita:
A loja online. Imagine um e-commerce onde cada loja vê seus gráficos de faturamento, alimentados por uma rota tipo /shops/{nomeDaLoja}/revenue_data.json. Um atacante inspeciona as requisições no navegador, descobre o padrão, pega uma lista com o nome de todas as lojas e, com um script simples trocando o nome na URL, acessa os dados de vendas de milhares de lojas. Nenhum "hack" sofisticado: só trocar um nome na URL.
O carro. Uma montadora permite controlar o veículo pelo celular (ligar o motor, trancar as portas) via API. Pra isso, o app envia o número de identificação do veículo (o VIN). O problema: a API não valida se aquele VIN pertence ao usuário logado. Resultado: trocando o VIN, um atacante controla carros que não são dele. Aqui o BOLA sai da tela e vira segurança física.
E o ID pode estar em vários lugares: no caminho da URL, na query string, no header ou no corpo da requisição. Não importa se é um número sequencial, um UUID ou uma string qualquer. Se dá pra manipular, é superfície de ataque.
BOLA não é BFLA
Quando comecei a estudar isso, esbarrei num primo do BOLA que vive sendo confundido com ele: o BFLA (Broken Function Level Authorization). Os dois são falhas de autorização e os nomes são quase iguais, então vale diferenciar.
BOLA é sobre o objeto errado, com a mesma função. Dois usuários comuns, mesma permissão. O usuário A acessa um dado do usuário B trocando o ID. Ninguém "subiu de nível": a role é idêntica. O que vazou foi o objeto de outra pessoa.
BFLA é sobre a função errada, com privilégio acima do seu. Um usuário comum executa uma ação que só um admin deveria poder, tipo uma rota DELETE /api/admin/users/5. Ele acessou uma função acima do nível dele.
Resumindo:
- BOLA: "sou um usuário comum e consegui ver a fatura de outro usuário comum." → objeto errado, mesmo nível.
- BFLA: "sou um usuário comum e consegui acessar o painel de admin." → função errada, nível acima.
Por que a autenticação não resolve isso
Na minha cabeça, esse BOLA podia ser até um falso positivo, visto que eu já tinha o fluxo de autenticação bem definido e o passava para cada rota. Então por que eu tinha um problema de segurança numa rota que já tinha autenticação?
O pulo do gato: a diferença entre autenticação e autorização.
- Autenticação responde: "quem é você?" É o login.
- Autorização responde: "o que você pode fazer?" É a permissão.
No BOLA, a autenticação está funcionando perfeitamente. O usuário 2 provou que é o usuário 2. O problema é 100% de autorização, e de um tipo específico que é fácil de esquecer. Porque autorização tem dois níveis:
- De função: "você pode chamar este endpoint?" Uma checagem só, na entrada da rota. Quando falha, é BFLA. Fácil de acertar, é um único portão.
- De objeto: "você pode acessar este registro específico?" Precisa rodar toda vez, pra cada objeto, comparando quem pede com quem é dono. Quando falha, é BOLA. Difícil de acertar porque não é um portão só: se repete em todo lugar.
E aqui está o motivo de o BOLA ser tão comum: o código funciona nos testes. Você desenvolve logado como usuário 1, pede o objeto do usuário 1, vê seus dados, tudo perfeito. Passa no code review, vai pra produção. O bug só aparece quando alguém pede o objeto dos outros, e ninguém faz isso por acidente. A falha fica invisível até alguém procurar por ela de propósito.
Em resumo: BOLA não é uma falha de "trancar a porta". A porta está trancada, o segurança conferiu o crachá, o usuário entrou legitimamente. O que falta é conferir se aquele documento que ele pediu lá dentro é dele antes de entregar. Autenticação cuida da entrada. BOLA acontece depois dela.
Onde isso mora no meu stack: Fastify + Drizzle
Duas ferramentas do meu backend, porque é entre elas que o BOLA nasce. Fastify é o framework web que uso no Node.js, o porteiro que recebe a requisição e chama o código da rota; é por onde os dados entram (request.params, request.query, request.body) e, portanto, por onde a entrada do ataque chega. Drizzle é o ORM que conversa com o banco (PostgreSQL) de forma type-safe e decide o que ele devolve, e é aí que a checagem de posse vai morar. A falha nasce no meio: quando pego o ID que veio do Fastify e busco no Drizzle sem cruzar com o dono do objeto.
O fix, em três níveis
Quando entendi o problema, a primeira solução que me veio foi "é só colocar um if na consulta". Funciona, mas é a pior das três. Vou mostrar em ordem, da mais ingênua à mais robusta, porque a evolução entre elas é o que me fez entender o assunto de verdade.
Nível 1: o if na mão (funciona, mas é frágil)
Busca o objeto e, antes de devolver, checa se o dono bate com quem está pedindo.
const relatorio = await db.query.relatorios.findFirst({
where: eq(relatorios.id, id)
});
if (relatorio.userId !== request.user.id) {
return reply.code(403).send();
}
Resolve o vazamento, mas tem dois problemas. O primeiro: depende de eu lembrar. Esse if precisa estar em toda rota, pra sempre. Crio uma rota numa sexta às 18h e esqueço, a vulnerabilidade volta. Nada pior do que segurança que depende de um humano lembrar dela. (E não adianta achar que delegar isso pra IA resolve: uma regra em linguagem natural do tipo "garanta autorização em todas as rotas" é ambígua, e a IA aplica de forma inconsistente. Você só troca o dev cansado pela IA.) O segundo problema, mais sutil: busco o objeto primeiro e checo depois. O banco já me devolveu um dado que talvez não seja meu, e confio no meu próprio if pra barrar. O ideal é o banco nunca me devolver o que não é meu.
Nível 2: a posse dentro da query (bom)
A virada: em vez de buscar o objeto e depois perguntar "isso é meu?", peço pro banco só me devolver o que é meu desde o início. A checagem de posse vira parte da própria busca.
const relatorio = await db.query.relatorios.findFirst({
where: and(
eq(relatorios.id, id),
eq(relatorios.userId, request.user.id)
)
});
if (!relatorio) return reply.code(404).send();
Não é mais "busca e pergunta se pode", é "busca apenas o que é meu". Se o relatório pertence a outro usuário, a query não retorna nada e o atacante recebe um 404. Pra ele, aquele objeto nem existe.
Um detalhe: mudei o 403 pro 404 de propósito. Um 403 ("proibido") confirma pro atacante que o objeto existe, ele só não pode ver. Um 404 não confirma nem a existência. Você nega o vazamento e ainda esconde a pista.
Nível 3: a posse estrutural (o certo)
O Nível 2 ainda tem o mesmo problema do Nível 1 disfarçado: eu preciso lembrar de adicionar o eq(relatorios.userId, ...) em toda query. O nível maduro é quando a posse deixa de ser algo que escrevo em cada rota e vira estrutural: uma camada central que injeta a verificação automaticamente, de forma que eu não consiga esquecer nem se quisesse. O objetivo é tornar o caminho seguro o único caminho possível.
No meu sistema, isso se materializou em três peças.
Uma única autoridade de escopo. Existe uma função no sistema inteiro, resolveVisibleOwnerIds(userId, role), que responde "quais donos de processo esse usuário pode enxergar". O ponto não é a regra em si, é que ela mora num arquivo só. Quando o modelo de hierarquia mudou, mudou num lugar, e listagem, KPIs, filtros e acesso a detalhe mudaram juntos. No Nível 2, essa regra estaria copiada em vinte queries, e três estariam desatualizadas.
Um único construtor de WHERE. As queries de listagem não escrevem a própria checagem: recebem um viewer (montado a partir do usuário autenticado) e passam por um buildScopedWhereClause() que injeta o predicado de visibilidade antes de qualquer filtro. E para acesso a um objeto individual existe um chokepoint equivalente que aprendi na marra: ele ignora o objeto que o handler passou e relê dono e empresa direto do banco. Se confiar no objeto que chegou, qualquer código que montar um objeto forjado se auto-autoriza.
Deny-by-default no middleware. Depois de resolver quem é o usuário, um gate de módulos garante: se a rota não está mapeada nas regras de acesso, perfil restrito recebe 403. Isso significa que quando eu crio um endpoint novo e esqueço de pensar em autorização, o resultado não é um vazamento silencioso que um atacante encontra seis meses depois, é um usuário legítimo bloqueado que me manda mensagem no sábado de manhã. Esquecer virou um bug barulhento em vez de uma vulnerabilidade muda. Essa é a definição prática de segurança estrutural: o sistema falha fechado, e a memória humana só é necessária para abrir caminhos, nunca para fechá-los.
De onde vem o userId
Tem uma pergunta escondida em todo o código acima. Comparo o dono do objeto com request.user.id. Mas de onde vem esse request.user.id? Do body? Header? Query? NEGATIVO!
A regra que precisei internalizar: tudo que vem do cliente é TERRITÓRIO INIMIGO. Body, header, query, params, tudo isso o atacante controla e edita à vontade. O "inspecionar do navegador" nem é o problema; qualquer um dispara uma requisição com curl ou Postman mandando o corpo que quiser. O body não é mais seguro que a URL: os dois vêm do cliente, os dois são falsificáveis.
Ou seja: se tiro o userId do corpo da requisição pra decidir quem é o dono, o atacante escreve { "userId": "id-de-outra-pessoa" } e eu mesmo entrego o vazamento de bandeja. Isso é pior do que não ter proteção, porque parece seguro. Eu criei um BOLA achando que estava resolvendo um.
A fonte correta do userId nunca é a requisição. É a identidade que o servidor estabeleceu quando o usuário se autenticou, normalmente num token assinado (JWT). No login, o servidor emite um token que carrega quem é aquele usuário, assinado com uma chave secreta que só ele tem. A cada requisição, um middleware valida o token e preenche o request.user.
A distinção fina que demorei pra sacar: "viver no cliente" é diferente de "ser controlado pelo cliente". O token fica guardado no navegador do usuário, sim, mas foi emitido e assinado pelo servidor. Se o atacante editar o userId dentro dele, a assinatura quebra e o servidor rejeita. Um caso é "o servidor disse quem você é e lacrou o envelope"; o outro é "o cliente afirmou quem é e eu acreditei".
"E se o atacante quebrar meu JWT?"
Quando entendi que a segurança dependia da assinatura do token, veio o medo: e se o atacante conseguir descriptografar meu JWT? Foi aí que descobri que a pergunta já estava errada.
O mal-entendido: JWT não é criptografado, ele é assinado. São coisas diferentes. Um JWT comum tem três partes separadas por ponto: header, payload e assinatura. As duas primeiras são apenas Base64, que é codificação, não criptografia. Qualquer um decodifica e lê o payload inteiro, incluindo o userId. Não existe "descriptografar" porque não há nada cifrado ali. O conteúdo é público por design.
Então o que protege o token? A assinatura. O servidor gera uma assinatura do header + payload usando uma chave secreta que só ele tem. O ataque que você teme na verdade é: o atacante lê o payload (fácil), edita o userId, e precisa gerar uma assinatura válida pro token adulterado. É aqui que ele trava, porque isso exige a chave secreta. Sem ela, o servidor recalcula a assinatura, vê que não bate, e rejeita.
Ou seja, a pergunta certa não é "quais as chances de descriptografar?" e sim "quais as chances de forjar uma assinatura sem a chave?". Com HS256 e uma chave forte de 256 bits, o atacante teria que adivinhar entre 2²⁵⁶ possibilidades (na casa de 10⁷⁷, comparável à quantidade de átomos no universo observável). Força bruta está fora de cogitação.
Mas essa matemática só vale se a chave for forte. O ataque real contra JWT quase nunca é força bruta na assinatura. É um destes três: chave fraca (se você usa "secret" como segredo, o hashcat quebra em minutos, e a matemática de 2²⁵⁶ vira 2²⁰ na prática); confusão de algoritmo (bibliotecas mal configuradas aceitavam tokens com "alg": "none", sem assinatura); e vazamento da chave (segredo commitado no Git, hardcoded, exposto num env, aí não tem matemática que salve).
A lição honesta: a assinatura do JWT é matematicamente sólida, se a chave for forte e o algoritmo bem configurado. O elo fraco nunca é o SHA-256, é a chave e a implementação. A defesa é: chave longa e aleatória (nunca uma palavra do dicionário), guardada fora do código, algoritmo fixado explicitamente na verificação, e rotação quando fizer sentido.
Defesa em profundidade: várias paredes, não uma
Mesmo com 2²⁵⁶ possibilidades, esse "quase impossível" ainda me incomodava. E se a chave vazar? Porque no mundo real, chave de 256 bits não é adivinhada, ela vaza: vai parar num commit do Git, num log, num arquivo de configuração. Raríssimo pela matemática, mas plausível por erro humano. A pergunta certa deixa de ser "e se o improvável acontecer?" e vira: eu projetei meu sistema pra que uma única falha seja catastrófica?
É aí que entra o conceito que fecha a história: defesa em profundidade. Não depender de uma camada só. O JWT diz "essa requisição é do usuário X", mas eu não confio nisso como única barreira. Por baixo dele continuam valendo as outras camadas: a posse validada na query (um token comprometido só acessa o que o token afirma, não o sistema inteiro), os acessos auditados, os tokens de vida curta, o menor privilégio.
Segurança não é uma parede alta, é várias paredes. Nenhum sistema sério aposta tudo numa camada, porque qualquer camada pode falhar, seja pela matemática (quase nunca) ou pelo erro humano (o tempo todo). Você não protege contra BOLA confiando que o JWT é inquebrável. Você protege assumindo que tudo pode falhar, e garantindo que uma falha isolada não vire um vazamento total.
Fechamento
Lá no começo eu disse que me senti um impostor. Um relatório cheio de vulnerabilidades que eu não sabia nomear, num sistema que eu mesmo construí, resolvidas com a ajuda de uma IA que fazia o trabalho que antes era meu. Era a prova de que eu tinha ficado pra trás.
Só que, escrevendo esse artigo, percebi que a história era outra. Eu não sabia o nome BOLA, mas passei o artigo inteiro explicando princípios que eu já aplicava no meu sistema sem saber que tinham nome. Entender o BOLA não me devolveu só uma correção de segurança. Me devolveu a parte do trabalho que eu achava perdida: parar, estudar uma coisa difícil, travar, destravar, e construir entendimento com as próprias mãos. O código que a IA escreve por mim eu não sinto como meu. Este conhecimento eu sinto.
Confesso que quase não postei isto, porque pensei "como é que eu vou escrever um artigo sobre BOLA se eu nem sei direito a definição?". Mas me provei errado: aprendi bastante justamente pra escrever este artigo, e encontrei um objetivo novo em compartilhar conhecimento.
Se tu chegou até aqui e se reconheceu no impostor do começo, deixo uma reflexão: comece. A ferramenta principal pra qualquer bom desenvolvedor é a curiosidade. Não saber o nome de uma coisa não significa que você não seja capaz de entendê-la. Significa só que ainda não parou pra estudar. E, como o Brooks já sabia, essa parada pra construir entendimento é onde mora boa parte da diversão de programar. A IA pode escrever o código. Entender por que ele funciona ainda é, e vai continuar sendo, o nosso trabalho.