Blog

XSS: defesa em camadas no frontend moderno.

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.

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.

← Voltar pro blog