All posts
VibeZero Team8 min read

Stored XSS, explained

A payload saved once runs in every reader's browser. The MySpace worm, a Roundcube CVE exploited in the wild, and the sanitizer settings that actually stop it.

  • security
  • vibe coding
  • engineering

Stored cross-site scripting is what happens when a string a user submitted gets saved, and later rendered into a page as markup rather than as text. The attacker writes the payload once. Everyone who loads the page afterwards runs it, in their session, with their cookies, on your domain.

That last part is the whole reason this class is graded the way it is. Script running on your origin is not “some JavaScript on a page”. It is your application, as far as the browser is concerned, and it can do anything the logged-in user can do, including the things your UI never offers.

The identifier is CWE-79. OWASP folded XSS into A03:2021 Injection, which is the right category: the bug is the same as SQL injection, with HTML as the language and the browser as the interpreter. If you have read SQL injection explained, you already know the shape. Data crossed into a place where it was read as instructions.

The worm that made the argument

On 4 October 2005 a MySpace profile started spreading. Within about a day it had carried itself into over a million profiles and MySpace took the site down to stop it. Samy Kamkar published the technical writeup, and it is still the best twenty minutes you can spend on why input filtering loses.

MySpace was not careless. It filtered. The writeup is a list of eleven obstacles and how each one was walked around, most of them filters:

  • <script> was stripped, so the payload rode inside CSS: <div style="background:url('javascript:alert(1)')">.
  • The word javascript was stripped, so it was broken with a newline: java\nscript, which the browser rejoined and the filter did not.
  • Quotes were escaped, so the payload built its own with String.fromCharCode(34).
  • The profile could not read its own source, so it fetched itself over XMLHttpRequest, which carried the victim’s session cookie for free.

Every individual filter was reasonable. The set of them was a blocklist, and a blocklist has to be right about every browser parsing quirk that exists now or ships later. The payload has to be right once.

It is not a museum piece

Twenty years later, stored XSS is still being exploited against real targets by people with budgets.

CVE-2024-37383 is a stored XSS in Roundcube Webmail before 1.5.7, and in the 1.6 line before 1.6.7, reached through SVG animate attributes. The payload arrives as an email. The victim opens what looks like an empty message, and the markup runs inside the webmail session. Attackers used it to inject a fake login form into the real interface and harvest credentials, and researchers documented the campaign against government targets. CISA added it to the Known Exploited Vulnerabilities catalog on 24 October 2024 with a two-week remediation deadline for federal agencies.

Notice the vector. Not a <script> tag, and not even an HTML attribute anyone thinks about: an SVG animation attribute. Whatever list of dangerous tags you have in your head, SVG is probably not on it. This is the same failure as MySpace, in 2024, in software written by professionals who knew XSS existed.

Where it lives in an AI-built app

XSS is the class generated code handles worst. In the Veracode testing summarized in the common vulnerability classes, the models failed to prevent XSS in 86 percent of the relevant samples. The generated shapes are consistent, and there are four of them.

A rich text field rendered as HTML. A bio, a comment, a product description, a note. The prompt was “let users format their bio”, the model reached for the only API that renders markup, and nothing in between decided what markup means:

// Vulnerable. profile.bio came out of Postgres, and went into Postgres from a form.
<div dangerouslySetInnerHTML={{ __html: profile.bio }} />

Markdown that was assumed to be safe. It is not. Markdown permits raw HTML by design, and the popular renderers dropped their built-in sanitizers years ago. marked removed its sanitize option and tells you to run a sanitizer yourself. react-markdown is safe until somebody adds rehype-raw to make an embedded <iframe> work, which is exactly the kind of thing a coding agent adds when you ask for one.

The admin panel. The worst version, and the one nobody tests. A support ticket, a signup name, a contact form message, rendered into an internal dashboard where the reader is an administrator. The attacker never needs to reach an admin account, because the admin comes to the payload. A stored XSS that only fires for staff is a privilege escalation with extra steps.

Anything stored as HTML “for later”. Cached email templates, a CMS body field, a JSON blob with a html key. The value is written by one part of the system and rendered by another, months apart, and the second one has no idea where it came from.

Which platform wrote the app barely changes that list. A rich text field looks the same whether it came from Lovable, Replit, or a prompt you typed yourself, because the sink is a framework API and every generator reaches for it the moment you ask for formatting.

Row Level Security does not help with any of this, which surprises people building on Supabase. RLS decides which rows a caller may read. A stored XSS is about what happens to a row the caller is fully entitled to read. Both controls are necessary and they answer different questions, the same way RLS misconfiguration and IDOR are different failures of the same wish.

Fixing it, in the order that matters

1. Render as text unless you have decided otherwise. In React, JSX interpolation escapes. {profile.bio} is already safe, and the entire bug is the deliberate act of opting out with dangerouslySetInnerHTML. The API is named the way it is on purpose. If the field does not need markup, this is the whole fix and it costs one line.

2. If it does need markup, sanitize on output with an allowlist. Not on input. Input sanitization stores a lossy value that you can never re-check, and it happens before you know where the value will be rendered. Output sanitization happens at the moment you know it is going into HTML:

import DOMPurify from "isomorphic-dompurify";

// The tags a bio may contain. Anything else becomes text or disappears.
const BIO = {
  USE_PROFILES: { html: true }, // drops SVG and MathML entirely
  ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "br", "ul", "ol", "li", "code"],
  ALLOWED_ATTR: ["href", "title"],
};

export function Bio({ html }) {
  return <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html, BIO) }} />;
}

USE_PROFILES: { html: true } is the line worth understanding rather than copying. It restricts the parser to the HTML namespace, which is what removes the entire SVG surface that Roundcube was breached through. A default sanitizer call permits SVG, because sometimes people want SVG.

If the sanitizer runs on the server, use the same allowlist there and export it from one module. Two sanitizer configurations that were identical when written are the standard way this bug comes back.

3. Put a Content Security Policy behind it. Sanitizers have bypasses. That is not a reason to skip them, it is the reason to have a second layer that does not depend on parsing being correct:

Content-Security-Policy: script-src 'self' 'nonce-{random-per-response}'; object-src 'none'; base-uri 'none'

Injected inline script has no nonce, so it does not run, even when the sanitizer missed it. This only works if the policy has no 'unsafe-inline' in script-src, and a policy that keeps it has bought you nothing at all. Our free scanner records that as its own check, csp-unsafe-inline, separately from no-csp, because “there is a policy” and “the policy stops script injection” are not the same statement, and the first one is what a dashboard tells you.

4. Escape by context, not in general. HTML body, HTML attribute, URL, and JavaScript string are four different escaping rules, and a value that is safe in one is an exploit in another. A template engine that escapes for HTML body does nothing for href="{{ userValue }}", where javascript:alert(1) is a working payload with no angle brackets in sight. Allowlist the URL scheme separately:

const safeHref = (raw) => {
  try {
    const url = new URL(raw, "https://example.com");
    return ["http:", "https:", "mailto:"].includes(url.protocol) ? url.href : "#";
  } catch {
    return "#";
  }
};

What a scanner can and cannot see

Our SAST engine, OpenGrep (app/engines/opengrep.py), matches the sink: a dangerouslySetInnerHTML, an innerHTML assignment, a v-html, a template rendered with autoescaping disabled. Sinks are worth flagging and they are the part that is mechanically findable.

The part it cannot decide is whether the value reaching that sink is yours or a stranger’s. In stored XSS the source is a database read, which means the taint path leaves the code entirely, sits in Postgres for a week, and comes back in a different service. No static analyzer follows that. It sees a variable from a query, and a query can equally return a string your own migration wrote.

So the finding a scanner gives you is “this renders unescaped HTML”, and the judgment is yours: can a user put anything into this column. That judgment is a question about your data model, which is why the release gate reads configuration and data alongside code rather than treating a clean code scan as an answer.

The other half is cheap and worth doing today. Take every field a user can write, put <img src=x onerror=alert(1)> in it, and load every page that displays it, including the admin views. It is not a clever payload. It does not need to be. If it fires, you knew about the field, and the field was the whole attack.

Stored XSS is one of three delivery mechanisms for the same underlying defect. Reflected XSS puts the payload in a URL and needs a click. DOM XSS happens after your server is finished, in the client’s own code, so the payload need never pass through your backend at all. The fixes overlap but not completely, which is why they are worth reading separately.

And once script is running on your origin, every token-based defense in your application is downstream of it. It can read the DOM, call your API, and lift the CSRF token out of the page before submitting the form. That ordering is why XSS is worth more of your attention than its CVSS score usually suggests: it is not one vulnerability among many, it is the one that turns off the others.

ShareXLinkedIn