CSP e Trusted Types na página de verificação: fechando o caminho do XSS

UNIFOKAL8 min de leituraProduto e integração

Um script injetado na tela que abre a câmera rouba imagem de documento, não senha. Como nonce, frame-ancestors, connect-src e Trusted Types fecham o caminho, e o que quebra no processo.

A página que pede a foto do documento e liga a câmera do usuário é uma das telas mais valiosas de qualquer produto digital. Um script injetado ali não rouba uma senha que se troca em trinta segundos: rouba a imagem de um documento de identidade, a selfie da pessoa e o resultado da verificação, material que não se revoga nem se redefine. É um alvo caro, e merece uma defesa proporcional.

A defesa mais eficaz não é encontrar o último ponto de injeção do código, tarefa que nunca termina. É reduzir o que um script injetado consegue fazer, e depois eliminar a categoria de bug que permite que uma string vire script. As duas camadas têm especificação pública no W3C: Content Security Policy e Trusted Types.

Este texto é sobre montar essas duas camadas numa tela de verificação sem quebrá-la no caminho, e sobre o detalhe que derruba integrações inteiras: a política de quem hospeda a página precisa autorizar o componente de terceiro que roda dentro dela.

O que a CSP faz, e o que ela não faz

A Content Security Policy Level 3 é uma lista de permissões que a página declara em um cabeçalho HTTP, e que o navegador passa a impor. Ela responde a perguntas do tipo: de onde este documento pode carregar script, para onde ele pode abrir conexões, quais imagens pode exibir, quem pode embuti-lo num quadro.

O que ela não faz merece a mesma clareza. A CSP não corrige a falha que permitiu a injeção. Ela é contenção: mesmo que um atacante consiga colocar conteúdo na página, a política limita o estrago, tipicamente impedindo que o script execute ou que o dado colhido saia para um servidor estranho. Tratar CSP como substituto de codificação de saída e validação de entrada é o mal-entendido mais comum, e a documentação da OWASP no Cross Site Scripting Prevention Cheat Sheet é explícita ao colocá-la como camada adicional, não como remédio.

E há um detalhe que decide se a política vale alguma coisa: a diretiva de script com 'unsafe-inline'. Sozinha, sem nonce nem hash na mesma diretiva, ela autoriza qualquer script embutido no documento, que é exatamente o que o atacante injeta: a política continua bonita no cabeçalho e vazia na prática. Vale saber a exceção, porque ela é a configuração recomendada para compatibilidade: quando a mesma diretiva traz um nonce ou um hash, a especificação manda o navegador IGNORAR o 'unsafe-inline', que passa a servir só para navegadores antigos demais para entender nonce.

Nonce por requisição, e o cache que estraga tudo

A alternativa ao 'unsafe-inline' descrita na especificação é o nonce: um valor aleatório gerado a cada resposta, colocado no cabeçalho e repetido no atributo de cada script legítimo. O navegador executa apenas os scripts cujo atributo bate com o nonce daquela resposta. Como o atacante não conhece o valor, o script injetado não roda.

A palavra decisiva é "a cada resposta". Um nonce fixo, reaproveitado entre usuários, é apenas uma senha pública: basta ler o código-fonte da página uma vez para saber o valor que autoriza qualquer script. Isso tem uma consequência arquitetural que pega muita gente de surpresa: a página não pode ser servida de um cache estático com o HTML já pronto e o nonce dentro. Ou o HTML é gerado a cada requisição, ou o nonce é injetado na borda no momento da entrega. Quem tenta conciliar cache agressivo e nonce por requisição sem esse cuidado acaba fixando o valor sem perceber, e a política vira enfeite.

Para conteúdo realmente estático existe alternativa, o hash do script, que autoriza um conteúdo exato em vez de um valor por requisição.

As diretivas que importam nesta tela

Uma tela de verificação tem um punhado de diretivas que fazem trabalho de verdade.

  • frame-ancestors decide quem pode embutir a sua página num quadro. É o que impede que um site hostil coloque a verificação real dentro de uma moldura enganosa, técnica próxima da que sustenta a página de verificação falsa. Numa integração legítima, essa lista contém os domínios dos clientes que embutem o fluxo, e nada além disso.
  • connect-src limita para onde o JavaScript da página pode abrir requisições e conexões persistentes. É a diretiva que transforma um roubo de imagem em tentativa bloqueada, porque o script injetado até pode ler o que quiser da página, mas não consegue enviar para fora da lista.
  • img-src e media-src limitam a origem do que a página exibe, o que também restringe truques de exfiltração por meio de URL de imagem.
  • default-src fecha o resto, e a prática recomendada é começar por 'none' e abrir apenas o necessário, em vez do caminho inverso.
  • form-action limita para onde um formulário pode ser submetido, detalhe esquecido em páginas que ainda têm formulários clássicos.

Diretivas apertadas também disciplinam a própria equipe: um serviço novo de métrica ou de chat passa a exigir decisão consciente, registrada na política, em vez de entrar de carona numa tag colada às pressas.

Ligar sem quebrar: report-only e o endpoint de relatório

Publicar uma política restritiva direto em produção costuma resultar em tela branca. O caminho descrito na especificação é o modo somente relatório, no qual o navegador não bloqueia nada, apenas relata o que teria bloqueado. Roda-se a política nesse modo por algumas semanas, com tráfego real e a variedade de aparelhos que só a produção tem, e a lista de violações vira o inventário do que a página realmente usa.

O endpoint que recebe esses relatórios continua útil depois que a política entra em vigor. Ele avisa quando alguém introduziu uma dependência nova sem atualizar a política, e mostra tentativas de injeção que a política barrou, sinal de ataque que nenhum outro log da aplicação registraria. Vale filtrar o ruído: extensões de navegador geram violações em volume, e um endpoint que recebe tudo sem agregação vira lixo em vez de sinal.

Trusted Types: atacar a causa, não o sintoma

A CSP contém o estrago. A especificação Trusted Types do W3C ataca a causa do XSS baseado em DOM, aquele em que nenhum servidor devolve HTML malicioso: o próprio JavaScript da página pega um valor controlado pelo atacante, vindo da URL ou de uma mensagem entre janelas, e o entrega a uma função que transforma texto em marcação viva.

Com a diretiva require-trusted-types-for 'script' ativa, o navegador passa a recusar strings comuns nesses pontos perigosos. Só um objeto produzido por uma política nomeada, criada explicitamente pelo código da aplicação, é aceito. O efeito prático é que os lugares onde texto vira código deixam de estar espalhados por todo o código e passam a caber numa função auditável. Deixa de ser preciso confiar em todo desenvolvedor que escrever uma linha nova: basta revisar as políticas declaradas.

O custo é real e vale dizer sem maquiagem. Bibliotecas antigas escrevem HTML de forma direta e quebram assim que a diretiva entra. Adotar Trusted Types costuma exigir substituir dependências ou registrar políticas específicas para elas, e por isso a ordem usual é ligar em modo relatório primeiro, medir o problema e só então impor. A documentação do MDN Web Docs cobre o suporte por navegador, que não é universal, o que reforça o ponto: Trusted Types elimina uma classe de bug em quem tem suporte, e não dispensa a CSP em quem não tem.

O ponto que o integrador esquece

Existe um detalhe que transforma uma boa política em chamado de suporte. Quando a verificação roda embutida no site do cliente, quem manda ali é a política do cliente, não a sua. Se a CSP dele não autorizar a origem do componente, a verificação simplesmente não carrega, e o sintoma que o usuário relata é uma área em branco no meio do cadastro.

Por isso a documentação de integração precisa dizer, em letras grandes, quais origens o site que hospeda deve permitir em script-src, connect-src, img-src e frame-src, e essa lista é parte do contrato: mudar de domínio de entrega sem avisar quebra todos os clientes que fizeram a lição de casa. O efeito pesa na escolha entre widget embutido e integração por API, já que cada caminho tem superfície de política diferente. E a mesma disciplina vale para o envio dos arquivos: connect-src restrito só combina com upload direto ao armazenamento se a origem do armazenamento estiver declarada dos dois lados.

Fontes citadas