Num único dia fechei seis falhas de segurança numa API de transcrição que estava no ar havia meses. Nenhuma foi encontrada por um teste. Os testes passavam em verde antes e depois. O que mudou não foi a ferramenta: foi eu sentar para ler meu próprio código me perguntando como o quebraria se quisesse, em vez de se ele fazia o que eu tinha pensado.
Essa diferença parece de atitude. É de método, e aparece no tipo de falha que sai.
Um teste verifica o caminho que você imaginou
Um teste se escreve a partir do caso de uso: dados razoáveis, saída razoável. Um atacante não passa dados razoáveis. Passa o vazio, o zero, o negativo, o enorme. Esses valores raramente estão na suíte, porque quando você escreve o teste não pensa neles e quando pensa já viu o bug.
O caso mais claro foi uma ACL vazia. O buscador de recuperação recebia uma lista de identificadores aos quais o usuário tinha acesso e filtrava o corpus por ela. Se a lista vinha vazia, o código interpretava como "não há filtro a aplicar" em vez de "este usuário não tem acesso a nada". Devolvia o corpus inteiro.
Isso falhava aberto: o valor padrão de uma ausência de permissões era permitir tudo. Nenhum teste de caminho feliz toca nisso, porque no caminho feliz a lista sempre traz algo.
Desde então reviso todos os filtros de autorização com a mesma pergunta: o que isto faz se a coleção estiver vazia? Não "o que faz se o usuário não tiver permissão?", que costuma estar bem coberto, mas o que faz com o vazio.
As categorias se repetem mais do que parece
Os achados se agrupam quase sempre nas mesmas famílias.
- Permissões que não são exigidas onde deveriam. Um endpoint de exportação que deixava tirar dados sem pedir a autoridade de gestão de estrutura. Uma exclusão em cascata de um domínio que verificava a permissão uma vez no começo em vez de verificar a autoridade de exclusão dura sobre cada elemento afetado. A cascata é o lugar clássico: você autoriza a operação e depois o loop interno faz coisas que ninguém autorizou.
- Limites que não existem. As listagens de administração passavam o
limitque chegasse direto para a consulta, sem teto. Um valor grande vindo de um script materializava a tabela inteira na memória do worker. O conserto cabe numa linha,Math.min(Math.max(limit, 1), 1000), mas não estava lá porque na UI olimitsempre valia dez. - Estado que se verifica uma vez e nunca mais.
authenticateApiKeyvalidava o hash, a expiração e a flagisActiveda chave. Mas a linha da chave sobrevive ao estado da conta do dono: se o registro de faturamento é limpo ao cancelar alguém, ninguém baixa aquela flag e a chave continua autenticando. Agora a função revalida que o proprietário siga tendo registro de faturamento, num só lugar, para que todos os endpoints que autenticam por chave herdem isso em vez de cada um ter que lembrar. - Conteúdo de terceiros que é renderizado. Aqui está o que mais me surpreendeu.
CSS também é uma injeção
O sanitizador de markdown permitia os atributos style e class livres em div, span e svg. A cadeia vai de raw a sanitize a harden e nenhuma etapa olha dentro do CSS: a biblioteca de sanitização trata style como string opaca e o endurecedor só reescreve URLs em a e img. O estilo chegava intacto ao render final.
Pensei em escrever um parser de CSS com lista branca de propriedades. Não fiz: verifiquei que nenhuma view legítima precisava de style e tirei os dois atributos da lista permitida. Um filtro de CSS próprio é superfície nova para manter, cobrindo um caso que não existia.
O importante do achado não é o conserto, é a classe de vetor. Não é preciso JavaScript para atacar outro usuário: com CSS dá para cobrir um botão, mover um texto por cima de outro ou mudar o que parece que uma mensagem diz. E qualquer view que pinte conteúdo alheio, não só o próprio, é um vetor cross-user.
Segredos em logs e links que duram demais
Dois achados que não são bugs de lógica, e sim de ciclo de vida.
O primeiro: quando a autenticação por API key falhava por chave corrompida ou malformada, o catch colocava a chave crua no contexto do log, e o logger serializava esse contexto com JSON.stringify sem redigir nada. Uma chave válida enviada a um endpoint errado acabava em texto claro no log. A redação que coloquei ataca por dois lados, por nome de campo (/rawkey|token|authorization|password/i) e por forma do valor (o prefixo dk_live_ / dk_test_), e deixa visíveis os oito primeiros caracteres seguidos de ***REDACTED***, o mesmo formato com que a UI volta a esconder a chave depois de mostrá-la uma vez.
O segundo: as URLs assinadas do S3 para o player de áudio eram geradas com expiresIn de sete dias. Sete dias é uma eternidade para um link que só precisa sobreviver ao carregamento de um <audio>. Qualquer um com o link — encaminhado, no histórico, cacheado por um proxy corporativo — mantinha acesso ao áudio original durante toda essa janela, e apagar a transcrição não revogava nada. Baixei a expiração para 600 segundos, com renovação a partir do cliente aos 480, e adicionei o DeleteObjectCommand contra o objeto ao apagar a transcrição, porque até então a cascata só apagava a linha.
O custo em experiência de usuário foi zero, exceto um fluxo: reencaminhar o link do áudio por e-mail para um colega. Esse fluxo nunca foi uma funcionalidade suportada nem tinha controle de acesso algum, e me parece correto que tenha deixado de funcionar.
Agora eu programo isso em vez de lembrar
O problema das auditorias é que dependem de você ter vontade num certo dia. A minha saiu porque eu estava documentando decisões de design e, ao reler, algumas coisas me soaram mal.
Cada achado vira um arquivo curto em docs/security/ com o vetor e por que o conserto é aquele e não outro, e daí sai a lista com que reviso o resto. Quando repeti o exercício contra o boilerplate com que começo projetos saíram trinta e nove pontos entre segurança, SEO e arquitetura, e a maioria eram versões do mesmo punhado de perguntas: o que acontece com a lista vazia, quem exige a permissão no loop interno, qual é o teto deste limite, quanto vive este link.
Ler seu código para confirmar que funciona e lê-lo para ver como quebrá-lo são duas leituras distintas. A segunda não acontece sozinha enquanto você faz a primeira.
