XSS em HTML dinâmico: escaping, sanitização e Trusted Types na prática

Três esteiras levam texto, conteúdo formatado e links por módulos de tratamento até uma página de perfil.

Escaping, sanitização e validação de URLs atendem a necessidades diferentes dentro da mesma página. Um nome de perfil precisa aparecer como texto; uma biografia pode preservar formatação; um link precisa apontar para um destino permitido. A escolha da proteção começa pelo que cada campo deve poder fazer.

O problema começa quando tudo vira uma string de HTML, recebe a mesma função de “limpeza” e entra no DOM. Ter vindo do próprio backend, passado por JSON ou sido salvo no banco não muda a natureza do conteúdo. Um dado controlado por usuário continua precisando do tratamento adequado no ponto em que será interpretado.

Comece pelo destino que o campo realmente precisa

XSS acontece quando conteúdo não confiável consegue executar código no contexto da página. A origem do valor é uma parte da análise; a outra é o destino, frequentemente chamado de sink. Uma atribuição a textContent em um elemento comum não tem o mesmo comportamento de uma atribuição a innerHTML.

O contrato de cada campo limita o que será interpretado pelo navegador. Para uma página de perfil, essa divisão reduz tanto o risco quanto a quantidade de HTML que o produto precisa aceitar:

CampoNecessidade do produtoTratamento principal
NomeMostrar caracteres como foram escritosDestino de texto ou escaping no template
BiografiaPreservar uma formatação limitadaSanitização com elementos e atributos permitidos
SiteNavegar para um endereço permitidoAnálise da URL e validação de protocolo e origem, conforme a regra
Evento de cliqueExecutar comportamento da aplicaçãoFunção confiável registrada pelo código

O último item é intencional. O usuário escolhe dados; não precisa escolher o conteúdo de um onclick, o nome de uma função ou um pedaço de script. Transformar essas escolhas em campos separados costuma eliminar a necessidade de um contexto perigoso.

Para texto, evite passar pelo parser de HTML

No navegador, um nome pode ser inserido diretamente em um elemento de texto. Se alguém escrever uma sequência que parece uma tag, ela deve aparecer como caracteres, sem virar um elemento:

const nome = document.querySelector("#profile-name");
nome.textContent = profile.displayName;

textContent cria conteúdo textual; não é uma função que devolve uma string HTML escapada. Por isso, passe o texto original. Fazer escaping antes pode exibir entidades como < ao leitor. Essa escolha vale para um destino comum, como um span ou um h2; preencher um elemento script ou style com texto não torna aquele conteúdo seguro.

Quando o servidor produz o HTML, o caminho muda. O valor precisa ser codificado para o contexto do template. Em PHP, um nome dentro de um parágrafo pode usar htmlspecialchars com charset explícito:

<p>
    <?= htmlspecialchars(
        $displayName,
        ENT_QUOTES | ENT_SUBSTITUTE,
        'UTF-8'
    ) ?>
</p>

Esse exemplo trata texto no corpo do HTML. ENT_QUOTES inclui as aspas na conversão, e ENT_SUBSTITUTE substitui sequências inválidas na codificação. Isso não transforma a função em validador de URL, sanitizador de HTML rico ou proteção universal para JavaScript inline. Templates com escaping automático devem manter essa proteção, usando os mecanismos próprios para cada contexto.

O momento da codificação também importa. Guardar no banco um nome já escapado para HTML mistura o dado com uma apresentação específica. O mesmo nome pode depois aparecer num e-mail de texto, num JSON ou numa nova tela. Preserve o valor apropriado ao domínio e faça a transformação correspondente na saída.

Uma URL bem formada ainda pode ser inadequada

Escapar aspas em href protege a estrutura do atributo, mas não decide para onde o link pode navegar. Uma URL pode usar um protocolo que o produto não aceita. A API URL ajuda a analisar e normalizar o endereço; a aplicação continua responsável pela política.

Para um campo que aceita sites públicos em HTTPS, uma implementação pode rejeitar outros protocolos e credenciais embutidas, atribuindo o endereço a uma propriedade fixa:

function preencherSite(link, valor) {
  const url = new URL(valor);

  if (url.protocol !== "https:" || url.username || url.password) {
    throw new Error("Informe uma URL absoluta em HTTPS, sem credenciais.");
  }

  link.href = url.href;
  link.textContent = url.hostname;
}

O exemplo aceita URLs absolutas, e o chamador deve tratar o erro sem inserir o valor recusado como HTML. Se o produto permitir apenas destinos de parceiros, acrescente uma lista de origins permitidas. Se aceitar caminhos internos, use uma base conhecida e valide o resultado. Uma regra de HTTPS por si só não protege contra phishing nem substitui restrições de negócio.

Criar o link com APIs do DOM evita montar manualmente uma tag, mas não elimina a validação do valor. O mesmo raciocínio vale para atributos que interpretam HTML, URLs ou código: setAttribute não torna qualquer nome de atributo seguro. Mantenha nomes e estrutura sob controle da aplicação.

Para conteúdo rico, defina o HTML que o produto aceita

A biografia é outra história. Se ela precisa preservar parágrafos e negrito, transformar tudo em texto elimina a funcionalidade. Sanitização analisa a marcação e restringe a estrutura permitida. Remover apenas ocorrências de script com regex deixa de fora atributos, URLs e diferenças de interpretação do parser.

Uma política pequena facilita a revisão: aceitar p, br, strong, em e listas; recusar imagens, estilos, embeds e atributos que a biografia não usa. Links podem ser um recurso separado, como no exemplo anterior. A conveniência de aceitar todo o HTML de um editor precisa ser comparada com o que o leitor realmente necessita publicar.

DOMPurify é uma opção mantida para sanitizar esse conteúdo. A configuração do exemplo a seguir conserva somente os elementos previstos e remove atributos. O pacote deve ser importado pelo projeto; o trecho não é um sanitizador próprio nem uma implementação completa da tela.

Trusted Types permite exigir a passagem por uma política

Trusted Types acrescenta um controle sobre determinados destinos de injeção do navegador. Uma política cria valores como TrustedHTML por meio de uma função escolhida pela aplicação. Com a exigência habilitada, destinos cobertos, como innerHTML, recusam strings comuns quando não há uma política default aplicável.

A API não escolhe um sanitizador nem julga sozinha se o resultado é seguro. Uma política que simplesmente devolve a entrada autoriza a mesma marcação problemática com outro tipo. O benefício está em concentrar a transformação e permitir que o navegador cobre sua utilização nos pontos cobertos.

Aplicando essa ideia à biografia, a função de sanitização pode ser compartilhada pelo caminho com Trusted Types e pelo fallback:

import DOMPurify from "dompurify";

function sanitizarBiografia(html) {
  return DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ["p", "br", "strong", "em", "ul", "ol", "li"],
    ALLOWED_ATTR: [],
    ALLOW_DATA_ATTR: false,
    ALLOW_ARIA_ATTR: false,
    RETURN_TRUSTED_TYPE: false,
  });
}

const politica = globalThis.trustedTypes
  ? globalThis.trustedTypes.createPolicy("profile-html", {
      createHTML: sanitizarBiografia,
    })
  : null;

export function renderizarBiografia(elemento, html) {
  elemento.innerHTML = politica
    ? politica.createHTML(html)
    : sanitizarBiografia(html);
}

A política deve ser criada uma vez no módulo de entrada apropriado, não a cada renderização. Dentro de createHTML, DOMPurify devolve uma string porque a própria política produz o TrustedHTML. O caminho sem a API continua sanitizando. Ele não oferece, porém, a mesma fiscalização sobre outras atribuições espalhadas pela página: um novo innerHTML fora dessa função ainda precisa ser identificado pela revisão e pelos controles disponíveis no navegador.

A configuração é deliberadamente restrita. Se o produto passar a aceitar links, imagens ou atributos, a política precisa ser revista junto com os requisitos, incluindo protocolos e destinos. Ampliar a lista para fazer um widget funcionar sem entender seu HTML transforma um ponto central de proteção em uma exceção permanente.

A CSP liga a exigência e limita os nomes das políticas

Criar a política em JavaScript não ativa a exigência global. A diretiva require-trusted-types-for 'script' faz essa cobrança nos destinos cobertos. trusted-types restringe os nomes de políticas que podem ser criadas; não atribui permissão a um módulo específico. Para o exemplo com DOMPurify, um fragmento de configuração seria:

Content-Security-Policy:
  require-trusted-types-for 'script';
  trusted-types profile-html dompurify;

O nome dompurify contempla a política interna que a biblioteca pode criar, conforme sua documentação. Confira esse comportamento na versão adotada. São diretivas para integrar à CSP existente, não uma política completa de carregamento de scripts. O guia de CSP estrita desenvolve nonces, hashes e a adoção gradual dessa outra camada.

Comece observando violações em Content-Security-Policy-Report-Only, com coleta de relatórios configurada, e revise os caminhos que ainda entregam strings aos destinos protegidos. Depois, ative o bloqueio nos ambientes e fluxos validados. Não crie uma default policy que simplesmente aceite qualquer string para silenciar erros: ela pode esconder exatamente os pontos que a migração deveria revelar.

Em 7 de outubro de 2026, o W3C publicou uma revisão do Working Draft de Trusted Types. O estágio da especificação e a disponibilidade de cada recurso de navegador são informações diferentes. Verifique a compatibilidade da API e das diretivas para o público atendido, incluindo versões antigas. Detectar a API ajuda a escolher um caminho de código; não comprova que a política HTTP está sendo aplicada.

O caminho depois da sanitização também faz parte da proteção

Acrescentar um selo vindo de uma integração à biografia já sanitizada, por concatenação de strings, pode reabrir o risco. Se o selo não passou pelo tratamento necessário, a segurança da primeira etapa não cobre a string final. A política precisa abranger todo o conteúdo que chega ao destino, e componentes posteriores não podem reinterpretar trechos sem revisão.

Algo parecido acontece quando um HTML considerado seguro é entregue a uma biblioteca que o modifica, converte em template ou acrescenta atributos. Sanitizar num ponto e confiar em todas as transformações seguintes deixa a fronteira incompleta. Prefira compor a estrutura confiável com DOM e inserir cada campo pelo caminho previsto.

No servidor, uma biografia também pode aparecer em prévias, páginas públicas e e-mails HTML. Sanitizar apenas o editor do navegador não protege esses consumidores, e um atacante pode chamar a API sem usar o editor. Cada saída precisa aplicar uma política adequada. Se houver uma representação sanitizada em cache, registre a versão da política e planeje reprocessá-la quando as regras mudarem.

E a Sanitizer API nativa?

A plataforma oferece métodos seguros, como Element.setHTML(), que inserem HTML com sanitização. Os métodos com Unsafe no nome têm outro contrato e não devem ser tratados como equivalentes. A MDN ainda classifica a Sanitizer API com disponibilidade limitada na consulta de 9 de outubro de 2026.

Ela pode ser uma escolha quando o suporte exigido pelo produto permitir, com uma configuração que também corresponda ao HTML aceito. Para uma base de navegadores mais ampla, mantenha um caminho de sanitização compatível. Evite um fallback que use innerHTML diretamente com a entrada original, porque ele concentra a vulnerabilidade nos usuários sem o recurso novo.

A revisão fica mais útil quando segue um campo até a tela

Escolha um valor controlado pelo usuário e acompanhe seu percurso: entrada, armazenamento, resposta da API, transformação e inserção. Confira se o nome continua texto, se a biografia só conserva a formatação prevista e se o site respeita a política de URLs. Inclua também mensagens de erro, prévias e caminhos alternativos, que costumam escapar da revisão da tela principal.

Nos testes do produto, cubra a passagem pelos destinos e a política, não apenas uma lista curta de strings maliciosas. Bibliotecas de sanitização precisam continuar atualizadas, e upgrades merecem verificar a configuração usada e os fluxos que dependem dela. O checklist de atualização de dependências ajuda a organizar essa manutenção.

A decisão que simplifica o perfil é pequena: cada campo recebe somente a capacidade de que precisa. O nome não interpreta HTML; a biografia tem um vocabulário limitado; o endereço segue uma regra explícita. Trusted Types pode cobrar a passagem pela política, enquanto a CSP restringe execução. A proteção fica mais compreensível porque acompanha as escolhas do produto.

Fontes