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.


DOM-Based XSS Source-to-Sink Data Flow Diagram showing untrusted data flowing from browser sources through JavaScript propagation to DOM execution sinks, with a sanitization boundary in the middle. location.hash / location.search localStorage / sessionStorage postMessage / window.name fetch() / XHR response data UNTRUSTED SOURCES Sanitization Boundary DOMPurify Trusted Types Origin Validation Schema Check ENFORCE HERE innerHTML / insertAdjacentHTML setAttribute() href / src setTimeout / eval (blocked) Template / React render output TRUSTED SINKS (sanitized)

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, or postMessage events.
  • Templates or components insert those values into the DOM via innerHTML, insertAdjacentHTML, outerHTML, document.write, setAttribute, href/src bindings, setTimeout(string), or the Function() 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.