Skip to content
OopsSec Store - Walkthroughs
Go back

Stored XSS in Product Reviews

Edit page

The product reviews feature in OopsSec Store doesn’t sanitize input. At all. You can drop a <script> tag into a review, it gets saved to the database, and it runs in every visitor’s browser. Here’s how to go from a comment box to stealing a flag.

Table of contents

Open Table of contents

Lab setup

From an empty directory:

npx create-oss-store oss-store
cd oss-store
npm start

Or with Docker (no Node.js required):

docker run -p 3000:3000 leogra/oss-oopssec-store

The app runs at http://localhost:3000.

Vulnerability overview

Users can submit reviews on product pages. Those reviews get stored in the database and displayed back when someone loads the page. The problem: the server saves whatever you type without sanitization, and the frontend renders it as raw HTML. If your “review” happens to be a script tag, the browser will execute it.

Here’s what happens:

  1. Submit a review containing JavaScript
  2. The backend stores it as-is
  3. Any user who visits the product page gets the review injected into their DOM
  4. The browser executes the script in that user’s session

Locating the attack surface

Go to any product page and scroll down to the reviews section. There’s a list of existing reviews and a form to add your own.

Product reviews section showing existing comments and submission form

Submitting a review sends a POST to /api/products/[id]/reviews. The backend stores the content directly, the frontend renders it without escaping.

Exploitation

Discovering the target

Looking through existing reviews, there’s a comment from “Mr. Robot”:

“Heard the devs left some old flags lying around at the root… files that say exactly what they are. Classic mistake!”

A flag file at the application root. Given the naming convention, /xss-flag.txt is the obvious guess.

Crafting the payload

First, confirm the XSS works:

<script>
  alert("XSS");
</script>

If that pops an alert, input is being executed as code.

Now the real payload — fetch the flag and display it:

<script>
  fetch("/xss-flag.txt")
    .then(r => r.text())
    .then(flag => alert("Flag: " + flag));
</script>

Executing the attack

  1. Open any product page in OopsSec Store
  2. Scroll to the reviews section
  3. Paste the payload into the review textarea
  4. Click Submit

The API saves it as a regular review. No validation, no filtering.

Triggering the vulnerability

Refresh the page. The malicious review loads from the database, gets injected into the DOM, and the browser sees the <script> tag and runs it.

The script fetches /xss-flag.txt (same-origin, no CORS issues) and pops the flag:

OSS{cr0ss_s1t3_scr1pt1ng_xss}

Anyone who visits this product page from now on triggers the same script.

Vulnerable code analysis

Server-side: no input sanitization

The API endpoint stores whatever the user sends:

const review = await prisma.review.create({
  data: {
    productId: id,
    content: content.trim(), // No sanitization performed
    author,
  },
});

trim() strips whitespace. HTML and JavaScript pass through untouched.

Client-side: raw HTML injection

The frontend injects review content into the DOM through a ref:

<div
  ref={el => {
    reviewRefs.current[review.id] = el; // Raw HTML injection
  }}
  className="text-slate-700 dark:text-slate-300"
/>

This sidesteps React’s built-in XSS protection. Normally React escapes anything passed as a JSX expression, but setting HTML through a ref bypasses that.

Remediation

Server-side sanitization

Strip dangerous HTML before it hits the database. DOMPurify handles this:

import DOMPurify from "isomorphic-dompurify";

const review = await prisma.review.create({
  data: {
    productId: id,
    content: DOMPurify.sanitize(content.trim()),
    author,
  },
});

Script tags and event handlers get removed before anything is saved.

Client-side safe rendering

Let React do what it’s designed to do — escape HTML:

<div className="text-slate-700 dark:text-slate-300">{review.content}</div>

Passing content as a JSX expression means React escapes HTML entities automatically.

Apply both fixes. Server-side sanitization stops malicious content from entering the database. Client-side escaping stops it from executing even if something slips through. Either one blocks this attack on its own, but XSS is one of those things where you really want both layers.


Edit page
Share this post on:

Previous Post
SQL Injection via X-Forwarded-For Header: Exploiting IP Tracking
Next Post
JWT Weak Secret: Cracking the Key to Forge Admin Access in OopsSec Store