Pular para o conteúdo
Novo

DeepSeek V4.1 Flash grátis por 7 dias: 10M tokens por dia após qualquer recarga. Até 30/11.

Como funciona

Cross-Site Scripting (XSS) explicado: tipos e defesas

Entenda XSS refletido, armazenado e baseado em DOM, por que a codificação depende do contexto e como a CSP reduz o risco, em laboratório local.

Intermediário11 min de leituraPor NotHoly

O que é cross-site scripting

Cross-site scripting (XSS) acontece quando uma aplicação web pega um dado que o atacante consegue influenciar e o coloca na página de um jeito que o navegador interpreta como código. O navegador então executa esse código com os privilégios da origem da página. Ele pode ler a página, enviar requisições em nome do usuário e mudar o que o usuário vê.

O problema central não é um script vindo de outro site. O problema é que a aplicação mistura dado e código, e o navegador não consegue saber qual parte veio do desenvolvedor e qual veio de um usuário. A MITRE classifica o problema como CWE-79, "Improper Neutralization of Input During Web Page Generation". Essa redação importa: a falha é não neutralizar a entrada no momento em que a página é montada.

Por que isso importa

XSS é uma das vulnerabilidades web mais comuns, e continua comum porque se esconde em funcionalidades normais: uma busca que repete o termo pesquisado, um campo de comentário, um nome de perfil, um fragmento de URL que uma single-page app lê ao carregar. Um ataque bem-sucedido roda dentro da sessão logada da vítima. Ele pode ler o que a página consegue ler, disparar ações que o usuário pode fazer ou mostrar um formulário de login falso. O navegador limita parte disso pela same-origin policy, mas o XSS funciona justamente porque roda dentro da origem em que o usuário já confia.

Para quem desenvolve, a lição prática é estreita e útil. Você não precisa filtrar toda string ruim. Precisa garantir que texto não confiável nunca seja interpretado pelo navegador como marcação ou código.

Os três tipos

A OWASP descreve três famílias. Elas diferem pelo caminho que o dado não confiável percorre antes de chegar ao navegador.

XSS refletido. O servidor pega um valor da requisição atual, normalmente um parâmetro de query ou um campo de formulário, e devolve esse valor na resposta sem codificação segura. O atacante monta um link que carrega o payload. O ataque exige que a vítima abra esse link. O payload não fica guardado em lugar nenhum do servidor.

XSS armazenado. A aplicação salva a entrada controlada pelo atacante, como um comentário, um nome de usuário ou um chamado de suporte, e depois a exibe para outros usuários. A vítima não precisa de um link especial. Basta abrir a página normal, por isso o XSS armazenado costuma ter o maior impacto.

XSS baseado em DOM. O servidor pode nunca ver o payload. O JavaScript do cliente lê uma fonte que o atacante controla, como location.hash, location.search, document.referrer ou uma mensagem de postMessage, e escreve esse valor em um sink perigoso, como innerHTML, document.write ou eval. O defeito está no código do navegador. Filtros do lado do servidor nunca veem o ataque, e é por isso que esse tipo surpreende equipes que revisam só o backend.

Os três tipos têm a mesma causa raiz. Dado não confiável chega a um contexto que o interpreta como código.

Por que o contexto define a defesa

A codificação de saída é a principal defesa, e ela não é uma operação única. A codificação correta depende do lugar onde o valor vai parar. A OWASP XSS Prevention Cheat Sheet lista as regras de cada contexto. A versão curta:

Contexto onde o valor entra O que fazer Erro comum
Corpo HTML Codificação de entidades HTML de &, <, >, ", ' Codificar duas vezes, o que mostra &amp;lt; na tela
Atributo HTML Codificar e sempre colocar o valor do atributo entre aspas Deixar o valor sem aspas, ou achar que isso protege href ou handlers de evento
String JavaScript Usar um encoder da biblioteca para esse contexto exato Escapar para HTML e depois inserir o valor dentro de um bloco <script>
Parâmetro de URL Codificar o valor em percent-encoding com encodeURIComponent() e aceitar só os esquemas http e https em URLs completas Achar que codificar basta para uma URL inteira vinda do usuário: um esquema javascript: ainda executa quando cai em href
CSS Não colocar dado não confiável no CSS; definir propriedades fixas pela CSSOM Montar uma string de atributo style a partir da entrada do usuário

A tabela mostra por que "sanitize a entrada" é uma regra incompleta. Uma string segura em um parágrafo pode ser perigosa dentro de um atributo href, porque javascript: é um esquema de URL válido ali. A mesma string é inofensiva quando escrita com textContent. A codificação precisa combinar com o sink que o valor alcança no momento em que é escrito.

O cheat sheet da OWASP também avisa que escapes hexadecimais ou Unicode sozinhos não substituem um encoder JavaScript sensível ao contexto, e que eval(), setTimeout() e setInterval() nunca devem receber strings não confiáveis. Codifique na saída e mantenha essas funções longe de caminhos não confiáveis.

Sinks seguros e escape do framework

Você reduz o número de decisões escolhendo sinks que tratam a entrada como texto. Definir element.textContent ou element.value escreve caracteres, não marcação. Definir element.innerHTML roda um parser HTML. O cheat sheet da OWASP aponta innerHTML como um sink a evitar com dado não confiável e recomenda textContent ou value.

Frameworks modernos como React, Angular, Vue e Svelte escapam valores nas ligações de template padrão. Um valor escrito como {nomeUsuario} em JSX, ou como interpolação em um template, vira texto. O risco volta quando o desenvolvedor usa uma saída de emergência: dangerouslySetInnerHTML no React, bypassSecurityTrustHtml no Angular, v-html no Vue ou uma escrita direta no DOM feita por uma biblioteca. Essas saídas são por onde o XSS costuma entrar em uma app com framework. Trate cada uso como item de revisão e mantenha uma lista deles.

Content Security Policy como segunda camada

Uma Content Security Policy (CSP) é um cabeçalho de resposta, ou um elemento <meta http-equiv>, que informa ao navegador de quais fontes a página pode carregar scripts, estilos, imagens e outros recursos. A MDN descreve a função principal dela como controlar quais recursos um documento pode carregar, o que defende contra XSS. A diretiva default-src define o fallback para todo tipo de recurso que não tem diretiva própria.

Com default-src 'self', o navegador recusa scripts inline e handlers de evento inline, como onerror="...", e recusa URLs javascript: nos lugares onde elas executariam código. Uma CSP não corrige um defeito no seu HTML. Ela limita o que um payload injetado consegue fazer. Por isso ela é defesa em profundidade: a codificação impede a injeção, e a CSP faz uma injeção que escapou perder força.

Dois limites importam. Uma política em <meta> não suporta todos os recursos da CSP, e a MDN observa que políticas em modo report-only precisam ser enviadas como cabeçalho HTTP. Confira na página da MDN quais diretivas os navegadores respeitam em <meta> antes de depender de uma. Uma política que permite 'unsafe-inline' ou uma lista ampla de hosts oferece muito menos proteção.

Laboratório prático

Este laboratório tem duas partes. Primeiro, você resolve o desafio de DOM XSS do OWASP Juice Shop, uma aplicação propositalmente vulnerável, rodando na sua própria máquina. Depois, você monta uma página pequena que escreve o mesmo texto não confiável em dois sinks diferentes e observa o que o navegador faz.

Execute cada passo em uma máquina que você controla. Mantenha os serviços presos a 127.0.0.1. Não aponte esses payloads para um site que você não possui ou para o qual você não tem permissão escrita de teste.

Parte 1: subir o Juice Shop localmente

Confirme que o Docker responde:

bash
docker --version
docker info --format '{{.ServerVersion}}'

Na máquina usada para este artigo, o primeiro comando mostrou Docker version 29.8.2, build 7fc2dff. A sua versão pode ser diferente.

Inicie o container com um mapeamento de porta só para o loopback:

bash
docker run -d -p 127.0.0.1:3000:3000 bkimminich/juice-shop

O prefixo 127.0.0.1: publica a porta somente na sua interface de loopback. Se a porta 3000 já estiver em uso, escolha outra porta do host, como 127.0.0.1:3001:3000, e troque as URLs abaixo.

Confirme que a aplicação responde e leia a versão:

bash
curl -s http://127.0.0.1:3000/rest/admin/application-version

Na máquina usada para este artigo, a resposta foi {"version":"20.3.0"}. A primeira inicialização pode demorar um pouco, então repita a requisição se não houver resposta.

Parte 2: resolver o desafio de DOM XSS

O guia de apoio oficial do Juice Shop tem uma seção chamada "Perform a DOM XSS attack". A dica diz para procurar um campo de entrada cujo conteúdo aparece no HTML depois que o formulário é enviado. O guia traz este payload:

html
<iframe src="javascript:alert(`xss`)">

Siga o guia no seu próprio navegador. Abra a barra de busca do Juice Shop, cole o payload e envie. Uma URL de busca com a consulta no fragmento também funciona. Na máquina usada para este artigo, essa URL foi carregada no Chrome headless, e o placar então marcou o desafio como resolvido:

text
http://127.0.0.1:3000/#/search?q=%3Ciframe%20src%3D%22javascript%3Aalert(%60xss%60)%22%3E

Consulte o desafio pela API da aplicação para checar o status:

bash
curl -s "http://127.0.0.1:3000/api/Challenges/?name=DOM%20XSS" | grep -o '"solved":[a-z]*'

Antes de carregar a URL de busca, a saída foi "solved":false. Depois de carregá-la, a saída foi "solved":true. O seu resultado deve bater depois que você disparar o desafio em um navegador.

Repare no que tornou isso um defeito de DOM. O payload fica no fragmento, que o navegador não envia ao servidor, e o código do cliente o escreve na página. Um log do servidor mostraria apenas requisições para o HTML e os scripts da aplicação.

Parte 3: montar o laboratório de sinks

Crie uma pasta e entre nela:

bash
mkdir xsslab && cd xsslab

Crie o index.html. Esse arquivo lê o texto depois do #, escreve uma vez com innerHTML e outra com textContent, e adiciona o elemento meta da CSP do roteiro do laboratório:

html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta http-equiv="Content-Security-Policy" content="default-src 'self'">
  <title>XSS sink lab</title>
</head>
<body>
  <h1>Sink comparison</h1>
  <p>Put text after the # in the URL, for example <code>#hello</code>.</p>
  <h2>innerHTML</h2>
  <div id="html-sink"></div>
  <h2>textContent</h2>
  <div id="text-sink"></div>
  <script src="app.js"></script>
</body>
</html>

Crie o app.js na mesma pasta. O script fica em um arquivo separado de propósito. Um bloco <script> inline também seria bloqueado pela política, e isso esconderia a comparação que você quer fazer.

javascript
// Read the fragment once, then write it to two different sinks.
const raw = decodeURIComponent(location.hash.slice(1));
document.getElementById('html-sink').innerHTML = raw;
document.getElementById('text-sink').textContent = raw;

Sirva a pasta somente em loopback. No Windows, o launcher do Python costuma ser python; no Linux e no macOS, costuma ser python3:

bash
python -m http.server 8000 --bind 127.0.0.1

Deixe esse terminal aberto enquanto trabalha. Abra http://127.0.0.1:8000/index.html no navegador. Pare o servidor com Ctrl+C quando terminar.

Parte 4: comparar os dois sinks

Abra as ferramentas de desenvolvedor do navegador e a aba Console. Carregue a página primeiro com um valor inofensivo:

text
http://127.0.0.1:8000/index.html#hello

Depois rode estas duas linhas no console:

javascript
document.getElementById('html-sink').innerHTML
document.getElementById('text-sink').textContent

As duas mostram hello. Agora carregue um fragmento que mistura marcação e um handler de evento:

text
http://127.0.0.1:8000/index.html#hello%20%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E

Eu carreguei exatamente essa URL no Chrome headless 156.0.8078.80 e fiz o dump do DOM resultante. A saída relevante foi:

html
<div id="html-sink">hello <img src="x" onerror="alert(1)"></div>
<div id="text-sink">hello &lt;img src=x onerror=alert(1)&gt;</div>

O sink innerHTML criou um elemento img. O sink textContent guardou os mesmos caracteres como texto simples. Rode as duas linhas do console de novo nessa URL e confira se a primeira devolve marcação com um elemento e a segunda devolve a string escapada.

Parte 5: adicionar a política e ver o bloqueio do handler

A página acima já inclui default-src 'self'. A mesma execução headless registrou esta mensagem:

text
Executing inline event handler violates the following Content Security Policy directive 'default-src 'self''.

O elemento img foi criado, mas o handler onerror não executou alert(1). A política bloqueou o handler inline. O seu console deve mostrar uma mensagem parecida. O texto exato pode mudar entre versões do navegador.

Para ver a diferença, remova o elemento meta, carregue a mesma URL de novo e observe se o alerta aparece. Depois recoloque o elemento meta. Essa comparação é o único passo do laboratório em que o payload deve, de fato, executar.

Guarde um ponto em mente. A política não tornou o sink innerHTML seguro. Ela bloqueou um tipo de execução. O elemento img continua existindo no DOM, e um atacante que encontre um payload que a política permita ainda poderia agir. A correção certa é escrever texto não confiável com textContent.

Erros comuns

Codificar uma vez, no contexto errado. A codificação HTML protege texto em um parágrafo. Ela não protege uma URL, uma string JavaScript ou um valor CSS. Combine o encoder com o lugar onde o valor entra.

Filtrar por strings ruins conhecidas. Listas de bloqueio que removem <script> deixam passar <img onerror>, <svg onload> e URLs javascript:. Codificação e sinks seguros funcionam porque não dependem de adivinhar a próxima string do atacante.

Confiar cegamente no framework. Os bindings padrão escapam valores, mas dangerouslySetInnerHTML, v-html e bypassSecurityTrustHtml pulam essa proteção. Procure esses usos no seu código.

Ler só o código do servidor. O DOM XSS nunca chega ao servidor. Revise o código do cliente que lê location, document.referrer e dados de postMessage.

Usar a CSP como único controle. Uma política rígida ajuda, mas não substitui a codificação, e uma política fraca com 'unsafe-inline' oferece pouca proteção.

Presumir que a política em <meta> tem todos os recursos. Uma política em <meta> não pode usar o modo report-only nem todas as diretivas. Se você precisa de relatórios, envie a política como cabeçalho de resposta.

Como continuar

Leia o OWASP Cross Site Scripting Prevention Cheat Sheet do início ao fim. Para uma referência de segurança, ele é curto, e cobre todos os contextos da tabela acima. Depois leia o guia de CSP da MDN e teste uma política mais restrita, com nonces ou hashes, na sua página de laboratório.

Resolva os outros desafios do Juice Shop que envolvem XSS e compare como as variantes armazenada e refletida diferem do caso de DOM que você acabou de resolver. Mantenha o laboratório em loopback e pare o container quando terminar:

bash
docker ps --filter ancestor=bkimminich/juice-shop
docker stop <container-id>

O próximo artigo da série trata de cross-site request forgery e de como os cookies SameSite reduzem um dos caminhos dele. XSS e CSRF são muitas vezes confundidos, e vale entender a diferença antes de desenhar um modelo de sessão.

Fontes

Fontes

  1. OWASP Cross Site Scripting Prevention Cheat Sheet
  2. MITRE CWE-79: Improper Neutralization of Input During Web Page Generation
  3. MDN Web Docs: Content Security Policy (CSP)
  4. Pwning OWASP Juice Shop (companion guide)

Mais em Segurança web e de APIs

Segurança web e de APIs

OWASP Top 10 explicado para desenvolvedores

O que é o OWASP Top 10, o que cada categoria de 2025 significa, como se defender e como mapear desafios do Juice Shop.

Iniciante15 min de leitura

Segurança web e de APIs

SQL Injection explicada com o OWASP Juice Shop

Como a SQL Injection muda o sentido de uma consulta, por que consultas parametrizadas resolvem o problema e um laboratório com Juice Shop e sqlite3.

Intermediário10 min de leitura

Segurança web e de APIs

O que é CSRF e como os cookies SameSite ajudam

CSRF usa os cookies do seu próprio navegador contra você. Veja as defesas da mais forte para a mais fraca e teste o SameSite com um laboratório Flask.

Intermediário12 min de leitura