O header que a borda reescrevia por cima
LifeLog·

O header que a borda reescrevia por cima

6 min de leitura← Voltar para timeline

No dia 18 de outubro o CI do blog ficou vermelho num teste que ninguém tinha tocado. O arquivo estava igual havia semanas, o site respondia 200, o build passava — e o job que valida headers de segurança em produção começou a falhar. Verde até as 11:08 daquele dia, vermelho depois.

O teste em questão não roda contra o servidor de desenvolvimento. Ele faz requisições HTTP diretas na URL de produção, de propósito: headers de segurança são aplicados na entrega, não no astro dev. Seis asserções, todas simples. Duas delas pararam de passar:

expect(csp).toContain("default-src 'self'")
expect(csp).toContain("script-src")

O header que chegava no cliente era este, na íntegra:

content-security-policy: frame-ancestors 'self'; base-uri 'self'

Duas diretivas. O vercel.json do repositório define onze — default-src, script-src, style-src, font-src, img-src, connect-src e mais. Onze viraram duas no caminho, e nenhuma das que sumiram era decorativa: sem default-src e sem script-src, a política não restringe origem de script nenhuma. O site continuava funcionando, o navegador não reclamava, e a proteção anti-XSS simplesmente não existia mais.

A causa não estava no repositório

O culpado foi eu, horas antes. No mesmo 18 de outubro eu tinha padronizado headers de segurança para todos os hosts da zona, criando uma regra de transformação de resposta na camada de borda. A ideia era boa no papel: um lugar só, uma política só, todo mundo protegido sem precisar repetir configuração em cada projeto.

O detalhe que faltou entender é a ordem de execução. A regra de borda roda depois da origem. Ela não soma ao header que a aplicação mandou — ela substitui. Quando o vercel.json responde com a política completa e a borda responde com a versão genérica, o que o navegador recebe é a última. A política mais fraca ganha por posição, não por mérito.

Isso não é um bug de configuração, é uma diferença de escopo. A borda enxerga hosts; a aplicação enxerga o próprio site. Uma política escrita para valer em todos os hosts tem que ser genérica o suficiente para não quebrar nenhum deles — e genérica o suficiente para não proteger nenhum de verdade. O host que tinha uma política melhor não tinha como avisar.

O conserto em uma linha

O commit dddb1a0, de 19 de outubro às 01:23, mexe em um único arquivo com uma inserção e uma remoção. Duas coisas ao mesmo tempo, e a segunda é a que quase passou batido.

A primeira: a regra da zona passou a excluir o host do blog. Onde a origem tem política própria, a origem manda — a borda sai do caminho.

A segunda: form-action ganhou um host a mais. Sem isso, o conserto da CSP deixaria um buraco novo, porque na mesma madrugada o commit 0c96ecc (00:17, três arquivos, 168 linhas) tinha adicionado o bloco de “próximo passo” no fim de cada post — com captura de e-mail que envia para outra aplicação, de outro domínio.

form-action 'self' bloqueia envio de formulário para fora da origem. O navegador não avisa o visitante, não mostra erro na tela, não deixa rastro no servidor: o POST simplesmente nunca sai. Quem preenchesse o e-mail veria a página recarregar e nada acontecer. Um formulário que falha em silêncio é pior do que um formulário que não existe, porque ninguém vai reportar.

Por que o teste pegou e o build não

Vale reparar em quem descobriu o problema. Não foi o astro check, não foi o build, não foi nenhum teste unitário. Foi um teste DAST que faz requisição real na produção e lê o header que voltou.

O astro dev não passa pela borda. O build estático não sabe o que é header. O preview da Vercel também não, quando a regra está na zona. Toda a cadeia de verificação local ficaria verde para sempre, com a política de segurança reduzida a dois terços de nada — porque nenhuma etapa dela atravessa o caminho onde a mutação acontecia.

A asserção que quebrou era frouxa de propósito, toContain("script-src"). Ela não valida a política inteira; valida que existe uma. Foi o suficiente. Um teste que confere presença teria pegado isso mesmo se a borda tivesse trocado onze diretivas por dez.

Ficou medido, e é o que dá para conferir hoje:

BLOG=https://lifelog-sepia.vercel.app
curl -sI "$BLOG/" | grep -i content-security-policy
# default-src 'self'; script-src ... ; form-action 'self' <host-da-loja>
git -C ~/projetos/lifelog show dddb1a0 --stat
# vercel.json | 2 +-    (1 inserção, 1 remoção)

O que fica dessa madrugada

Header de segurança na borda é política global, e política global é política de menor denominador. Se um host precisa de mais, ele precisa de exceção explícita — não de confiança de que alguém vai lembrar.

E o segundo aprendizado é sobre onde testar. O mesmo commit que consertou a CSP também tinha acabado de introduzir uma dependência cross-origin nova (form-action). As duas mudanças se cruzam: uma abre o formulário para fora, a outra restringe o que pode sair. Nenhuma das duas aparece em teste que não atravesse a produção inteira.

Na mesma madrugada saíram mais quatro commits, todos pelo mesmo motivo: contraste de botão no tema claro reprovando no axe, UTM nos links cruzados, e duas rodadas de baseline visual porque o snapshot gerado numa máquina não batia com o da CI. Seis commits entre 00:17 e 02:01, todos com a mesma origem — coisas que só aparecem quando o código encontra a entrega real.

O próximo passo é o 85f1508, de 21 de outubro: posts relacionados no fim de cada post, primeira fatia do plano de autoridade. Mesma lógica, escopo diferente — o link interno passa a existir onde antes o leitor terminava a página e saía.

Leia também

Mesmo projeto, mesma trilha — as histórias se conectam.

Próximo passo

Se isto te serviu, tem mais: escrevo sobre o mesmo assunto nos outros projetos, e a loja tem as ferramentas que eu uso aqui.

Sem spam, descadastro de um clique. Também estou aqui: Loja de skills · Portfólio