Existe uma sensação de que XSS é problema resolvido. Framework moderno escapa conteúdo por padrão, e o <script>alert(1)</script> colado num campo de texto aparece como texto na tela.
O escape automático resolveu 90% dos casos. O que restou são os 10% onde ele não acontece, e esses lugares são poucos e conhecidos.
Os lugares que continuam abertos
HTML injetado direto.
<div dangerouslySetInnerHTML={{ __html: post.body }} />
O nome do atributo é um aviso e ele é usado o tempo todo, porque alguém precisou renderizar conteúdo de um editor de texto rico. É a origem número um de XSS em aplicação React.
URL controlada por usuário.
<a href={user.website}>site</a>
React escapa o conteúdo, não o esquema. javascript:alert(document.cookie) como valor de href executa ao clique. O mesmo vale pra src de iframe e pra data: em alguns contextos.
function safeUrl(raw: string): string | undefined {
try {
const u = new URL(raw, window.location.origin);
return ["http:", "https:", "mailto:"].includes(u.protocol) ? u.href : undefined;
} catch {
return undefined;
}
}
Estado injetado no HTML pelo servidor.
<script>window.__STATE__ = {{ JSON.stringify(state) }}</script>
Se algum valor do estado contiver </script>, o navegador fecha a tag ali e o resto vira HTML. A correção é escapar < e / na serialização, ou entregar o estado em application/json dentro de uma tag que não executa.
Atributo de estilo e nome de arquivo. Nome de arquivo enviado por usuário, renderizado sem escape, é o vetor que ninguém lembra porque não parece entrada de usuário. Escrevi sobre isso em upload de arquivo seguro.
Sanitizar HTML rico, quando não dá pra evitar
Se o produto exige editor de texto rico, você vai renderizar HTML de usuário. Sanitize com biblioteca testada, e sanitize com lista de permissão.
import DOMPurify from "dompurify";
const clean = DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ["p", "br", "strong", "em", "ul", "ol", "li", "a", "h2", "h3", "blockquote"],
ALLOWED_ATTR: ["href", "title"],
ALLOWED_URI_REGEXP: /^(https?|mailto):/i,
});
Duas coisas importam mais que a configuração.
Sanitize na renderização, não só na gravação. Sanitizar no salvamento parece melhor porque acontece uma vez, e deixa de funcionar quando o dado entra por outro caminho: importação, migração, API interna, script.
E não escreva o seu sanitizador. Parece um problema de expressão regular e é um problema de parser de HTML com décadas de casos extremos. Toda regex que remove <script> é contornável.
CSP: a camada que segura o resto
Content Security Policy é a defesa que funciona quando as outras falharam. Ela não impede a injeção: impede que o script injetado execute.
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{RANDOM}';
style-src 'self' 'nonce-{RANDOM}';
img-src 'self' data: https:;
connect-src 'self' https://api.exemplo.com;
frame-ancestors 'none';
base-uri 'none';
object-src 'none'
Os pontos que importam:
Nonce por requisição, não unsafe-inline. Uma CSP com unsafe-inline em script-src não protege contra XSS, que é o motivo principal de existir. É a configuração mais comum e a mais inútil.
base-uri 'none' impede que um <base> injetado redirecione todos os caminhos relativos pro servidor do atacante. É um ataque pouco conhecido e barato de fechar.
frame-ancestors 'none' fecha clickjacking, substituindo o antigo X-Frame-Options.
Comece em modo de relatório, colete por algumas semanas, ajuste, e só então aplique. CSP ligada de uma vez em aplicação existente quebra coisa e vira rollback.
O cookie e a razão de tudo isso
Boa parte do impacto de um XSS é roubo de sessão. Fecha-se isso no cookie:
Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax; Path=/
HttpOnly impede que JavaScript leia o cookie. Com ele, um XSS ainda faz coisa ruim, e não leva a sessão embora.
É por isso que guardar token em localStorage piora o impacto de todo XSS do sistema: localStorage é legível por qualquer script da página, por construção. Escrevi sobre o trade-off em JWT: os erros que aparecem em quase todo projeto.
O que testar
Três testes que cobrem a maior parte:
Um payload de XSS em todo campo de texto que depois aparece em alguma tela. Inclusive nome, que costuma aparecer em dez lugares diferentes.
Uma URL com esquema javascript: em todo campo de link.
Um nome de arquivo com HTML no upload.
E, se você tem editor de texto rico, cole o conteúdo abaixo e veja o que acontece:
<img src=x onerror="alert(document.domain)">
Se aparecer o alerta, você tem trabalho. Se não aparecer nada, confira se o <img> sumiu ou se só o onerror foi removido: a diferença diz se o sanitizador está com lista de permissão ou de bloqueio.
A ordem que eu seguiria
Se o sistema não tem nada disso, o retorno por esforço é: cookie HttpOnly primeiro, depois auditar todo dangerouslySetInnerHTML e todo href dinâmico, depois CSP em modo de relatório.
CSP é a mais trabalhosa e a que dá mais tranquilidade no longo prazo, porque ela protege contra o XSS que vai ser introduzido no ano que vem por alguém que não leu nada disso.