
O guard que nunca chegou a rodar
Em setembro eu escrevi um filtro de uma tarde inteira para proteger o download do meu currículo, e na semana seguinte descobri que ele não filtrava nada. Não era bug. Era premissa errada sobre o que um caminho de URL é.
O que eu achei que estava protegido
O currículo tem dois caminhos de download possíveis. Um passa por um modal que registra consentimento, grava o evento e dispara notificação. O outro é tentar abrir o arquivo direto pelo endereço público, sem nenhuma dessas três coisas.
O segundo não me interessava por discrição, mas por rastreabilidade: sem consentimento registrado e sem evento gravado, o download saía do meu radar completamente. Um relatório de acessos que não sabe quantas vezes o currículo saiu não é um relatório, é uma decoração.
Então escrevi o filtro. A ideia era simples e, olhando de longe, parecia à prova de tudo: eu tinha dois nomes de arquivo, então listava os dois caminhos exatos e devolvia 404 para qualquer coisa que casasse. Testei o caminho exato, recebi 404, fechei o notebook.
O sintoma
Dois dias depois, medindo o arquivo em produção nos dois domínios, recebi isto:
/Samuel_Andrade_2026.pdf 404
/%53amuel_Andrade_2026.pdf 200
/Sam%75el_Andrade_2026.pdf 200
/Samuel_Andrade_2026.p%64f 200
/Samuel%5FAndrade%5F2026%2epdf 200
Todos os 200 entregavam o arquivo real: mesmo tamanho, mesmo hash, mesmo conteúdo do repositório. A proteção existia apenas para a forma escrita exata do nome, e para nenhuma outra.
O detalhe que eu não tinha considerado: %53 é S, %75 é u, %5F é _, %2e é .. São as
mesmas letras. É literalmente o mesmo arquivo, escrito de outro jeito.
Duas camadas, falhando juntas
Havia dois motivos independentes, e qualquer um dos dois bastaria para o arquivo escapar.
O primeiro é o matcher. Ele decide quais requisições chegam ao meu código comparando o caminho
cru contra uma lista. Se o caminho não casar, o meu código não é executado — não existe chance de
ele recusar qualquer coisa. E a comparação acontece antes de qualquer decodificação. Então
Sam%75el_Andrade_2026.pdf simplesmente não é a string que eu listei, o filtro não é chamado, e a
requisição segue direto para o servidor de arquivos estáticos.
O segundo é o Set.has(). Mesmo que o matcher casasse, eu comparava o caminho normalizado de um
jeito insuficiente: só o deixava em minúsculas. %75 continua sendo %75 depois de
toLowerCase(). O conjunto não tem o que casar.
E o pior: os dois juntos tornavam o filtro impossível de testar de forma honesta. Um teste que manda o
caminho exato passa. Um teste que manda o caminho codificado também “passa”, porque o matcher
simplesmente não chama o meu código — e o teste só falha se ele olhar o status da resposta. Bastava
o teste verificar “o filtro respondeu” em vez de “o filtro recusou”, e ele ficaria verde para sempre.
A classe inteira do problema
O que eu tinha construído era uma lista fechada de formas corretas contra um espaço aberto de formas equivalentes.
Um nome de arquivo tem infinitas representações válidas na URL. Cada caractere pode estar escrito
ou codificado. O encoding pode ser simples ou duplo. A letra pode estar em caixa alta ou baixa. O
caminho pode ter barras duplicadas, segmentos . e .., barra no final, espaço no final. Filtrar por
lista exige saber todas as formas — e a lista nunca está completa, porque a próxima forma aparece no
dia seguinte, sozinha, e não avisa.
A inversão é o truque inteiro. Em vez de listar as formas que devem passar, eu descarto a lista e reduzo toda requisição a uma forma canônica única. Se não existe representação que escape, não existe representação listável.
A correção
Três camadas, nessa ordem.
A primeira é a mais cara e a mais importante: tirar o matcher. Sem lista de caminhos, o filtro roda
em toda requisição. Não existe encoding que “não case”, porque não existe lista para casar. Todo
request entra no meu código e eu decido o que fazer com ele. O preço é que o filtro passou a ser
chamado também para cada imagem e cada script do site. A função é uma comparação de string, sem I/O,
sem banco, sem rede — o custo é desprezível ao lado do que já acontece em cada requisição de asset.
A segunda é a normalização até o fim. Decodifico percent-encoding repetidamente até o valor parar de
mudar, o que cobre também o duplo encoding (%2575 vira %75 vira u). Depois colapso segmentos .
e .. e barras duplicadas, e comparo o último segmento do caminho em minúsculas. São duas passadas,
e a segunda é necessária: %2e%2e só vira .. depois do decode, e só pode ser colapsado na
passada seguinte. Qualquer caminho com byte nulo é recusado antes de qualquer comparação, porque byte
nulo é tentativa, não endereço.
A terceira é o registro. Só no caminho do bloqueio eu escrevo uma linha de log com o caminho e o agente que pediu. Nada no caminho liberado, então o custo é zero no fluxo normal. E o log está dentro de um bloco que nunca pode lançar exceção: o filtro roda em toda requisição agora, e uma exceção ali transformaria um problema de privacidade em um site fora do ar. Prefiro um 404 silencioso a derrubar tudo.
Como se prova que um filtro funciona
A parte que eu tinha pulado é a mais importante, e é a que teria me economizado a semana.
Um teste que passa não prova nada se ele nunca teria falhado. O jeito de provar é o controle negativo: rodar o mesmo teste contra o alvo quebrado e exigir que ele falhe.
Fiz isso com uma especificação de ponta a ponta: ela envia os vetores que eu tinha medido em produção e exige resposta de bloqueio. Contra a versão antiga, em produção, ela acusou exatamente seis falhas, as seis formas que serviam o arquivo. Depois da correção, contra o build local, dezesseis de dezesseis passaram.
O número seis importa tanto quanto o dezesseis. Não foi “o teste falhou”. Foi “o teste falhou exatamente nos casos que eu medi no mundo real”. Um teste que falha em três vetores que eu nunca observei está validando a minha imaginação, não o problema.
O teste unitário cresceu de cinco casos para cinquenta e nove: os vetores medidos em produção, os
caminhos que têm que continuar funcionando, e um contrato que falha se alguém voltar a declarar o
matcher. Esse último é o que realmente importa, porque transforma a decisão em uma regra que o
repositório não deixa você desfazer sem perceber.
Números
| Medida | Antes | Depois |
|---|---|---|
| Formas do caminho que serviam o arquivo em produção | 5 de 6 | 0 de 6 |
| Casos no teste unitário do filtro | 5 | 59 |
| Casos do teste de ponta a ponta | 0 | 16 |
| Controle negativo contra a versão quebrada | — | 6 falhas esperadas |
| Linhas do filtro | 29 | 138 |
| Requisições que passam pelo filtro | só as listadas | todas |
O que sobrou da lição
A primeira é a que eu quero lembrar: filtro por caminho é comparação de forma, e quem resolve o caminho é o servidor, não eu. Enquanto a comparação e a resolução forem feitas em formas diferentes, existe um espaço entre as duas, e é por esse espaço que o arquivo passa.
A segunda: teste verde sem controle negativo não é prova, é decoração. Eu podia ter escrito trinta casos, todos passando, e continuaria com o currículo exposto.
A terceira é a mais chata e a mais útil: um filtro que nunca é chamado não parece quebrado por nenhum sinal. Não dá erro, não aparece no log, não derruba nada. Ele simplesmente não está lá. E como a ausência de erro se parece muito com a presença de proteção, é muito fácil olhar para o 404 do caminho exato e concluir que está tudo certo.