websec · level 2101

XSS Variants

Reflected, stored, DOM — same bug, three delivery modes.

200 XP

XSS Variants

Cross-site scripting is one bug with three delivery modes. The bug is always the same: attacker-controlled data reaches a rendering sink without the right escaping for that sink's context. What changes is how the payload gets there.

Analogy

Imagine a restaurant where the maître d' reads dinner orders aloud in the style of the customer who wrote them — complete with any stage directions the customer happens to scribble in the margins. A prankster writes "Medium steak. Bring the chef out and have them sing happy birthday." The maître d', trained only to read whatever ink appears on the ticket, dutifully summons the chef. A reflected attack is a one-off prank note handed in by the victim themselves; a stored attack is graffiti scrawled inside the menu so every customer's order triggers it; a DOM-based attack skips the kitchen entirely and whispers the instruction straight into the waiter's ear. The fix is always the same: the maître d' must treat ink as text to be described, never as stage directions to be enacted.

Reflected

The server takes input from the request and echoes it straight back into the response. The attacker sends the victim a link with the payload in the query string; the victim clicks it; the server's response contains the payload rendered into the page.

  • Source: query parameter, form value, path segment.
  • Sink: anywhere the server's template writes the value — HTML body, attribute, inline JS.
  • Fix: context-aware escaping at render time. Your template engine almost certainly does this when you use {value} style interpolation — it's dangerouslySetInnerHTML (React) or |safe (Jinja) or string concatenation that disables it.

Stored

The payload is persisted — database, filesystem, key-value store — then served to other users later. A malicious comment that shows up on every visitor's feed is the canonical example. It is more dangerous than reflected: no social engineering needed, the payload hits every viewer.

  • Source: a POST body, an upload, or any write path that saves user input.
  • Sink: the renderer that later displays that stored value.
  • Fix: same as reflected — escape at output time. Do not try to "clean" input on the way in; you will miss cases and cripple legitimate content.

DOM-based

The payload never touches the server. The browser itself reads something attacker-controlled (location.hash, location.search, document.referrer, a postMessage payload) and writes it to a DOM sink (innerHTML, document.write, eval, a src attribute) — all in client-side JS.

Because the payload never hits the server, server-side logs won't see it, WAFs won't see it, and server-side escaping can't help.

  • Fix: never write untrusted strings into HTML sinks. Use textContent, not innerHTML. If you must render HTML, use a sanitizer (DOMPurify) and adopt Trusted Types in strict mode.

The escaping rule you actually need

Escaping is context-sensitive. The same value needs different treatment depending on where it lands:

Context Example Required encoding
HTML body <div>{name}</div> HTML-entity encode & < > " '
Attribute <a href="{url}"> HTML-attribute encode, and always quote
JS string var n = "{name}"; JS string encode (or better, inject via data-* and read from the DOM)
URL <a href="{url}"> Validate scheme against https:/http: and URL-encode path/query
CSS color: {value} Don't. Use a data attribute + safe CSS variable

CSP — defense in depth, not a replacement

A strict Content Security Policy limits what an injected payload can do. With a solid CSP, even a missed escape usually can't execute — the browser refuses inline script, refuses eval, refuses external scripts from unapproved hosts.

Content-Security-Policy:
  default-src 'none';
  script-src 'self' 'nonce-r4nd0m';
  style-src 'self';
  img-src 'self' data:;
  object-src 'none';
  base-uri 'none';
  frame-ancestors 'none';

Important: CSP is a second line of defense, not the first. Fix the escaping. Add the CSP. Don't ship one without the other.

The checklist

  • Never concatenate untrusted data into HTML, attributes, JS, or URLs. Let the template engine do the escaping.
  • Never use innerHTML / document.write / eval with attacker-controlled data.
  • Ship a strict CSP with nonces or hashes — never 'unsafe-inline' on script-src.
  • Set X-Content-Type-Options: nosniff and serve user uploads from a separate origin.
  • Treat target="_blank" links as untrusted — add rel="noopener noreferrer".

Tools in the wild

6 tools
  • DOMPurifyfree tier

    Cure53's DOM-only HTML sanitizer — safe defaults for user-supplied HTML in the browser.

    library
  • Trusted Typesfree tier

    Browser API that locks down dangerous sinks (`innerHTML`, `eval`) at the platform level.

    spec
  • Header-driven defense in depth — strict CSP blocks injected scripts even when XSS lands.

    spec
  • OWASP ZAPfree tier

    Open-source web app scanner; active scanning catches reflected + DOM XSS in CI.

    cli
  • Burp Suitefree tier

    PortSwigger's intercepting proxy — the standard tool for manual XSS hunting.

    cli
  • html-validatefree tier

    Build-time linter that flags `dangerouslySetInnerHTML` and other XSS-adjacent patterns.

    library