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:
- Stand up OWASP Juice Shop locally in Docker for controlled, offline practice.
- Trigger a reflected XSS via a crafted URL/search input and explain what an
attacker gains from it. - Trigger a stored XSS via a persisted field (e.g. a review or profile field)
and explain why it's more dangerous than reflected XSS. - Distinguish HTML-context encoding from attribute-context and JS-context
encoding, and explain why "one generic escape function" is not enough. - Fix the equivalent rendering code using proper output encoding for the
right context. - Add a Content-Security-Policy header as a second layer of defense and
explain what it does and does not protect against.
Prerequisites
To complete this lab you'll need:
- Docker installed and able to run
docker run -p 3000:3000 bkimminich/juice-shop. - A web browser with dev tools (to inspect requests/DOM and read console
output). - Basic familiarity with HTML and JavaScript.
- A frontend or backend project (any templating engine/framework) or a
scratch file, to write the "before/after" rendering example in Step 4.
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
-
Reflected XSS: the payload is part of the request (URL query string,
search box) and is echoed back in the response immediately. It only affects
the victim who is tricked into clicking a crafted link — the attack is
delivered to someone. -
Stored XSS: the payload is saved server-side (a comment, a review, a
profile field) and gets rendered for every user who later views that
page — no crafted link needed, no per-victim social engineering. This is why
stored XSS is generally treated as more severe.
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
-
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-shopOpen
http://localhost:3000in 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 athttp://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:3000with the Score Board visible and showing
unsolved XSS challenges. -
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 productsNow 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 JavaScriptalertdialog 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<iframe ...>).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
alertfired, 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. -
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.-
Open any product's detail view and find its review/comment box.
-
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> -
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. -
-
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 htmlIn 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. settingelement.textContentinstead ofinnerHTML,
or a library'sescapeHtmlAttribute/escapeJshelper).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 injectedjavascript: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'inscript-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("<iframe") expect(output).not_to_contain("<iframe src=\"javascript:alert")
Submission criteria
Submit when all of the following hold:
- Documented evidence of the reflected XSS (Step 2) and stored XSS
(Step 3) triggering against Juice Shop, with the exact payloads used. - A rewritten
render_search_results-equivalent function (or component/
template) using proper context-aware output encoding in your language
of choice. - A
Content-Security-Policyheader definition withscript-src 'self'
(no'unsafe-inline'), plus a one-sentence explanation of what it does
and does not protect against. - 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. -