Security · 45 min

Find and Fix an XSS Vulnerability

Run OWASP Juice Shop locally in Docker, find and trigger a real reflected and stored cross-site scripting vulnerability, then fix the equivalent rendering code with proper output encoding and a Content-Security-Policy header.

Problem

Cross-site scripting (XSS) happens when user-controlled data is written into an HTML page without being encoded for that context, so the browser interprets it as markup or script instead of as inert text. The result is that an attacker's input runs as if it were the application's own JavaScript — inside the victim's authenticated session, with access to their cookies, DOM, and anything the page's JavaScript can do. In this lab you will run **OWASP Juice Shop** — an intentionally vulnerable e-commerce app built for security training — in a local Docker container. You will find and trigger a **reflected XSS** (payload lives in the URL/request, executes immediately) and a **stored XSS** (payload is saved server-side and executes for every later visitor), see exactly what each lets an attacker do, and then step back to the code level to fix the equivalent rendering logic with **output encoding** and a **Content-Security-Policy (CSP)** header as a defense-in-depth backstop. > Only ever run these payloads against the local Juice Shop container you > start yourself in this lab (`localhost`). Never inject scripts into any > site, form, or app you do not own or have explicit written permission to > test.

Objectives

By the end of this lab you will be able to:

Prerequisites

To complete this lab you'll need:

The mental model: context-aware encoding

HTML, HTML attributes, and JavaScript are different syntaxes with different
special characters. A value that is safe to place inside a <p> tag's text
content is not automatically safe inside an href="..." attribute or inside a
<script> block. XSS happens whenever user input crosses into markup/script
without being encoded for the specific context it lands in:

Context: HTML text content
  <p>{{ comment }}</p>
  Dangerous input: <script>alert(document.cookie)</script>
  → browser parses it as a real <script> tag and runs it

Context: HTML attribute
  <img src="{{ avatar_url }}">
  Dangerous input: x" onerror="alert(1)
  → browser parses onerror as a new attribute and runs it as JS

Context: inside a <script> block
  <script>var name = "{{ username }}";</script>
  Dangerous input: "; alert(1); //
  → breaks out of the string literal and runs arbitrary JS

The fix in every case is the same idea, applied with the right encoder:
escape the characters that are special in that specific syntax
(<, >, &, ", ' for HTML; different rules for JS string literals;
different again for URLs) so the browser renders them as inert text instead of
interpreting them as markup or code. Most modern templating engines
(React JSX, Rails ERB with <%=, Django templates, Handlebars {{ }})
auto-escape HTML context by default — the vulnerabilities usually appear where
a developer explicitly opts out (dangerouslySetInnerHTML, |safe, html_safe,
{{{ }}}) or builds an attribute/URL/script string by hand.

Reflected vs. stored

Why Juice Shop

Juice Shop is a deliberately vulnerable e-commerce app that ships with a
built-in scoreboard tracking which vulnerabilities ("challenges") you've
triggered, which gives you an objective way to confirm you found the intended
bug rather than a false positive.

How to work through this lab

Steps 1–3 find and trigger reflected and stored XSS. Step 4 is the fix — do
not skip it; this lab is not complete until you show the corrected rendering
code and the CSP header.

Steps

  1. Start Juice Shop locally

    Run the official Juice Shop image. It listens on port 3000 and needs no
    setup step — it comes pre-seeded with products, users, and challenges.

    docker run --rm -d --name juice-shop -p 3000:3000 bkimminich/juice-shop
    

    Open http://localhost:3000 in your browser. Dismiss the welcome banner
    and cookie notice. Optionally create an account (Account → Login →
    "Not yet a customer?") so you can test the stored XSS in Step 3 against
    your own profile/review data.

    Open the Score Board (usually reachable via the hamburger menu, or
    directly at http://localhost:3000/#/score-board) — it lists every
    built-in challenge, including several XSS ones, and flips to solved when
    you trigger them. Use it to confirm your work in later steps, not as a
    walkthrough — figure out the payloads yourself first.

    Checkpoint

    Confirm the app loads, you can browse products, and the Score Board page
    lists unsolved challenges including ones with "XSS" in the name.

    Deliverable for this step: Juice Shop running at
    http://localhost:3000 with the Score Board visible and showing
    unsolved XSS challenges.

  2. Trigger a reflected XSS

    Juice Shop's product search reflects your search term back into the page.
    Try the normal case first:

    Search box: "apple"
    → page shows a "search results for apple" style area, filtered products
    

    Now try a payload that would break out of wherever that term gets
    rendered:

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

    Submit it (pressing Enter, or via the search icon). If the app is
    vulnerable at this input, a JavaScript alert dialog fires — the browser
    executed markup that came straight from the URL/query, not from the
    application's own code.

    Inspect what actually happened

    Open your browser's dev tools Elements/Inspector tab and look at the
    DOM near the search results. You should find your literal <iframe...>
    markup sitting in the page, unescaped — where a safe implementation would
    show the literal text <iframe src="javascript:alert(xss)"> as a
    harmless string (e.g. rendered as &lt;iframe ...&gt;).

    Also check the URL bar: the search term is usually reflected into the URL
    query string too. That means this exact attack could be delivered as a
    single crafted link — an attacker sends
    http://victim-site/#/search?q=<payload> and anyone who clicks it runs
    the script in their own logged-in session.

    Checkpoint

    Confirm the alert fired, note which request parameter carried the
    payload, and confirm the Score Board now shows a solved XSS challenge
    related to search (e.g. "DOM XSS").

    Deliverable for this step: a working reflected XSS payload, the exact
    request that triggers it, and a note on how it could be delivered via a
    single link.

  3. Trigger a stored XSS

    Now find a field whose value is persisted and rendered later for
    other visitors — a stronger attack because it requires no crafted link
    per victim. Juice Shop's product review feature is a good candidate.

    1. Open any product's detail view and find its review/comment box.

    2. Submit a payload as the review text:

      Review: <img src=x onerror=this.src='http://localhost:3000/#/search?q=stored-xss-poc'>
      

      A simpler, purely observational payload also works to prove the point
      without navigating anywhere:

      Review: <script>document.title = 'XSS-' + document.cookie</script>
      
    3. Reload the product page (or open it in a fresh private/incognito
      window, simulating a different visitor with no special action taken).

    If vulnerable, the payload executes on page load, for anyone who views
    that product
    — no click, no crafted link, just visiting a normal page.
    That is the defining trait of stored XSS: the attack surface is every
    future viewer, not just someone tricked into a link.

    Checkpoint

    Confirm the payload fires from a plain page load in a fresh browser
    session (not just the tab where you submitted it), and check the Score
    Board for a solved challenge related to persisted/stored input.

    Deliverable for this step: a working stored XSS payload, confirmation
    it executes for a "fresh visitor" simulation, and a one-sentence
    explanation of why stored XSS is more severe than reflected XSS.

  4. Fix it: output encoding and CSP (submission)

    Juice Shop itself is out of scope to patch — the point of this step is to
    show you understand the correct fix by rewriting the equivalent
    rendering logic in your own stack, plus adding CSP as a backstop.

    The vulnerable pattern (pseudocode, mirrors the search reflection)

    # Server or template renders the raw search term straight into HTML
    def render_search_results(query, results):
        html = "<div class='search-info'>Results for: " + query + "</div>"
        html += render_products(results)
        return html
    
    <!-- Naive template, no escaping -->
    <div class="search-info">Results for: {{{ query }}}</div>   <!-- triple braces = raw/unsafe in many engines -->
    

    The fix: encode for the context you're rendering into

    def render_search_results(query, results):
        safe_query = html_escape(query)   # &, <, >, ", ' all encoded
        html = "<div class='search-info'>Results for: " + safe_query + "</div>"
        html += render_products(results)
        return html
    

    In practice, prefer your framework's default auto-escaping instead of
    hand-rolling it, and never opt out for user-controlled data:

    // React: JSX auto-escapes text content by default. This is already safe:
    <div className="search-info">Results for: {query}</div>
    // Never do this with user input:
    // <div dangerouslySetInnerHTML={{ __html: query }} />
    
    <%# Rails ERB: <%= %> auto-escapes by default. This is already safe: %>
    <div class="search-info">Results for: <%= query %></div>
    <%# Never do this with user input: <%= raw(query) %> or <%= query.html_safe %> %>
    
    <!-- Django templates: {{ }} auto-escapes by default. This is already safe: -->
    <div class="search-info">Results for: {{ query }}</div>
    <!-- Never do this with user input: {{ query|safe }} -->
    

    If you must build an HTML attribute or a <script> value from user
    input, remember Step-0's lesson: HTML-escaping alone is not enough there —
    use an attribute-safe or JS-string-safe encoder (most templating/DOM APIs
    give you one; e.g. setting element.textContent instead of innerHTML,
    or a library's escapeHtmlAttribute/escapeJs helper).

    Add Content-Security-Policy as a backstop

    Output encoding is the real fix; CSP is a second, independent layer that
    limits the damage even if an encoding bug slips through. Set a strict
    policy as a response header:

    Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'
    
    • script-src 'self' tells the browser to refuse to execute any inline
      <script> or injected javascript: URL that isn't loaded from your own
      origin — this alone would have blocked the <script>...</script> and
      <iframe src="javascript:..."> payloads from Steps 2–3, even if the
      encoding fix somehow had a gap.
    • Avoid 'unsafe-inline' and 'unsafe-eval' in script-src — they defeat
      the point of the policy.
    • CSP is defense in depth, not a substitute for encoding: it doesn't
      stop every XSS variant (e.g. it won't stop an attacker from defacing
      text content), and misconfigured CSP is easy to bypass. Fix the encoding
      first; add CSP second.

    Prove the fix

    Write a test (or a manual check against your rewritten renderer) that runs
    the exact payloads from Steps 2–3 through your fixed rendering function
    and asserts the output contains the encoded, inert text — not live
    markup:

    test "search query is HTML-escaped, not executed":
        payload = "<iframe src=\"javascript:alert(`xss`)\">"
        output = render_search_results(payload, [])
        expect(output).to_contain("&lt;iframe")
        expect(output).not_to_contain("<iframe src=\"javascript:alert")
    

    Submission criteria

    Submit when all of the following hold:

    1. Documented evidence of the reflected XSS (Step 2) and stored XSS
      (Step 3) triggering against Juice Shop, with the exact payloads used.
    2. A rewritten render_search_results-equivalent function (or component/
      template) using proper context-aware output encoding in your language
      of choice.
    3. A Content-Security-Policy header definition with script-src 'self'
      (no 'unsafe-inline'), plus a one-sentence explanation of what it does
      and does not protect against.
    4. A passing test proving the Step 2 payload is rendered as encoded,
      inert text against the fixed version — not executed.

    Include a one-line note confirming you only tested against your local
    Juice Shop container, never a third-party system.