DOM-Based Vulnerability Sanitization
Client-side DOM manipulation is a high-velocity attack surface where untrusted data flows directly from browser-exposed sources to execution sinks without any server mediation. Unlike reflected or stored injection vectors, DOM-based attacks execute entirely inside the runtime environment, bypassing traditional server-side input validation and WAF rules completely. A successful exploit can exfiltrate session tokens, alter application state, or silently redirect users — all without a detectable server request. This guide is part of Vulnerability Patterns & Web Mitigation Strategies and focuses on actionable source-to-sink mapping, context-aware sanitization, and Trusted Types enforcement. Where client-side sanitization intersects with rendered HTML, the controls here complement Cross-Site Scripting (XSS) Mitigation and Cross-Site Request Forgery (CSRF) Defense.
Threat Anatomy
DOM-based vulnerabilities follow a source → propagation → sink chain. The attacker controls data at the source; the application transports it through JavaScript logic; a DOM sink executes or renders it without sanitization.
MITRE ATT&CK reference: T1059.007 (JavaScript execution), T1185 (Browser Session Hijacking).
Source categories and their sinks
| Source Category | Common DOM Sources | Dangerous Execution Sinks |
|---|---|---|
| URL / Location | location.hash, location.search, document.URL, document.referrer |
innerHTML, outerHTML, document.write(), eval() |
| Storage / State | localStorage, sessionStorage, IndexedDB |
setAttribute(), insertAdjacentHTML(), setTimeout(string) |
| Cross-Window | postMessage data, window.name |
iframe.srcdoc, window.open(), dynamic node creation |
| Network Responses | fetch() / XMLHttpRequest JSON bodies |
Dynamic template rendering, Function() constructor |
How attackers exploit the chain
The canonical attack path requires no server interaction. An attacker crafts a URL with a malicious #fragment. The application reads location.hash inside a client-side router and assigns the value to element.innerHTML. The browser parses the injected markup and fires any embedded event handler. The entire attack executes inside the victim’s session with no server-side log entry for the payload itself.
Framework escape hatches amplify the risk: dangerouslySetInnerHTML in React, v-html in Vue, and DomSanitizer.bypassSecurityTrustHtml in Angular each disable the framework’s built-in context encoding at the call site, re-exposing the raw sink to developer-supplied strings.
For a deep dive into reflected and stored variants of the same injection class, see Cross-Site Scripting (XSS) Mitigation.
Prerequisites & Scope
Apply the controls on this page when your application meets any of these conditions:
- Client-side JavaScript reads from
location.hash,location.search,document.URL,document.referrer,localStorage,sessionStorage,window.name, orpostMessageevents. - Templates or components insert those values into the DOM via
innerHTML,insertAdjacentHTML,outerHTML,document.write,setAttribute,href/srcbindings,setTimeout(string), or theFunction()constructor. - You use React, Vue, Angular, or Svelte but bypass the framework’s default encoding at least once (
dangerouslySetInnerHTML,v-html,bypassSecurityTrustHtml). - Your application embeds third-party widgets or runs inside an iframe that communicates over
postMessage.
Runtime dependencies: DOMPurify ≥ 3.0; a browser supporting the Trusted Types API (Chrome 83+, Edge 83+; polyfill available for Firefox/Safari); Node.js 20+ for CI sink-tracing tests.
Out of scope: reflected and stored XSS triggered by server-rendered HTML — those are addressed in Cross-Site Scripting (XSS) Mitigation. Server-side template injection falls under Injection Attack Prevention.
Mitigation Architecture
The following table contrasts the vulnerable pattern with the hardened pattern for the most common DOM sink scenarios.
| Scenario | Vulnerable Pattern | Hardened Pattern |
|---|---|---|
| Hash-driven content | el.innerHTML = location.hash.slice(1) |
el.innerHTML = policy.createHTML(DOMPurify.sanitize(location.hash.slice(1), cfg)) |
| User-supplied link | a.href = userInput |
URL allowlist check (https: only) before assignment; about:blank on failure |
postMessage render |
el.innerHTML = event.data.html |
Origin check → schema validation → policy.createHTML(sanitize(data)) |
| Framework escape hatch | dangerouslySetInnerHTML={{ __html: raw }} |
dangerouslySetInnerHTML={{ __html: policy.createHTML(DOMPurify.sanitize(raw, cfg)) }} |
Dynamic setTimeout |
setTimeout(userString, 500) |
Use callback: setTimeout(() => handleAction(safeData), 500) — never pass strings |
The Trusted Types API acts as the enforcement layer: when Content-Security-Policy: require-trusted-types-for 'script' is active, the browser rejects any string assigned directly to a DOM sink with a TypeError, forcing all insertions through the named policy.
Step-by-Step Implementation
Step 1 — Map and audit your source-to-sink flows (OWASP ASVS V5.3)
Run a static scan to discover every DOM sink assignment that receives a non-literal string:
# ESLint with security plugin — add to your existing ESLint config
npm install --save-dev eslint-plugin-security @microsoft/eslint-plugin-sdl
# .eslintrc additions
{
"plugins": ["security", "@microsoft/sdl"],
"rules": {
"security/detect-non-literal-regexp": "error",
"@microsoft/sdl/no-inner-html": "error",
"@microsoft/sdl/no-document-write": "error",
"@microsoft/sdl/no-unsafe-url": "error"
}
}
Additionally run CodeQL with the javascript/xss query suite in CI to catch taint flows that span multiple function calls.
Step 2 — Install and configure DOMPurify with context-aware rules (OWASP ASVS V5.4)
import DOMPurify from 'dompurify';
const HTML_CONFIG: DOMPurify.Config = {
ALLOWED_TAGS: ['a', 'b', 'i', 'em', 'strong', 'p', 'ul', 'ol', 'li', 'span', 'img', 'br'],
ALLOWED_ATTR: ['href', 'title', 'alt', 'src', 'class'],
ALLOW_DATA_ATTR: false,
FORBID_ATTR: ['style', 'onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur'],
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'base'],
KEEP_CONTENT: true,
};
/**
* Context-aware sanitizer.
* @param input Untrusted string from any DOM source.
* @param context 'html' | 'attribute' | 'url'
*/
export function sanitizeForContext(
input: unknown,
context: 'html' | 'attribute' | 'url' = 'html'
): string {
if (typeof input !== 'string') return '';
switch (context) {
case 'html':
return DOMPurify.sanitize(input, HTML_CONFIG);
case 'attribute':
// Strip all tags; remove characters that can break attribute context
return DOMPurify.sanitize(input, { ALLOWED_TAGS: [], ALLOWED_ATTR: [] })
.replace(/["'<>&]/g, '');
case 'url': {
try {
const url = new URL(input, window.location.origin);
if (!['https:', 'http:', 'mailto:'].includes(url.protocol)) {
throw new Error('Unsafe URI scheme');
}
return url.toString();
} catch {
return 'about:blank';
}
}
default:
throw new Error(`Unsupported sanitization context: ${String(context)}`);
}
}
Never sanitize for JavaScript execution context — if you need to pass data into a script, use JSON serialization and strict data binding, not string injection.
Step 3 — Define a Trusted Types policy (NIST SP 800-53 SI-10)
// trusted-types-policy.ts — import once at application entry point
declare const trustedTypes: TrustedTypePolicyFactory | undefined;
let policy: TrustedTypePolicy | null = null;
if (typeof trustedTypes !== 'undefined' && trustedTypes.createPolicy) {
policy = trustedTypes.createPolicy('dom-sanitizer', {
createHTML: (input: string) => sanitizeForContext(input, 'html'),
createScriptURL: (input: string) => {
const url = new URL(input, window.location.origin);
if (url.protocol !== 'https:') throw new TypeError('Non-HTTPS script URL blocked');
return url.toString();
},
createScript: () => {
throw new TypeError('Inline script creation is prohibited');
},
});
}
/** Safe DOM insertion — falls back gracefully when Trusted Types is not supported. */
export function setInnerHTML(element: Element, html: string): void {
if (policy) {
element.innerHTML = policy.createHTML(html) as unknown as string;
} else {
element.innerHTML = sanitizeForContext(html, 'html');
}
}
Step 4 — Set the Content Security Policy header
Content-Security-Policy:
default-src 'self';
script-src 'strict-dynamic' 'nonce-{RANDOM_PER_REQUEST}';
trusted-types dom-sanitizer;
require-trusted-types-for 'script';
object-src 'none';
base-uri 'none';
The require-trusted-types-for 'script' directive tells the browser to reject any string-to-sink assignment that does not originate from a named Trusted Types policy. This turns every unguarded innerHTML = call into a TypeError that surfaces immediately in testing rather than silently in production.
Step 5 — Harden postMessage handlers (OWASP ASVS V5.3)
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://admin.example.com',
]);
interface MessagePayload {
action: 'updateUI' | 'loadData';
content?: string;
}
function isValidPayload(data: unknown): data is MessagePayload {
if (typeof data !== 'object' || data === null) return false;
const d = data as Record<string, unknown>;
return (
(d.action === 'updateUI' || d.action === 'loadData') &&
(d.content === undefined || typeof d.content === 'string')
);
}
window.addEventListener('message', (event: MessageEvent) => {
// 1. Strict origin allowlist
if (!ALLOWED_ORIGINS.has(event.origin)) return;
// 2. Structural schema validation
if (!isValidPayload(event.data)) return;
// 3. Sanitize before DOM insertion
if (event.data.action === 'updateUI' && event.data.content) {
const container = document.getElementById('dynamic-container');
if (container) setInnerHTML(container, event.data.content);
}
});
Edge Cases & Bypass Patterns
1. Mutation XSS (mXSS) via browser parser differences
DOMPurify sanitizes input using the browser’s own HTML parser. Certain payloads exploit parser differences between the sanitization context and the insertion context (e.g., inside <table>, <svg>, or <math> elements) to reconstruct malicious markup after sanitization. Mitigation: keep DOMPurify up to date; never insert sanitized HTML into a different parser context than the one used during sanitization (e.g., do not sanitize as HTML then insert as SVG content).
2. Prototype pollution enabling sink injection
An attacker who can write to Object.prototype via a JSON merge operation can poison default property lookups. If DOMPurify or Trusted Types configuration objects inherit polluted properties, sanitization rules may be silently bypassed. Mitigation: use Object.create(null) for configuration objects; freeze configuration with Object.freeze(HTML_CONFIG); validate JSON input with a schema validator before merging.
3. Trusted Types policy name spoofing
If your CSP lists trusted-types dom-sanitizer allow-duplicates, an injected script may register a second policy with the same name that bypasses sanitization. Mitigation: omit allow-duplicates; register your policy exactly once at module load time; store the policy reference in a module-scoped const.
4. Angular bypassSecurityTrustHtml audit gap
Angular’s DomSanitizer.bypassSecurityTrustHtml suppresses Angular’s own sanitization permanently for the marked value and propagates it through the template pipeline. A single call can create an application-wide bypass if the marked value reaches multiple templates. Mitigation: ban bypassSecurityTrustHtml in ESLint and require a code-review approval for any exception; wrap the call site with an explicit DOMPurify pass before marking as trusted.
5. javascript: URI in href on older browsers
Protocol-relative URLs and double-encoded javascript: schemes bypass naive blocklists on IE-era browsers and some mobile WebViews. Mitigation: normalise all URLs through the URL constructor; compare url.protocol against an explicit allowlist (['https:', 'http:', 'mailto:']); default to about:blank on any failure.
For how these bypass patterns relate to broader injection attack prevention controls, see the parameterized query and allowlist patterns in that section.
Automated Testing & CI Validation
Unit test — sanitizer contract
// sanitizer.test.ts (Vitest or Jest)
import { describe, expect, it } from 'vitest';
import { sanitizeForContext } from './sanitizer';
describe('sanitizeForContext', () => {
it('strips script tags from HTML context', () => {
const result = sanitizeForContext('<script>alert(1)</script>hello', 'html');
expect(result).not.toContain('<script>');
expect(result).toContain('hello');
});
it('blocks javascript: URIs', () => {
expect(sanitizeForContext('javascript:alert(1)', 'url')).toBe('about:blank');
expect(sanitizeForContext('data:text/html,<h1>x</h1>', 'url')).toBe('about:blank');
});
it('allows https: URIs', () => {
expect(sanitizeForContext('https://example.com/path', 'url')).toBe('https://example.com/path');
});
it('strips event handlers from attribute context', () => {
const result = sanitizeForContext('" onmouseover="alert(1)', 'attribute');
expect(result).not.toContain('onmouseover');
expect(result).not.toContain('"');
});
it('returns empty string for non-string input', () => {
expect(sanitizeForContext(null, 'html')).toBe('');
expect(sanitizeForContext(undefined, 'html')).toBe('');
});
});
CI/CD gate
# .github/workflows/dom-security.yml
name: DOM Security Gates
on: [push, pull_request]
jobs:
static-analysis:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: ESLint DOM sink rules
run: npx eslint . --ext .ts,.tsx,.js --max-warnings 0
- name: Run sanitizer unit tests
run: npx vitest run --reporter=verbose
- name: CodeQL DOM XSS analysis
uses: github/codeql-action/analyze@v3
with:
languages: javascript
queries: security-and-quality
csp-violation-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- name: Headless CSP violation test
run: |
node scripts/csp-sink-trace.js
# Exit non-zero if any CSP violation is logged during polyglot payload injection
Compliance Mapping
| Framework | Control | Satisfied By |
|---|---|---|
| OWASP ASVS 4.0 | V5.3.1 — Output encoding prevents XSS | Context-aware sanitizeForContext() wrapping all DOM sinks |
| OWASP ASVS 4.0 | V5.3.3 — Context-aware URL encoding | URL constructor protocol allowlist; about:blank default |
| OWASP ASVS 4.0 | V5.3.9 — Trusted Types enforcement | trustedTypes.createPolicy('dom-sanitizer', ...) + CSP directive |
| SOC 2 CC6.1 | Logical access controls for data integrity | Trusted Types policy restricts all sink writes to sanitized values |
| SOC 2 CC7.1 | System monitoring and anomaly detection | CSP report-uri + violation logging surfacing unsanitized sink attempts |
| NIST SP 800-53 | SI-10 — Information Input Validation | Schema validation for postMessage; DOMPurify for HTML sinks |
| NIST SP 800-53 | SC-18 — Mobile Code | object-src 'none'; require-trusted-types-for 'script' in CSP |
| PCI DSS 4.0 | Req 6.4.3 — Client-side script inventory & integrity | ESLint gate + CodeQL taint analysis on every pull request |
Common Pitfalls Checklist
Frequently Asked Questions
Why does DOM-based sanitization differ from server-side filtering?
Server-side filters process HTTP request payloads before rendering a response. DOM-based vulnerabilities occur entirely inside the browser: sources such as localStorage, location.hash, and postMessage never traverse the network and therefore bypass WAF rules, server-side validation, and log inspection. Client-side controls must independently validate all data that originates in the browser environment, even data that the application itself previously wrote to localStorage.
How does the Trusted Types API improve DOM sanitization?
Without Trusted Types, the contract “always sanitize before assigning to innerHTML” is enforced only by code review and developer discipline — both of which scale poorly. Trusted Types makes the contract machine-enforced: the browser’s DOM bindings reject any plain string assigned to a sink and throw a TypeError immediately. This surfaces violations during development and CI testing rather than in production incidents, and eliminates the entire class of “forgot to sanitize” bugs.
Can modern frontend frameworks prevent all DOM-based vulnerabilities?
React, Vue, Angular, and Svelte auto-escape template variable interpolation against their internal serialization context. However, none of them protect against direct DOM API calls (document.write, innerHTML) made outside the template, and all of them expose escape hatches that disable encoding for a specific value. Frameworks mitigate the common case but cannot provide guarantees when their defaults are bypassed or when the application reads directly from location.*, postMessage, or Web Storage.
How do I test for DOM-based vulnerabilities in CI/CD?
Layer three techniques: (1) static taint analysis with ESLint @microsoft/sdl rules and CodeQL javascript/xss queries to catch direct source-to-sink assignments at review time; (2) unit tests that assert your sanitizer rejects known polyglot payloads; and (3) dynamic headless-browser tests (Playwright) that inject payloads into location.hash and postMessage, then monitor CSP violation reports and DOM mutation observers for unexpected script execution. Fail the CI job if any CSP violation report is generated.
Related
- Cross-Site Scripting (XSS) Mitigation — reflected and stored injection via server-rendered HTML
- Escaping User Input in React and Vue Components — framework-specific encoding patterns
- Cross-Site Request Forgery (CSRF) Defense — coordinating client-side state mutation controls with origin verification
- Injection Attack Prevention — server-side allowlist and parameterization patterns that complement client-side controls
- Vulnerability Patterns & Web Mitigation Strategies — parent section covering the full spectrum of web application security controls