XSS Beyond the Basics
The XSS That Gets Past Your Sanitizer
You know about innerHTML being dangerous. You know about escaping user input. That's table stakes. The XSS attacks that land at FAANG companies look nothing like alert(1) — they exploit parser differentials, DOM clobbering, mutation XSS, and template injection in frameworks that claim to be "safe by default."
Here's the thing most people miss: modern browsers and frameworks have made basic XSS harder, but they've created entirely new attack surfaces in the process. React's JSX escaping doesn't protect you from dangerouslySetInnerHTML. DOMPurify can be bypassed through mutation XSS. And the DOM itself has a feature called "clobbering" that most developers have never even heard of.
Think of your browser as a nightclub with multiple entrances. Traditional XSS is walking through the front door — bouncers (sanitizers) check you there. But DOM clobbering is climbing in through a window. Mutation XSS is bribing the bouncer into thinking your weapon is a toy. Template injection is wearing a staff uniform. To secure the club, you need to understand every entrance, not just the front door.
DOM Clobbering: When HTML Overwrites JavaScript
DOM clobbering is one of the most underrated attack vectors. It exploits a legacy browser behavior: named HTML elements are automatically exposed as properties on document and window.
// If your page contains: <img id="currentUser">
// Then this becomes truthy:
typeof document.currentUser // "object" — it's the <img> element!
This matters when your code does something like:
// Developer assumes currentUser is set by their auth logic
const user = window.currentUser || { role: 'guest' }
An attacker who can inject HTML (even if scripts are blocked by CSP) can write:
<a id="currentUser" href="javascript:void(0)">
Now window.currentUser is the anchor element instead of undefined. Your fallback logic never fires. Depending on how currentUser is used downstream, this can escalate to privilege issues or even code execution.
The Double Clobbering Technique
It gets worse. You can clobber nested properties using <form> elements:
<form id="config">
<input name="apiUrl" value="https://evil.com/steal">
</form>
Now document.config.apiUrl returns the input element, and document.config.apiUrl.value returns "https://evil.com/steal". If your code reads config.apiUrl from the DOM, the attacker controls your API endpoint.
Defending Against DOM Clobbering
// BAD: vulnerable to clobbering
const endpoint = document.config?.apiUrl
// GOOD: use a variable that can't be clobbered
const CONFIG = Object.freeze({ apiUrl: '/api/v1' })
// GOOD: validate that the value is actually a string
const endpoint = typeof document.config?.apiUrl === 'string'
? document.config.apiUrl
: '/api/v1'
The key defense: never trust that a global or document property contains what you expect. Use Object.freeze, module-scoped variables, or explicit type checks.
Mutation XSS: The Sanitizer Bypass
Mutation XSS (mXSS) is the attack class that keeps DOMPurify maintainers up at night. It exploits the difference between how a sanitizer parses HTML and how the browser re-parses it after insertion.
Here's the core insight: when you set innerHTML, the browser doesn't just drop in your string — it re-parses it. And the re-parsing can produce different DOM structure than what the sanitizer saw.
<!-- What the sanitizer sees and approves: -->
<math><style><img src=x onerror=alert(1)></style></math>
<!-- What the browser actually creates after innerHTML insertion: -->
<!-- The <style> inside <math> is treated as text by the math parser, -->
<!-- but when re-parsed in HTML context, the <img> escapes the style tag -->
The mutation happens because different parsing contexts (HTML, SVG, MathML) have different rules. A string that's "safe" in one context becomes dangerous when the browser switches contexts during re-parsing.
How DOMPurify handles mutation XSS
DOMPurify's core defense is to perform sanitization in the same parsing context the browser will use. Internally, it:
- Creates a detached document via
document.implementation.createHTMLDocument('') - Sets the dirty HTML via
innerHTMLon a body element in that document - Walks the resulting DOM tree — not the string — checking each node against allow/block lists
- Serializes the clean tree back to a string
This "parse then walk" approach catches most mXSS because the sanitizer sees the same DOM the browser would create. But edge cases still exist — particularly around template elements, namespace switches between HTML/SVG/MathML, and browser-specific parsing quirks.
DOMPurify also runs multiple rounds of sanitization when it detects potential namespace confusion. Since version 3.x, it added the SAFE_FOR_TEMPLATES option and improved handling of the noscript element, which has different parsing behavior depending on whether scripting is enabled.
Trusted Types: The Browser-Native Defense
Trusted Types is a browser API that prevents DOM-based XSS at the platform level. Instead of trying to catch every dangerous string, it makes the dangerous sinks (like innerHTML, eval, document.write) reject raw strings entirely.
// Without Trusted Types: anything goes
element.innerHTML = userInput // works, and you hope it's safe
// With Trusted Types enforced: raw strings are rejected
element.innerHTML = userInput // TypeError: This requires a TrustedHTML value
// You must create values through a policy
const policy = trustedTypes.createPolicy('sanitizer', {
createHTML: (input) => DOMPurify.sanitize(input)
})
element.innerHTML = policy.createHTML(userInput) // works
Setting Up Trusted Types
You enable enforcement via CSP:
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types sanitizer
This tells the browser: "Only accept TrustedHTML, TrustedScript, or TrustedScriptURL values in injection sinks. Only the policy named 'sanitizer' can create them."
Browser Support Reality
Trusted Types is natively supported in Chromium-based browsers (Chrome 83+, Edge 83+, Opera). Firefox and Safari do not yet support it natively. A polyfill exists but only provides the API surface — it cannot enforce the sink restrictions in non-supporting browsers.
For production, deploy Trusted Types in report-only mode first:
Content-Security-Policy-Report-Only: require-trusted-types-for 'script'; trusted-types sanitizer; report-uri /csp-report
This logs violations without breaking functionality, letting you find all the injection sinks in your codebase before enforcing.
Template Injection in Frontend Frameworks
React escapes string values in JSX by default. Vue escapes template interpolations. Angular sanitizes bindings. But each framework has escape hatches, and developers use them more often than they should.
React: The dangerouslySetInnerHTML Trap
// React escapes this — safe
<div>{userInput}</div>
// React does NOT escape this — you own the risk
<div dangerouslySetInnerHTML={{ __html: userInput }} />
But there's a subtler issue. React also doesn't escape href values:
// This renders a clickable javascript: URL — XSS
<a href={userInput}>Click me</a>
// If userInput is "javascript:alert(document.cookie)"
// the user clicks and JS executes
React 19 warns in development mode about javascript: URLs, but does not block them in production. You must validate URL schemes yourself.
Vue: v-html and Beyond
<!-- Safe: Vue escapes interpolations -->
<p>{{ userInput }}</p>
<!-- Dangerous: raw HTML injection -->
<p v-html="userInput"></p>
Vue 3 also has a lesser-known vector: dynamic component names.
<!-- If componentName comes from user input, this is component injection -->
<component :is="componentName" />
Angular: Bypassing the Sanitizer
Angular's DomSanitizer is strict by default, but developers routinely bypass it:
// The bypass that security teams love to find in code reviews
this.sanitizer.bypassSecurityTrustHtml(userInput)
Every call to bypassSecurityTrust* should be audited. It's Angular's equivalent of dangerouslySetInnerHTML.
Production Scenario: Sanitizing a Rich Text Editor
You're building a rich text editor that saves HTML content. Users can format text with bold, italic, lists, and links. Here's how to handle it safely:
import DOMPurify from 'dompurify'
const ALLOWED_TAGS = ['b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'ol', 'li', 'br']
const ALLOWED_ATTR = ['href', 'target', 'rel']
function sanitizeEditorContent(dirty) {
return DOMPurify.sanitize(dirty, {
ALLOWED_TAGS,
ALLOWED_ATTR,
ALLOW_DATA_ATTR: false,
ADD_ATTR: ['target'],
FORBID_ATTR: ['style', 'class', 'id', 'name'], // prevent clobbering
FORCE_BODY: true,
})
}
// Validate URLs in href attributes
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'A') {
const href = node.getAttribute('href') || ''
if (!/^https?:\/\//i.test(href)) {
node.removeAttribute('href')
}
node.setAttribute('target', '_blank')
node.setAttribute('rel', 'noopener noreferrer')
}
})
Key decisions in this config:
- Explicit allowlist over blocklist — only permit tags you need
- Remove
idandnameattributes — prevents DOM clobbering - Validate URLs — blocks
javascript:anddata:schemes - Force
noopener noreferrer— prevents tab-napping attacks
| What developers do | What they should do |
|---|---|
| Using a blocklist to remove dangerous tags while allowing everything else Blocklists always miss edge cases. New HTML elements and attributes are added to the spec regularly. An allowlist ensures only known-safe elements pass through. | Using an explicit allowlist of permitted tags and attributes |
| Sanitizing HTML with regex like replacing angle brackets HTML is not a regular language — regex cannot parse it correctly. Nested contexts, encoding tricks, and parser quirks mean regex will always have bypasses. DOM-based sanitizers parse HTML the same way the browser does. | Using a proper DOM-based sanitizer like DOMPurify |
| Trusting that React or Vue auto-escaping covers all cases Framework auto-escaping only applies to text interpolation. Escape hatches (dangerouslySetInnerHTML, v-html) and certain attributes (href, src) are not covered. Each one is a potential injection point. | Auditing every use of dangerouslySetInnerHTML, v-html, href attributes, and bypassSecurityTrust |
Challenge: Spot the Vulnerability
Try to solve it before peeking at the answer.
function renderUserProfile(user) {
const container = document.getElementById('profile')
container.innerHTML =
'<h2>' + DOMPurify.sanitize(user.name) + '</h2>' +
'<a href="' + user.website + '">Website</a>' +
'<div id="' + user.username + '">' +
'<img src="' + user.avatar + '" alt="avatar">' +
'</div>'
}- 1Never trust id or name attributes from user input — they enable DOM clobbering
- 2Use DOMPurify with explicit allowlists, never blocklists — and keep it updated for mXSS patches
- 3Deploy Trusted Types in report-only mode first, then enforce — it catches DOM XSS at the sink level
- 4Validate URL schemes (http/https only) for every user-controlled href and src attribute
- 5Audit every use of dangerouslySetInnerHTML, v-html, bypassSecurityTrust — each is an unguarded injection sink
- 6Framework auto-escaping only covers text interpolation — attributes, URLs, and raw HTML insertions are your responsibility