Security · 45 min

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:

Pré-requisitos

Para completar este lab você vai precisar de:

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

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

  1. 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-shop
    

    Abra http://localhost:3000 no 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 em http://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:3000 com o Score Board visível e mostrando challenges
    de XSS não resolvidos.

  2. 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 filtrados
    

    Agora 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álogo alert do 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 &lt;iframe ...&gt;).

    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 alert disparou, 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.

  3. 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.

    1. Abra a visualização de detalhe de qualquer produto e encontre sua caixa
      de avaliação/comentário.

    2. 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>
      
    3. 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.

  4. 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 html
    

    Na 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.: usar element.textContent em vez de innerHTML, ou um helper de
    biblioteca escapeHtmlAttribute/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 URL javascript: 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' em script-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("&lt;iframe")
        espera(saida).nao_contem("<iframe src=\"javascript:alert")
    

    Critério de submissão

    Submeta quando tudo o que segue for verdade:

    1. Evidência documentada do XSS refletido (Passo 2) e do XSS armazenado
      (Passo 3) disparando contra o Juice Shop, com os payloads exatos
      usados.
    2. Uma função (ou componente/template) equivalente a
      renderizar_resultados_busca reescrita usando output encoding
      consciente de contexto na linguagem de sua escolha.
    3. Uma definição de header Content-Security-Policy com
      script-src 'self' (sem 'unsafe-inline'), mais uma frase explicando
      o que ele protege e o que não protege.
    4. 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.