Um site criado por IA pode personalizar um título com a query string, renderizar uma resposta de API ou montar uma confirmação de formulário usando innerHTML. O resultado parece correto com dados comuns. Quando uma entrada controlável alcança uma API que interpreta HTML ou JavaScript, porém, a mesma conveniência pode virar DOM XSS.

Prevenir DOM XSS é rastrear fontes de dados até os pontos em que o navegador os interpreta. A correção principal acontece no código: mantenha texto como texto, construa elementos com APIs seguras, valide URLs e reserve parsing de HTML para casos realmente necessários e sanitizados.

Este guia organiza a revisão no código, na build e na URL publicada sem tratar Content Security Policy como substituto para remover o fluxo vulnerável.

Entenda o fluxo: fonte, transformação e sink

DOM XSS nasce no navegador. Um valor chega de uma fonte controlável, atravessa transformações e alcança um sink capaz de interpretar marcação, script ou URL executável.

Fontes comuns incluem:

  • location.search, location.hash e partes da URL;
  • campos de formulário;
  • respostas de API;
  • localStorage, sessionStorage e IndexedDB;
  • postMessage;
  • atributos data-* e conteúdo inserido por terceiros;
  • configuração remota, CMS e arquivos JSON.

Sinks de alto risco incluem:

  • innerHTML, outerHTML e insertAdjacentHTML();
  • document.write();
  • eval() e Function();
  • timers que recebem código como string;
  • criação de <script> com origem controlável;
  • navegação para URLs não validadas, especialmente esquemas executáveis.

A especificação de Trusted Types do W3C chama essas APIs poderosas de injection sinks. A presença de um sink não prova vulnerabilidade sozinha; o risco aparece quando uma entrada não confiável pode alcançá-lo sem uma fronteira adequada.

Faça uma busca orientada a dados

Comece pelas fontes e sinks, sem editar:

Terminal window
rg -n \
'innerHTML|outerHTML|insertAdjacentHTML|document\.write|eval\(|new Function|setTimeout\(|setInterval\(' \
src public
rg -n \
'location\.(search|hash|href)|URLSearchParams|postMessage|localStorage|sessionStorage|fetch\(' \
src public

Expanda os caminhos conforme o projeto. Depois descreva cada fluxo:

fonte:
quem controla o valor:
transformações:
sink:
contexto interpretado (texto, HTML, atributo, URL ou script):
validação ou sanitização existente:
teste que alcança o fluxo:

Não encerre a revisão porque o valor veio da própria API ou do armazenamento local. APIs podem refletir conteúdo externo, e estado do navegador pode ser alterado por uma pessoa, extensão, script de terceiro ou versão anterior da aplicação.

Use texto como texto

Quando o objetivo é exibir uma string, use textContent. A documentação da MDN recomenda evitar innerHTML para texto porque ele invoca o parser de HTML e pode abrir risco de XSS.

Evite:

const params = new URLSearchParams(location.search);
message.innerHTML = `Olá, ${params.get("name")}`;

Prefira:

const params = new URLSearchParams(location.search);
const name = params.get("name") ?? "visitante";
message.textContent = `Olá, ${name}`;

O segundo exemplo não transforma a string em marcação. Ainda pode ser necessário limitar tamanho, normalizar espaços ou validar o conteúdo conforme a experiência, mas caracteres de HTML permanecem texto.

Para estruturas compostas, crie nós explicitamente:

const strong = document.createElement("strong");
strong.textContent = name;
message.replaceChildren("Olá, ", strong);

Essa abordagem também deixa visível qual parte é estrutura criada pelo código e qual parte é conteúdo recebido.

Valide URLs pelo destino e pelo protocolo

Nem todo problema passa por innerHTML. Um valor pode controlar href, src, action ou uma navegação por JavaScript.

Analise a URL com a API URL, defina a base esperada e aplique uma allowlist:

const candidate = new URL(rawUrl, location.origin);
const allowedProtocols = new Set(["https:", "http:"]);
if (!allowedProtocols.has(candidate.protocol)) {
throw new Error("Unsupported URL protocol");
}

Para links que deveriam permanecer no próprio site, exija também candidate.origin === location.origin. Para um endpoint externo, compare origem e caminho com o contrato da integração. Não aceite uma URL só porque contém um domínio esperado como substring.

Evite montar HTML de link com interpolação. Crie o elemento, atribua uma URL validada e use textContent para o rótulo.

Se HTML for requisito, trate-o como uma fronteira excepcional

Alguns projetos realmente recebem conteúdo rico. Nesse caso, escapar tudo muda o produto, mas enviar a string diretamente para innerHTML também não é aceitável.

Antes de permitir HTML:

  1. confirme por que texto ou componentes conhecidos não resolvem;
  2. defina quais elementos e atributos são necessários;
  3. use um sanitizador de HTML mantido e configurado por allowlist;
  4. centralize a política em um único módulo revisável;
  5. teste payloads de atributos, URLs e estruturas aninhadas;
  6. preserve a sanitização na última fronteira antes do sink;
  7. registre a dependência e seu processo de atualização.

A documentação de innerHTML da MDN classifica a propriedade como injection sink e recomenda TrustedHTML com enforcement de Trusted Types para reduzir o risco. Trusted Types não sanitiza por conta própria: a política ainda precisa transformar ou rejeitar a entrada corretamente.

Não crie uma política chamada default que simplesmente devolve a string. Isso silencia o mecanismo sem tornar o conteúdo seguro.

Use Trusted Types como defesa adicional, quando o ambiente permitir

Trusted Types reduz a área de revisão ao exigir valores tipados em sinks cobertos. O enforcement depende da diretiva CSP require-trusted-types-for 'script' e ainda deve ser verificado nos navegadores suportados.

No contrato atual do Site no Ar, a Version publicada não transforma um arquivo _headers em cabeçalhos HTTP. Portanto, não declare Trusted Types em enforcement só porque a diretiva aparece na fonte. A camada que controla a resposta precisa ser confirmada e o header servido deve ser inspecionado.

O artigo de Content Security Policy explica como começar em report-only, validar jornadas e separar a política proposta da proteção realmente entregue.

Mesmo com enforcement, continue removendo sinks desnecessários. CSP e Trusted Types são defesa em profundidade, não licença para espalhar parsing de HTML.

Revise bibliotecas e código gerado

Frameworks costumam escapar interpolação textual por padrão, mas oferecem escapes explícitos para HTML bruto. Procure APIs equivalentes a “dangerously set HTML”, templates não escapados, renderizadores Markdown, editores ricos e plugins que montam DOM.

Para cada ocorrência:

  • identifique a origem dos dados;
  • confirme a versão da biblioteca;
  • leia o contrato de escaping;
  • verifique se há bypass intencional;
  • teste o HTML final, não apenas o componente-fonte;
  • confira se a build inclui outra cópia ou caminho legado.

Um agente pode gerar um helper seguro e depois contorná-lo em outro arquivo. A revisão precisa cobrir o artefato publicável e os chunks carregados pela página. O guia de dependências JavaScript ajuda a relacionar biblioteca, versão, bundle e atualização.

Teste sem executar payload destrutivo

Use marcadores inofensivos em um ambiente autorizado. O objetivo é provar se a entrada vira nó ou atributo inesperado, não demonstrar impacto em pessoas ou sistemas reais.

Teste pelo menos:

Entrada Evidência esperada
query string caracteres aparecem como texto
fragmento da URL nenhum HTML inesperado é criado
resposta de API esquema validado antes da renderização
armazenamento local valor adulterado falha de forma segura
postMessage origem e formato são verificados
URL de link ou imagem protocolo e destino passam por allowlist
conteúdo rico permitido elementos e atributos fora da política são removidos
navegador sem defesa CSP código continua seguro pela construção do DOM

No DevTools, use breakpoints de modificação do DOM e acompanhe a origem do valor. Revise console, Elements, Network e Sources. Um scanner pode localizar padrões, mas só o rastreamento confirma se existe caminho entre fonte e sink.

Valide a URL que será entregue

O contrato de Publicação envia os arquivos estáticos preparados pelo projeto. Ele não reescreve JavaScript inseguro nem sanitiza respostas que a página buscará depois.

Antes do deploy:

  • gere a build final;
  • busque sinks também nos assets produzidos;
  • confirme que source maps e arquivos de teste não serão publicados;
  • execute os fluxos com entradas sintéticas;
  • verifique formulário, Analytics e integrações;
  • registre qualquer sink inevitável e sua fronteira de proteção.

Depois da publicação, repita os testes na URL padrão e em cada domínio próprio ativo. O HTML pode ser o mesmo, mas origem, integrações, CSP e comportamento de URLs podem variar por hostname.

Se a correção alterar JavaScript, siga o fluxo de republicação controlada e compare a versão anterior com a nova antes de considerar o risco encerrado.

Prompt copiável para o agente

Revise este site para DOM XSS sem corrigir automaticamente.
Aplicação publicável: [caminho]
Comando de build: [comando]
URLs autorizadas para teste: [lista]
- Inventarie fontes como URL, formulário, API, storage e postMessage.
- Busque sinks de HTML, script e navegação no código e na build.
- Mostre cada caminho fonte -> transformação -> sink.
- Prefira textContent e criação explícita de elementos para texto.
- Valide protocolo e origem antes de atribuir URLs.
- Para HTML necessário, identifique sanitizador, allowlist e ponto de uso.
- Não crie política Trusted Types que apenas devolve a entrada.
- Não trate CSP como correção do fluxo vulnerável.
- Use somente marcadores sintéticos e inofensivos nos testes.
- Devolva evidências, menor diff proposto e riscos restantes.

Checklist final

  • fontes controláveis foram inventariadas;
  • sinks foram buscados na fonte e na build;
  • cada ocorrência tem fluxo e contexto documentados;
  • texto não confiável usa API de texto;
  • estruturas são criadas com elementos conhecidos;
  • URLs passam por validação de protocolo e destino;
  • HTML rico existe apenas quando é requisito;
  • sanitização é centralizada, mantida e testada;
  • Trusted Types não usa política permissiva;
  • CSP foi tratada como defesa adicional;
  • testes usaram entradas sintéticas e inofensivas;
  • URL padrão e domínios próprios foram verificados;
  • artefato publicado corresponde à build revisada.

DOM XSS deixa de ser um padrão escondido no JavaScript quando a equipe consegue desenhar cada fluxo entre entrada e interpretação. Quanto menos strings chegam a APIs poderosas, menor é a superfície que precisa de sanitização, política e vigilância depois da publicação.