02Segurança5 min

O que aprendi auditando meu próprio código como se fosse de outra pessoa

Uma ACL vazia que devolvia o corpus inteiro, uma injeção de CSS sem uma linha de JavaScript e uma API key que continuava valendo depois de cancelar a conta do dono. Tudo num dia, tudo em código que eu tinha escrito.

Por Salva Castells

Imagem de capa: Ildar Sagdejev (Specious), CC BY-SA 3.0.
Neste post

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 limit que 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 o limit sempre valia dez.
  • Estado que se verifica uma vez e nunca mais. authenticateApiKey validava o hash, a expiração e a flag isActive da 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.

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.