Encontre e Corrija uma Vulnerabilidade XSS
Rode o OWASP Juice Shop localmente no Docker, encontre e dispare uma vulnerabilidade real de cross-site scripting refletido e armazenado, e depois corrija o código de renderização equivalente com output encoding correto e um header Content-Security-Policy.
Problema
Cross-site scripting (XSS) acontece quando dado controlado pelo usuário é escrito numa página HTML sem ser codificado para aquele contexto, então o navegador o interpreta como markup ou script em vez de texto inerte. O resultado é que a entrada de um atacante roda como se fosse o próprio JavaScript da aplicação — dentro da sessão autenticada da vítima, com acesso aos cookies dela, ao DOM e a tudo que o JavaScript da página pode fazer. Neste lab você vai rodar o **OWASP Juice Shop** — uma aplicação de e-commerce intencionalmente vulnerável construída para treinamento de segurança — num container Docker local. Você vai encontrar e disparar um **XSS refletido** (o payload vive na URL/requisição, executa imediatamente) e um **XSS armazenado** (o payload é salvo do lado do servidor e executa para todo visitante futuro), ver exatamente o que cada um permite a um atacante fazer, e depois voltar ao nível de código para corrigir a lógica de renderização equivalente com **output encoding** e um header **Content-Security-Policy (CSP)** como uma camada extra de defesa em profundidade. > Só rode estes payloads contra o container Juice Shop local que você mesmo > sobe neste lab (`localhost`). Nunca injete scripts em qualquer site, > formulário ou app que você não seja dono ou não tenha permissão explícita > por escrito para testar.
Objetivos
Ao final deste lab você será capaz de:
- Subir o OWASP Juice Shop localmente no Docker para prática controlada e
offline. - Disparar um XSS refletido via uma URL/entrada de busca manipulada e
explicar o que um atacante ganha com isso. - Disparar um XSS armazenado via um campo persistido (ex.: uma avaliação ou
campo de perfil) e explicar por que é mais perigoso que o XSS refletido. - Distinguir encoding de contexto HTML de encoding de contexto de atributo e
de contexto JS, e explicar por que "uma função de escape genérica" não é
suficiente. - Corrigir o código de renderização equivalente usando output encoding
correto para o contexto certo. - Adicionar um header Content-Security-Policy como uma segunda camada de
defesa e explicar o que ele protege e o que não protege.
Pré-requisitos
Para completar este lab você vai precisar de:
- Docker instalado e capaz de rodar
docker run -p 3000:3000 bkimminich/juice-shop. - Um navegador web com dev tools (para inspecionar requisições/DOM e ler a
saída do console). - Familiaridade básica com HTML e JavaScript.
- Um projeto frontend ou backend (qualquer motor de templates/framework) ou
um arquivo de rascunho, para escrever o exemplo "antes/depois" de
renderização no Passo 4.
O modelo mental: encoding consciente de contexto
HTML, atributos HTML e JavaScript são sintaxes diferentes com caracteres
especiais diferentes. Um valor seguro para colocar dentro do conteúdo de texto
de uma tag <p> não é automaticamente seguro dentro de um atributo
href="..." ou dentro de um bloco <script>. XSS acontece sempre que a
entrada do usuário cruza para dentro de markup/script sem ser codificada
para o contexto específico onde ela cai:
Contexto: conteúdo de texto HTML
<p>{{ comentario }}</p>
Entrada perigosa: <script>alert(document.cookie)</script>
→ o navegador interpreta como uma tag <script> real e a executa
Contexto: atributo HTML
<img src="{{ avatar_url }}">
Entrada perigosa: x" onerror="alert(1)
→ o navegador interpreta onerror como um novo atributo e o executa como JS
Contexto: dentro de um bloco <script>
<script>var name = "{{ username }}";</script>
Entrada perigosa: "; alert(1); //
→ escapa do literal de string e roda JS arbitrário
A correção em todo caso é a mesma ideia, aplicada com o encoder certo:
escapar os caracteres que são especiais naquela sintaxe específica
(<, >, &, ", ' para HTML; regras diferentes para literais de string
JS; diferentes de novo para URLs) para que o navegador renderize como texto
inerte em vez de interpretar como markup ou código. A maioria dos motores de
template modernos (React JSX, Rails ERB com <%=, Django templates, Handlebars
{{ }}) escapa automaticamente o contexto HTML por padrão — as vulnerabilidades
normalmente aparecem onde um desenvolvedor opta explicitamente por sair disso
(dangerouslySetInnerHTML, |safe, html_safe, {{{ }}}) ou constrói uma
string de atributo/URL/script à mão.
Refletido vs. armazenado
-
XSS refletido: o payload faz parte da requisição (query string da URL,
caixa de busca) e é ecoado de volta na resposta imediatamente. Só afeta a
vítima que é enganada a clicar num link manipulado — o ataque é entregue
a alguém. -
XSS armazenado: o payload é salvo do lado do servidor (um comentário,
uma avaliação, um campo de perfil) e é renderizado para todo usuário que
visitar depois aquela página — sem precisar de link manipulado, sem
engenharia social por vítima. É por isso que o XSS armazenado é geralmente
tratado como mais severo.
Por que o Juice Shop
O Juice Shop é uma aplicação de e-commerce deliberadamente vulnerável que vem
com um placar embutido rastreando quais vulnerabilidades ("challenges") você
disparou, o que te dá uma forma objetiva de confirmar que você achou o bug
pretendido e não um falso positivo.
Como trabalhar neste lab
Os Passos 1–3 encontram e disparam XSS refletido e armazenado. O Passo 4 é a
correção — não pule ele; este lab não está completo até você mostrar o código
de renderização corrigido e o header CSP.
Passos
-
Suba o Juice Shop localmente
Rode a imagem oficial do Juice Shop. Ela escuta na porta 3000 e não
precisa de nenhum passo de configuração — já vem pré-populada com
produtos, usuários e challenges.docker run --rm -d --name juice-shop -p 3000:3000 bkimminich/juice-shopAbra
http://localhost:3000no seu navegador. Dispense o banner de
boas-vindas e o aviso de cookies. Opcionalmente crie uma conta (Account →
Login → "Not yet a customer?") para poder testar o XSS armazenado no
Passo 3 contra seus próprios dados de perfil/avaliação.Abra o Score Board (geralmente acessível pelo menu hambúrguer, ou
diretamente emhttp://localhost:3000/#/score-board) — ele lista todo
challenge embutido, incluindo vários de XSS, e marca como resolvido quando
você o dispara. Use-o para confirmar seu trabalho nos próximos passos, não
como um passo a passo — descubra os payloads você mesmo primeiro.Checkpoint
Confirme que o app carrega, você consegue navegar pelos produtos, e a
página Score Board lista challenges não resolvidos incluindo alguns com
"XSS" no nome.Entregável deste passo: Juice Shop rodando em
http://localhost:3000com o Score Board visível e mostrando challenges
de XSS não resolvidos. -
Dispare um XSS refletido
A busca de produtos do Juice Shop reflete seu termo de busca de volta na
página. Tente primeiro o caso normal:Caixa de busca: "apple" → a página mostra uma área tipo "search results for apple", produtos filtradosAgora tente um payload que escaparia de onde quer que esse termo seja
renderizado:Caixa de busca: <iframe src="javascript:alert(`xss`)">Envie (Enter, ou pelo ícone de busca). Se o app for vulnerável nessa
entrada, um diálogoalertdo JavaScript dispara — o navegador executou
markup que veio direto da URL/query, não do próprio código da aplicação.Inspecione o que realmente aconteceu
Abra a aba Elements/Inspector das dev tools do navegador e olhe o DOM
perto dos resultados da busca. Você deve encontrar seu markup literal
<iframe...>sentado na página, sem escape — onde uma implementação
segura mostraria o texto literal<iframe src="javascript:alert(xss)">
como uma string inofensiva (ex.: renderizado como<iframe ...>).Confira também a barra de URL: o termo de busca geralmente também é
refletido na query string da URL. Isso significa que esse ataque exato
poderia ser entregue como um único link manipulado — um atacante envia
http://site-vitima/#/search?q=<payload>e quem clicar roda o script na
própria sessão logada.Checkpoint
Confirme que o
alertdisparou, anote qual parâmetro de requisição
carregou o payload, e confirme que o Score Board agora mostra um
challenge de XSS resolvido relacionado à busca (ex.: "DOM XSS").Entregável deste passo: um payload de XSS refletido funcionando, a
requisição exata que o dispara, e uma nota sobre como poderia ser
entregue via um único link. -
Dispare um XSS armazenado
Agora encontre um campo cujo valor seja persistido e renderizado
depois para outros visitantes — um ataque mais forte porque não exige um
link manipulado por vítima. A funcionalidade de avaliação de produtos do
Juice Shop é uma boa candidata.-
Abra a visualização de detalhe de qualquer produto e encontre sua caixa
de avaliação/comentário. -
Envie um payload como o texto da avaliação:
Avaliação: <img src=x onerror=this.src='http://localhost:3000/#/search?q=stored-xss-poc'>Um payload mais simples, puramente observacional, também funciona para
provar o ponto sem navegar para lugar nenhum:Avaliação: <script>document.title = 'XSS-' + document.cookie</script> -
Recarregue a página do produto (ou abra em uma janela nova
anônima/privada, simulando um visitante diferente sem nenhuma ação
especial).
Se vulnerável, o payload executa ao carregar a página, para qualquer um
que veja aquele produto — sem clique, sem link manipulado, só visitando
uma página normal. Essa é a característica que define o XSS armazenado: a
superfície de ataque é todo visitante futuro, não só alguém enganado a
clicar num link.Checkpoint
Confirme que o payload dispara a partir de um carregamento de página
simples numa sessão de navegador nova (não só na aba onde você enviou), e
confira o Score Board por um challenge resolvido relacionado a entrada
persistida/armazenada.Entregável deste passo: um payload de XSS armazenado funcionando,
confirmação de que executa para uma simulação de "visitante novo", e uma
frase explicando por que o XSS armazenado é mais severo que o refletido. -
-
Corrija: output encoding e CSP (submissão)
O próprio Juice Shop está fora de escopo para corrigir — o ponto deste
passo é mostrar que você entende a correção correta reescrevendo a
lógica de renderização equivalente na sua própria stack, mais adicionar
CSP como uma camada extra.O padrão vulnerável (pseudocódigo, espelha o reflexo da busca)
# Servidor ou template renderiza o termo de busca cru direto no HTML def renderizar_resultados_busca(query, resultados): html = "<div class='search-info'>Resultados para: " + query + "</div>" html += renderizar_produtos(resultados) return html<!-- Template ingênuo, sem escaping --> <div class="search-info">Resultados para: {{{ query }}}</div> <!-- chaves triplas = cru/inseguro em muitos motores -->A correção: codifique para o contexto onde você está renderizando
def renderizar_resultados_busca(query, resultados): query_segura = html_escape(query) # &, <, >, ", ' todos codificados html = "<div class='search-info'>Resultados para: " + query_segura + "</div>" html += renderizar_produtos(resultados) return htmlNa prática, prefira o auto-escaping padrão do seu framework em vez de
implementar na mão, e nunca opte por sair dele para dado controlado pelo
usuário:// React: JSX escapa automaticamente conteúdo de texto por padrão. Isto já é seguro: <div className="search-info">Resultados para: {query}</div> // Nunca faça isto com entrada do usuário: // <div dangerouslySetInnerHTML={{ __html: query }} /><%# Rails ERB: <%= %> escapa automaticamente por padrão. Isto já é seguro: %> <div class="search-info">Resultados para: <%= query %></div> <%# Nunca faça isto com entrada do usuário: <%= raw(query) %> ou <%= query.html_safe %> %><!-- Django templates: {{ }} escapa automaticamente por padrão. Isto já é seguro: --> <div class="search-info">Resultados para: {{ query }}</div> <!-- Nunca faça isto com entrada do usuário: {{ query|safe }} -->Se você precisar construir um atributo HTML ou um valor de
<script>a partir de entrada do usuário, lembre da lição do Passo-0:
escapar HTML sozinho não basta ali — use um encoder seguro para atributo
ou seguro para string JS (a maioria das APIs de template/DOM te dá um;
ex.: usarelement.textContentem vez deinnerHTML, ou um helper de
bibliotecaescapeHtmlAttribute/escapeJs).Adicione Content-Security-Policy como camada extra
Output encoding é a correção de verdade; CSP é uma segunda camada,
independente, que limita o dano mesmo que um bug de encoding passe
despercebido. Defina uma política estrita como header de resposta:Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'-
script-src 'self'diz ao navegador para recusar executar qualquer
<script>inline ou URLjavascript:injetada que não seja carregada
da sua própria origem — isso sozinho já teria bloqueado os payloads
<script>...</script>e<iframe src="javascript:...">dos Passos 2–3,
mesmo que a correção de encoding tivesse alguma brecha. - Evite
'unsafe-inline'e'unsafe-eval'emscript-src— eles anulam
o propósito da política. - CSP é defesa em profundidade, não um substituto para o encoding: ela
não para toda variante de XSS (ex.: não impede um atacante de desfigurar
conteúdo de texto), e CSP mal configurada é fácil de contornar. Corrija o
encoding primeiro; adicione CSP depois.
Prove a correção
Escreva um teste (ou uma checagem manual contra seu renderizador
reescrito) que roda os payloads exatos dos Passos 2–3 pela sua função de
renderização corrigida e afirma que a saída contém o texto codificado,
inerte — não markup vivo:test "query de busca é HTML-escaped, não executada": payload = "<iframe src=\"javascript:alert(`xss`)\">" saida = renderizar_resultados_busca(payload, []) espera(saida).contem("<iframe") espera(saida).nao_contem("<iframe src=\"javascript:alert")
Critério de submissão
Submeta quando tudo o que segue for verdade:
- Evidência documentada do XSS refletido (Passo 2) e do XSS armazenado
(Passo 3) disparando contra o Juice Shop, com os payloads exatos
usados. - Uma função (ou componente/template) equivalente a
renderizar_resultados_buscareescrita usando output encoding
consciente de contexto na linguagem de sua escolha. - Uma definição de header
Content-Security-Policycom
script-src 'self'(sem'unsafe-inline'), mais uma frase explicando
o que ele protege e o que não protege. - Um teste passando provando que o payload do Passo 2 é renderizado como
texto codificado, inerte, contra a versão corrigida — não executado.
Inclua uma nota de uma linha confirmando que você só testou contra seu
container Juice Shop local, nunca um sistema de terceiros. -