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.
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.
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.
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.
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.
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 &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.
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.
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.
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.
Confirme que o Docker responde:
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:
docker run -d -p 127.0.0.1:3000:3000 bkimminich/juice-shopO 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:
curl -s http://127.0.0.1:3000/rest/admin/application-versionNa 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.
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:
<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:
http://127.0.0.1:3000/#/search?q=%3Ciframe%20src%3D%22javascript%3Aalert(%60xss%60)%22%3EConsulte o desafio pela API da aplicação para checar o status:
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.
Crie uma pasta e entre nela:
mkdir xsslab && cd xsslabCrie 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:
<!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.
// 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:
python -m http.server 8000 --bind 127.0.0.1Deixe esse terminal aberto enquanto trabalha. Abra http://127.0.0.1:8000/index.html no navegador. Pare o servidor com Ctrl+C quando terminar.
Abra as ferramentas de desenvolvedor do navegador e a aba Console. Carregue a página primeiro com um valor inofensivo:
http://127.0.0.1:8000/index.html#helloDepois rode estas duas linhas no console:
document.getElementById('html-sink').innerHTML
document.getElementById('text-sink').textContentAs duas mostram hello. Agora carregue um fragmento que mistura marcação e um handler de evento:
http://127.0.0.1:8000/index.html#hello%20%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3EEu carreguei exatamente essa URL no Chrome headless 156.0.8078.80 e fiz o dump do DOM resultante. A saída relevante foi:
<div id="html-sink">hello <img src="x" onerror="alert(1)"></div>
<div id="text-sink">hello <img src=x onerror=alert(1)></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.
A página acima já inclui default-src 'self'. A mesma execução headless registrou esta mensagem:
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.
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.
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:
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.
Segurança web e de APIs
O que é o OWASP Top 10, o que cada categoria de 2025 significa, como se defender e como mapear desafios do Juice Shop.
Segurança web e de APIs
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.
Segurança web e de APIs
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.