In short

Render plain text with textContent unless the feature needs formatting. For rich text, convert Markdown first, then sanitize the final HTML with a maintained sanitizer and a narrow allowlist, such as paragraphs, emphasis, lists and links to absolute https: URLs. Insert the result without further string concatenation, enforce Trusted Types so other HTML sinks fail, keep the sanitizer patched against mutation XSS, and accept postMessage input only from the expected origin, frame and schema.

  • Sanitize the final HTML: Markdown rendering, link conversion and plugins run first, and nothing concatenates markup afterwards.
  • List allowed elements and attributes explicitly; with the HTML Sanitizer API an elements list alone keeps other attributes, so add attributes: [].
  • Check link destinations with a URL parser: javascript:, relative and credential-bearing URLs fail this policy.
  • Mutation XSS exploits differences between parsing and reparsing; insert sanitized nodes, avoid string round trips and keep the sanitizer current.
  • Trusted Types have been Baseline since February 2026; enforce require-trusted-types-for 'script' after a report-only rollout.
  • Treat postMessage as an API: check event.origin, event.source, the message schema and the requested action.

Verify it

  1. Render each fixture in the supported browsers and compare the resulting DOM, not the string, with the expected table.
  2. Confirm no event handler, id, style or javascript: value survives in stored output, previews, exports or email renderings.
  3. Send Content-Security-Policy-Report-Only: require-trusted-types-for 'script' and fix every reported sink before enforcing it.
  4. Post messages from the right origin but the wrong frame, with an unknown type and with an out-of-range value; none may change state.
  5. Bump the policy version in a test and confirm stored content is sanitized again before display.
Applies to

Rich text, Markdown and HTML inserted into the DOM in the browser or sanitized on the server, plus postMessage handlers. The sanitizer configuration must match the supported elements, attributes and sinks.

Part of
Learning path: Browser and API boundaries (step 6 of 18). Workflows: Fix a finding, Secure a feature

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 30 minutes: 9 to read, 3 on the code and 18 for the exercise

What you will learn

  • Define rich text as an allowed capability set.
  • Place sanitization after representation-changing steps.
  • Validate frame messages independently of origin alone.

Before you start

Front-end and full-stack developers who display user-formatted content or embed frames, and reviewers of sanitizer and messaging code.

  • Distinguish plain-text rendering, HTML parsing, URL handling and DOM insertion.

Refresh first: Prevent XSS at the output boundary

Go to the guided practice

Treat rich text as a request for browser capabilities

Plain text represents characters; HTML can create links, images, forms, embedded content and executable event handlers. For names, labels, notifications and search snippets, text binding or textContent avoids HTML interpretation entirely. A rich-text feature needs a capability policy, not simply a promise that input came from a WYSIWYG editor. Users can bypass that editor, import old documents or submit content through an API. The final HTML representation reaching the browser is the security-relevant input.

Suppose comments may contain paragraphs, strong and emphasized text, lists, and HTTPS links. They do not need forms, images, styles, script, iframes, ids or event handlers. That policy preserves useful formatting while excluding unnecessary capabilities. A submitted <strong>hello</strong> can remain formatted; an image element with an onerror handler must disappear or become literal text according to the product's chosen rendering contract. Deleting only script tags leaves many other executable and navigation contexts intact.

Sanitize after conversion and before the HTML sink

Many pipelines have multiple interpreters: Markdown becomes HTML, linkifiers add anchors, a sanitizer processes markup and a component inserts it. If a linkifier runs after sanitization and concatenates attacker-controlled strings into HTML, it can reintroduce the very capability the sanitizer removed. Perform representation-changing transformations first, then sanitize the complete result with a maintained parser-aware library and the explicit allowlist. Avoid further untrusted HTML concatenation afterward.

Keep URL policy separate within sanitization. An a element may be allowed while javascript: and unexpected data: links are disallowed. Parse and validate permitted schemes rather than testing a raw prefix before entity or whitespace handling. Remove unnecessary id and name attributes to reduce DOM-clobbering interactions with application code. Keep the sanitized artifact's policy version so stored content can be reprocessed when the policy or sanitizer changes. A cached clean verdict from an obsolete policy is not permanent proof.

The module below applies the comment policy in the browser. Where the HTML Sanitizer API is available it calls setHTML on a detached element, which always drops script-capable markup, with an explicit element list and attributes: []; without that empty list Chrome kept the id attribute in testing. Elsewhere it falls back to DOMPurify, returning a TrustedHTML value so the assignment works under enforced Trusted Types. Both paths then keep an href only for an absolute https: URL without credentials. In Chrome 154 with require-trusted-types-for 'script' enforced, both paths produced the expected output for H1 to H4, a relative link, a credential-bearing link, an SVG script and a known mutation-XSS payload, and a plain string assigned to innerHTML threw a TypeError.

  • Order: Markdown renderer, then link conversion, then the sanitizer, then the sink; nothing concatenates HTML afterwards.
  • Elements: p, strong, em, ul, ol, li and a.
  • Attributes: href on a only; every other attribute is removed.
  • Links: absolute https: URLs without credentials.
  • Stored output records the policy version, here 4.
Fixedrender-comment.mjsJavaScript
// render-comment.mjs: comments may use p, strong, em, ul, ol, li and HTTPS links, nothing else.
import DOMPurify from 'dompurify'; // 3.4.16 or later as of October 2026

const TAGS = ['p', 'strong', 'em', 'ul', 'ol', 'li'];

// Keep an href only if it is an absolute https: URL without credentials.
function allowedHref(value) {
  try {
    const url = new URL(value);
    return url.protocol === 'https:' && !url.username && !url.password;
  } catch {
    return false; // relative or malformed
  }
}

DOMPurify.addHook('afterSanitizeAttributes', node => {
  if (node.hasAttribute('href') && !allowedHref(node.getAttribute('href')))
    node.removeAttribute('href');
});

// Call after Markdown rendering and link conversion, never before them.
export function renderComment(target, html) {
  if (typeof Element.prototype.setHTML === 'function') {
    // HTML Sanitizer API: drops script-capable markup, then applies this allowlist.
    const box = document.createElement('div');
    const elements = [...TAGS, {name: 'a', attributes: ['href']}];
    box.setHTML(html, {sanitizer: {elements, attributes: []}}); // []: no global attributes
    for (const a of box.querySelectorAll('a[href]'))
      if (!allowedHref(a.getAttribute('href'))) a.removeAttribute('href');
    target.replaceChildren(...box.childNodes);
  } else {
    target.innerHTML = DOMPurify.sanitize(html, {
      ALLOWED_TAGS: [...TAGS, 'a'],
      ALLOWED_ATTR: ['href'],
      RETURN_TRUSTED_TYPE: true, // TrustedHTML where Trusted Types are enforced
    });
  }
}

Choose a maintained sanitizer and keep it current

Use a parser-based sanitizer maintained by people who track browser parsing changes; a regular expression cannot model how browsers build a tree. Mutation XSS is the reason: a sanitizer parses the string, removes dangerous nodes and serializes the result, and the browser then parses that string again. Nesting tricks with math, svg, form and style can make the second parse build different elements from the first, turning inert text into markup. DOMPurify fixed such a nesting-based bypass in 3.1.3 (CVE-2024-47875), and similar fixes keep arriving, so track sanitizer releases like any security dependency. Insert sanitized nodes directly where the API allows it, as setHTML does, and never modify the sanitized string afterwards.

When content is sanitized on the server and stored, the same policy applies. The Python function below uses nh3, the Python binding for the Rust ammonia sanitizer; the older bleach library is no longer maintained and its repository was archived in June 2026. nh3's url_schemes setting still allows relative and credential-bearing URLs, so an attribute_filter enforces the absolute HTTPS rule. Install nh3 and run python3 sanitize_comment.py; it checks seven fixtures against policy version 4.

Choose a maintained sanitizer and keep it current
StackSanitizerNotes
BrowserElement.setHTML() (HTML Sanitizer API) or DOMPurifysetHTML was not in Safari on 11 October 2026, so keep DOMPurify as the fallback
Node.js serverDOMPurify with jsdom, or sanitize-htmlRun the same policy as the browser if both sanitize
JavaOWASP Java HTML SanitizerBuild the policy with HtmlPolicyBuilder; allow URL protocols explicitly
.NETHtmlSanitizerRestrict AllowedTags, AllowedAttributes and AllowedSchemes
Pythonnh3Replace bleach, which is no longer maintained
RubyLoofah, used by rails-html-sanitizerRails sanitize accepts tags: and attributes: lists
PHPHTML PurifierConfigure HTML.Allowed and URI.AllowedSchemes
GobluemondayStart from NewPolicy() rather than a broad preset
Fixedsanitize_comment.pyPython
"""sanitize_comment.py: server-side policy for comment HTML with nh3 (pip install nh3)."""
from urllib.parse import urlsplit

import nh3

TAGS = {"p", "strong", "em", "ul", "ol", "li", "a"}


def https_only(tag: str, attribute: str, value: str) -> str | None:
    # Keep an href only if it is an absolute https: URL without credentials.
    if (tag, attribute) != ("a", "href"):
        return value
    try:
        url = urlsplit(value)
    except ValueError:
        return None
    if url.scheme == "https" and url.hostname and url.username is None and url.password is None:
        return value
    return None


def sanitize_comment(html: str) -> str:
    # Run after Markdown rendering and link conversion; store the result with POLICY_VERSION.
    return nh3.clean(html, tags=TAGS, attributes={"a": {"href"}},
                     attribute_filter=https_only, link_rel="nofollow noopener")


POLICY_VERSION = 4

if __name__ == "__main__":
    rel = ' rel="nofollow noopener"'
    cases = {
        "<p>Hello <strong>Ada</strong></p>": "<p>Hello <strong>Ada</strong></p>",
        "<p id=policy>Hello<img src=x onerror=attack()></p>": "<p>Hello</p>",
        "<a href=javascript:attack()>Read</a>": f"<a{rel}>Read</a>",
        "<a href=https://docs.example.test/help onclick=attack()>Read</a>":
            f'<a href="https://docs.example.test/help"{rel}>Read</a>',
        "<a href=/relative>Read</a>": f"<a{rel}>Read</a>",
        "<a href=https://u:p@docs.example.test/>Read</a>": f"<a{rel}>Read</a>",
        "<svg><script>attack()</script></svg><p>after</p>": "<p>after</p>",
    }
    for given, expected in cases.items():
        assert sanitize_comment(given) == expected, (given, sanitize_comment(given))
    print(f"PASS: {len(cases)} comment fixtures match policy version {POLICY_VERSION}")

Constrain the application surrounding the rendered content

Even sanitized content can affect application behavior if code assumes DOM names or messages are trustworthy. Do not retrieve security-sensitive configuration from named elements created by comments. Use explicit references and typed data kept outside untrusted markup. Treat postMessage as a separate boundary: verify the exact sending origin, expected window source, message schema and allowed action before invoking a privileged operation, as /domains/appsec/browser-security/dom-xss-postmessage/ describes.

A message from a trusted origin may still come from the wrong frame or carry an unsupported operation. Passing the entire message directly to a native bridge, navigation function or account updater gives the sender a much broader capability than the feature needs. For the exercise, the preview frame may request resize with an integer height from 100 to 600 only. It cannot request export, arbitrary navigation or session access.

Trusted Types turn the remaining sinks into reviewed code paths. With require-trusted-types-for 'script' in the Content Security Policy, assigning a string to innerHTML, insertAdjacentHTML, document.write or a script URL throws, so every HTML insertion must go through a named policy or an API such as setHTML that sanitizes itself. The feature has been Baseline since February 2026, when Firefox 148 shipped it; Chrome has supported it since version 83 and Safari since 26. Roll it out with Content-Security-Policy-Report-Only first. A policy that returns its input unchanged only moves the problem, and CSP adds containment: it should never be the only reason a dangerous fixture fails.

Search the code for innerHTML, outerHTML, insertAdjacentHTML, document.write, srcdoc, dynamic script creation and framework escape hatches, and route approved HTML through one reviewed rendering component.

Verify retained formatting and removed capability

A regression should compare DOM structure, allowed link destinations and application effects. The ordinary strong text must remain strong; a malicious event attribute must not survive; a disallowed URL must not become an actionable link. Include malformed nesting and entity-encoded values because browser parsers normalize representations before use. Test the actual sanitizer and rendering combination in the supported browsers; a hand-written string comparison cannot prove how the browser will build the tree.

When debugging, identify the stage that introduced the dangerous representation. Save synthetic before/after fragments and policy version without retaining real private comments. A change that strips all formatting may stop exploitation but break the product contract; a change that allows every attribute to restore formatting reopens the boundary. Test every renderer that displays the record, including editor preview, stored display, exports, notifications and mobile views; sanitizer success in the preview says nothing about another renderer using the same record. The browser lab at /labs/output-rendering/ practices the text-rendering half of this guide: you render a model’s answer as text and link only an allowed URL. The lab at /labs/html-sanitizer/ practices the other half by having you configure a sanitizer for user comments.

Decide how stored content survives a policy change

Sanitizing on write creates a useful stored representation, but it also ties that representation to the sanitizer version and policy at that time. Suppose policy version 3 allowed an attribute later found to interact dangerously with application code. Rendering old version 3 output as permanently trusted bypasses the revised version 4. Keep enough provenance to re-sanitize or migrate old records, and avoid treating an unversioned boolean clean=true as a lifetime guarantee.

Sanitizing on read ensures current policy is applied but increases runtime work and can change old documents' appearance immediately after an update. A combined approach stores original content privately plus a versioned sanitized representation and refreshes the latter when policy changes. That has a retention tradeoff: keeping original untrusted content is useful for reprocessing, but may retain personal data and requires protection from accidental raw rendering.

Test the migration with the same explicit feature contract. H1 should retain its strong formatting under both versions; an attribute newly excluded in version 4 should disappear before it reaches the DOM. A failure to reprocess should produce a bounded fallback such as plain text or unavailable preview, not silently display the raw original. The policy version also belongs in any cache key that assumes the representation is already safe. This turns sanitizer maintenance into a controlled content migration rather than an undocumented hope that old output remains harmless.

Guided practice

Apply a supplied rich-text and preview-message policy to normalized candidate inputs.

Supplied scenario material
Plain text
Sanitizer contract: allow p,strong,em,ul,ol,li,a; only a.href; HTTPS absolute links without credentials. Disallowed elements and their subtrees are removed. Invalid href is removed but anchor text remains.
H1=<p>Hello <strong>Ada</strong></p>
H2=<p id=policy>Hello<img src=x onerror=attack()></p>
H3=<a href=javascript:attack()>Read</a>
H4=<a href=https://docs.example.test/help onclick=attack()>Read</a>
Preview policy: origin=https://preview.example.test; source=frameP; message={type:resize,height:integer 100..600}; no extra fields.
M1=(trusted origin,frameP,{type:resize,height:300})
M2=(trusted origin,frameQ,{type:resize,height:300})
M3=(trusted origin,frameP,{type:export,height:300})
M4=(trusted origin,frameP,{type:resize,height:601})
  1. 1. Preserve the ordinary document

    Apply the policy to H1 and describe the DOM.

    Expected result and reasoning

    One p contains text Hello and one strong element containing Ada. The feature retains its intended formatting; converting everything to plain text is a different contract.

  2. 2. Remove unsupported capabilities

    Apply the policy to H2, H3 and H4.

    Expected result and reasoning

    H2 becomes <p>Hello</p>; id and img are removed. H3 keeps the anchor text Read but has no href. H4 retains the HTTPS href and text Read but loses onclick. Allowed element names do not imply allowed attributes or schemes.

  3. 3. Find the ordering bug

    A post-sanitization linkifier adds raw user content as <a href=VALUE>VALUE</a>. Explain why the earlier verdict no longer covers the output.

    Expected result and reasoning

    The final markup differs from the sanitized representation and VALUE can alter attribute or element syntax. Move conversion before the sanitizer or construct the link using safe typed APIs and validated URLs.

  4. 4. Authorize preview messages

    Evaluate M1–M4, including the exact changed state.

    Expected result and reasoning

    M1 sets frame height to 300. M2 fails source identity, M3 fails the operation allowlist and M4 exceeds the upper bound. The latter three cause no resize and no other action, even though their origin is trusted.

Your deliverable: A transformed-fragment table and a message decision table with exact retained capabilities.

Paper exercise: the policy, fragments and messages are supplied. render-comment.mjs and sanitize_comment.py above apply the same policy to H1 to H4 in code.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Repair the code

HTML sanitization lab: Configure a sanitizer for user comments

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. OWASP DOM XSS prevention
  2. MDN: Content Security Policy
  3. OWASP XSS prevention
  4. DOMPurify
  5. MDN: Element.setHTML()
  6. MDN: Trusted Types API
  7. Mozilla Hacks: Goodbye innerHTML, hello setHTML (Firefox 148). 24 February 2026.
  8. GitHub advisory GHSA-gx9m-whjm-85jf: DOMPurify nesting-based mXSS
  9. nh3 documentation
  10. MDN: Window.postMessage()

Reference information

SDLC phase
Development
ASVS 5.0.0 requirements
v5.0.0-1.3.1 · v5.0.0-1.3.5 · v5.0.0-3.2.2 · v5.0.0-3.2.3 · v5.0.0-3.5.5

Download this guide as Markdown