Skip to content

Security Goals & Threat Model

Cure53 edited this page Jul 3, 2026 · 25 revisions

This page explains what DOMPurify is meant for, what it protects against, what it cannot (yet) cover, and which tags and attributes you should think very hard about before adding to the allow-list.

Last modernized: 2026. Earlier dated annotations (2015-2023) have been folded in or removed where the underlying tech is obsolete. Current line is 3.4.x; 2.x is in legacy maintenance.

Overall Goals

  • Be simple: Take a string (or a DOM node / document) as input, remove anything in there that can cause XSS, and return the sanitized result for safe usage. By default you get a string back; configuration flags (RETURN_DOM, RETURN_DOM_FRAGMENT, IN_PLACE, RETURN_TRUSTED_TYPE) let you work with DOM nodes or Trusted Types instead.
  • Be tolerant: Many XSS filters succeed at preventing XSS but remove far too much harmless markup. DOMPurify's goal is to allow as much as possible and slice out only what is capable of executing script. The emphasis today is JavaScript; the old plugin-era execution vectors (Flash, VBScript, JScript, Java applets) are gone from the platform but are still stripped for good measure.
  • Be compatible: DOMPurify aims to be as compatible as possible while still meeting the first two goals. It is compatible with all modern browsers (Chrome/Edge, Firefox, Safari, and other current engines).
    • The 3.x line dropped Microsoft Internet Explorer to be able to move forward and use modern platform features. The old toStaticHTML() fallback and all IE document-mode handling are gone.
    • The 2.x line still exists for legacy needs but receives maintenance only - do not expect new features there.
    • DOMPurify also runs server-side on a DOM implementation such as jsdom. Note that the DOM you run on becomes part of your trusted computing base: a server-side DOM can parse some constructs (for example noscript with scripting disabled) differently from a browser, so test the exact DOM you deploy.
  • Be fast: DOMPurify aims for good performance, but performance will never be prioritized over security or compatibility.
  • Be open and logical: DOMPurify carries as few lines of browser-specific witch-craft as possible. Pull requests are very welcome but are always reviewed thoroughly and may be rejected.

Security Goals

  • Prevent XSS attacks: DOMPurify is built to prevent XSS whenever you deal with user-controlled HTML in the browser (or on a server-side DOM). Great server-side filters exist too, but DOMPurify is meant for when those are not reliable, not available, or otherwise not an option. If you find a way to sneak in script execution, that's a bug and a bypass - please report it privately through the project's security advisories and we will fix it.
  • Resist mutation XSS (mXSS): A large share of modern bypasses are not "forgot to remove <script>" but parse asymmetry - markup that is inert in the tree DOMPurify inspects but becomes active after the output is serialized and reparsed. DOMPurify defends this class by checking every node against its parent's namespace (HTML/SVG/MathML integrity) and, under SAFE_FOR_XML (default-on), by a narrow attribute-value guard that rejects comment/CDATA closers and rawtext/RCDATA closing tags hidden inside attribute values. Disabling SAFE_FOR_XML removes that protection - only do so for tightly constrained HTML-only use.
  • Prevent DOM Clobbering attacks: DOMPurify aims to prevent DOM Clobbering - markup such as <img src=x name=getElementById> or <form><input name=attributes> that shadows properties/methods on document, window, or form objects. Internals read security-critical DOM members through cached, realm-safe accessors rather than trusting named properties on live instances. Controls: SANITIZE_DOM (default-on) and SANITIZE_NAMED_PROPS (rewrites user id/name into a user-content- form). If you find a working clobbering vector, report it. See also: DOM Clobbering strikes back (PortSwigger), In the DOM, no one will hear you scream.
  • Produce output safe for common re-insertion sinks (including jQuery): DOMPurify is aware of the "smart" HTML parsing some libraries do. The historical SAFE_FOR_JQUERY flag has been removed - this is handled by default now, so output is safe for re-insertion via jQuery's .html() and similar. The caveat is unchanged: sanitize before the value reaches the library, and never post-process the sanitized string before insertion.
  • Prevent structural damage: The HTML returned by DOMPurify is sane and does not drop closing tags or leak structure in ways that could break your page or exfiltrate data via dangling markup. If you find a way to break this, it's a bug. See also: PostXSS / dangling markup.
  • Be safe from Prototype Pollution: DOMPurify should behave correctly even if the surrounding application is already affected by prototype pollution. Internal config is created prototype-free, incoming config is cloned before use, and presence is tested with own-property checks rather than inherited reads. This is not hypothetical - a || {} fallback that inherited from Object.prototype was a real default-config bypass (CVE-2026-41238, fixed in 3.4.0), and USE_PROFILES array pollution was another (GHSA-cj63-jhhr-wcxv). Treat "an attacker can pollute Object.prototype" as a realistic precondition. See also: Prototype Pollution.
  • Integrate with Trusted Types: DOMPurify supports the Trusted Types API and can return a TrustedHTML object (RETURN_TRUSTED_TYPE: true), so it can serve as the sanitizing policy behind a Trusted-Types CSP.

Non-Goals

  • Markup context flipping: DOMPurify will NOT protect you against feeding HTML-sanitized output into a different markup context. If you sanitize HTML and then drop it into SVG, MathML, an XML document, an attribute value, or a rawtext element (<style>, <textarea>, <noscript>…), strange and exploitable things can happen. Sanitize for the exact sink you use, and keep HTML in HTML, SVG in SVG.
  • CSS-based attacks: DOMPurify is not a CSS sanitizer. Both the <style> element and the style attribute are allowed by default (DOMPurify keeps CSS). DOMPurify does not stop CSS-based data exfiltration (attribute-selector + resource-URL tricks), nor the long-dead expression()-style legacy-engine attacks. If you don't need CSS, drop it with FORBID_TAGS: ['style'] and FORBID_ATTR: ['style']; if you keep it, you own the CSS risk. See also: H5SC CSS Attacks.
  • HTTP leaks: DOMPurify will NOT reliably stop HTML that requests external resources (tracking pixels, prefetch, etc.). There are too many ways to do it. A demo hook that attempts to proxy/neuter such leaks is provided as a starting point: hooks-proxy-demo.html.
  • Doing anything passively: DOMPurify will NOT protect you just by being present. You must actually call it on a string or node and use its return value. Including the script and never invoking it does nothing. If you specifically want passive, document-wide protection - every HTML sink sanitized automatically, including third-party and legacy code you never route through DOMPurify by hand - that is the job of DOMFortify, a separate Cure53 project that installs a Trusted Types default policy backed by DOMPurify and refuses script sinks outright. It is the enforcement layer DOMPurify deliberately is not.
  • XSS via "script gadget" framework features: DOMPurify will NOT save you from client-side frameworks that re-enable script execution from otherwise-inert attributes (the classic AngularJS case, and others since). AngularJS itself is end-of-life, but the script-gadget class persists across templating/hydration frameworks. The SAFE_FOR_TEMPLATES flag aggressively scrubs {{ … }}, ${ … }, and <% … %> expressions for the case where sanitized HTML is fed into a client-side template engine - but treat it as a last resort (the safer design is to not pass user HTML through a second interpreter at all), and note its own history of edge bugs (CVE-2025-26791). See also: Script Gadgets (BHUSA), JSMVCOMFG.

Dangerous tags and attributes: think twice before allow-listing

DOMPurify's secure defaults are the product. Most application-specific bypasses are born the moment someone widens the allow-list (ADD_TAGS, ADD_ATTR, ADD_URI_SAFE_ATTR, ALLOWED_TAGS/ALLOWED_ATTR, CUSTOM_ELEMENT_HANDLING, …) to admit something on the list below.

It helps to separate two kinds of danger, because they deserve different caution:

  • Honest foot-guns - dangerous in an obvious, local, developer-controllable way. An allowed <iframe> is risky and you know it; DOMPurify still sanitizes the element and its attributes, and the contract holds ("I accepted this risk knowingly").
  • Hidden landmines - dangerous in a non-obvious or unmitigatable way: the hazard lives somewhere the developer can't see or fix, often after the sanitizer has finished. <selectedcontent> (the engine re-injects content post-walk) and iframe srcdoc (the value is a whole un-sanitized document) are landmines, not foot-guns.

A guiding principle: the allow-list governs what is kept; it does not, and should not, govern whether kept output can execute. When allowing a tag means DOMPurify can no longer guarantee the kept element is free of live handlers no matter how well it does its job, prefer to keep it forbidden - or, if you support it, gate it behind a conspicuously-named unsafe flag rather than the generic ADD_TAGS path.

Tags

Tag Default Why it's dangerous If you must allow it
selectedcontent forbidden Landmine. The customizable-select engine re-clones the selected <option>'s subtree into it, synchronously and after DOMPurify has walked the tree, so an allowed one can carry handlers the walk already cleaned. Forbidden by default since 3.4.5. Don't. If you genuinely need it, empty its children post-walk and re-verify on every engine release. Strongly consider leaving it forbidden.
iframe forbidden Embedding/navigation primitive; src=javascript: historically, and srcdoc opens a full nested document (see below). Sandbox attributes are easy to get wrong. Pin src to an allow-listed origin via ALLOWED_URI_REGEXP; do not also allow srcdoc.
object, embed forbidden Load and render arbitrary external content; data=/src= navigation; legacy plugin surface. Avoid. If unavoidable, constrain data/type tightly.
base forbidden A single <base href> rewrites every relative URL in the document - resource loads, link targets, form actions - turning benign relative URLs into attacker-controlled ones. <base target> is similar for navigation. Almost never allow. There is rarely a safe reason in sanitized content.
form allowed DOM-clobbering vector (named children shadow form/document properties); action/formaction are navigation sinks; a clobbered form can be reached via external form= association. Keep SANITIZE_DOM on; consider SANITIZE_NAMED_PROPS; scrutinize action/formaction.
meta forbidden http-equiv="refresh" redirects; charset switching can change how later bytes decode (an mXSS lever). Avoid. If you need one specific meta, hard-restrict http-equiv/charset.
link forbidden rel=stylesheet (CSS injection), rel=preload/prefetch/dns-prefetch (leaks, request smuggling-ish), imagesrcset. Avoid; if needed, restrict rel and the URL.
style (element) allowed Kept by default, and DOMPurify does not sanitize the CSS inside it, so CSS-based exfiltration (attribute selectors + resource URLs) and </style> rawtext breakout are your risk, not DOMPurify's. (A top-level <style> is parsed into <head>, so it appears in output only when nested in body content or under WHOLE_DOCUMENT.) FORBID_TAGS: ['style'] if you don't need CSS blocks.
noscript forbidden Parses differently with scripting enabled vs disabled (browser vs typical server-side DOM); a classic mXSS/parse-asymmetry footgun. Avoid, especially server-side.
template allowed Kept by default; its content is an inert DocumentFragment parsed and handled differently from live DOM, and is rarely needed in sanitized user content. (A top-level <template> also lands in <head>.) With declarative partial updates (Chrome 150+), a <template for=…> becomes a post-sanitize teleport directive — see engine-deferred mutation on the Attack Classes page. DOMPurify strips the for/patchsrc wiring (below), so a kept <template> cannot act as a patch target under default config. FORBID_TAGS: ['template'] unless you specifically need it; keep SAFE_FOR_XML on.
math, svg allowed Foreign content: namespace confusion and integration points (foreignObject, annotation-xml) are where much mXSS lives. Allowed because they're legitimately useful - the danger is mixing namespaces. Fine to keep, but never disable the namespace check; HTML-only apps can use USE_PROFILES: { html: true }.
custom elements (<x-…>) forbidden Lifecycle reactions, framework hydration, and is= customized built-ins can add behavior after sanitization. A permissive CUSTOM_ELEMENT_HANDLING (e.g. tagNameCheck: /.*/) is a broad escape hatch. Use narrow, tag-specific tagNameCheck/attributeNameCheck; keep allowCustomizedBuiltInElements: false.

Attributes

Unlike most of the tags above, several of these attributes are allowed by default - so the danger is usually not "you added them" but "you turned off the protection DOMPurify already applies" (URI validation, clobbering controls), or "your own code reads them as a sink DOMPurify never validated." The Default column says whether DOMPurify keeps the attribute out of the box.

Attribute(s) Default Why it's dangerous Guidance
on* (every event handler) forbidden Direct script execution. Never allow-list these.
href, src, action, cite, poster, background, srcset, xlink:href allowed (URI-validated) URL sinks (javascript:, data:, ...), but DOMPurify keeps them and checks the value against ALLOWED_URI_REGEXP. The risk is removing that check, not the attribute itself. Don't weaken the URL check: avoid ALLOW_UNKNOWN_PROTOCOLS, a loosened ALLOWED_URI_REGEXP, or adding these to ADD_URI_SAFE_ATTR.
formaction, data, ping, imagesrcset forbidden URL / navigation sinks that are not in the default list. If you add them, make sure they still pass URI validation - never via ADD_URI_SAFE_ATTR.
srcdoc (on iframe) forbidden Landmine - "people think it's safe because it's just an attribute." Its value is a complete HTML document parsed in a nested context, and DOMPurify does not recurse into it. With iframe+srcdoc allowed, <iframe srcdoc="<img src=x onerror=alert(1)>"> executes. (SAFE_FOR_XML only incidentally catches srcdoc values with rawtext closers like </script>; onerror/onload payloads pass.) Don't add it. If you truly must, sanitize the inner document yourself, separately, before composing it.
style allowed DOMPurify keeps inline CSS but does not sanitize it - CSS-based exfiltration (attribute-selector + resource URLs) is out of scope, as is legacy expression(). FORBID_ATTR: ['style'] if you don't need inline CSS.
id, name allowed DOM clobbering; name on form children clobbers form properties. Enable SANITIZE_NAMED_PROPS (prefixes them user-content-) and keep SANITIZE_DOM on.
custom data-* allowed DOMPurify can't know your app later reads data-target as a URL or HTML - if it does, you've made a sink DOMPurify never validated. Don't read user-controlled data-* as URLs/HTML without your own validation; ALLOW_DATA_ATTR: false drops them all.
xmlns (namespace declarations) allowed Needed for SVG/MathML; misuse is foreign-content / namespace confusion, which the per-node namespace check exists to contain. Leave the namespace check on; don't set an unusual NAMESPACE / PARSER_MEDIA_TYPE without reason.
is forbidden Turns a benign built-in into a customized built-in element with attached behavior. Avoid unless your CUSTOM_ELEMENT_HANDLING policy is narrow.
for allowed on <label>/<output> only Legit on <label>/<output>; on any other element it is the declarative partial updates patch-target wiring that teleports/removes DOM ranges after sanitization. DOMPurify keeps it on <label>/<output> and drops it elsewhere, gated on SAFE_FOR_XML. Don't re-add it via an after-hook; don't disable SAFE_FOR_XML.
patchsrc forbidden Declarative-partial-updates remote-fetch directive: pulls remote markup and applies it as a patch (script-loading for CSP). Dropped outright, gated on SAFE_FOR_XML. Don't add it via ADD_ATTR; don't disable SAFE_FOR_XML.
autofocus (with a handler / tabindex) forbidden Auto-triggers focus, which can fire an onfocus handler with no user interaction - an execution amplifier (this shape appeared in the CVE-2026-41238 default-config bypass). Don't add it alongside handler attributes.
target forbidden If added, target=_blank enables reverse-tabnabbing via window.opener; <base target> redirects navigation. If you add it, enforce rel="noopener" semantics downstream.
dirname forbidden Smuggles extra form-field values on submit. Avoid on form controls.

Config flags that widen this surface

Require a reason for each of these before accepting a non-default config, and re-read the dedicated foot-guns checklist on the Attack Classes page: ADD_TAGS, ADD_ATTR, ADD_URI_SAFE_ATTR, ADD_DATA_URI_TAGS, ALLOW_UNKNOWN_PROTOCOLS, a loosened ALLOWED_URI_REGEXP, CUSTOM_ELEMENT_HANDLING, SAFE_FOR_XML: false, SANITIZE_DOM: false, SANITIZE_NAMED_PROPS: false, WHOLE_DOCUMENT: true. FORBID_TAGS/FORBID_ATTR always win over the ADD_* equivalents - rely on that when in doubt.

Rule of thumb: if allowing a tag or attribute means the engine, a second parser, or your own later code - not DOMPurify - decides whether the output executes, that item belongs on this list. Prefer secure defaults; widen the allow-list narrowly, deliberately, and with tests against the actual sink you use.

Hooks and persistent config: subtle foot-guns

These are safe by default; the danger appears only when an application opts in.

  • afterSanitize* hooks run after validation. Anything written in afterSanitizeElements / afterSanitizeAttributes is not re-checked - that is the defining purpose of an "after" hook. A hook that does node.setAttribute('href', 'javascript:…') there re-introduces a payload sanitization already removed. Put attacker-influenced values through uponSanitize* hooks instead (they run before validation, so their output is re-checked), or validate them yourself.
  • Prefer data.keepAttr over data.allowedAttributes[name] = true. Both can keep an attribute from inside a hook, but writing to allowedAttributes is the persistent-shaped tool and has repeatedly been the source of cross-call / cross-element leaks. keepAttr (and forceKeepAttr) are per-element and cannot leak.
  • ALLOWED_URI_REGEXP runs against attacker-controlled values. Beyond the obvious "don't make it permissive": a catastrophically-backtracking ("ReDoS") pattern becomes an attacker-triggerable denial of service, because the attacker controls the string the regex is tested against. Supply only linear-time patterns; DOMPurify cannot detect a pathological one for you.
  • setConfig() makes per-call config inert. After setConfig(), per-call options passed to sanitize() are ignored by design - including TRUSTED_TYPES_POLICY: null and a per-call FORBID_ATTR. Use clearConfig() to reset (it also drops any caller-supplied Trusted Types policy), or set a new persistent config.

Safe and simple: recipes that are safe as shown

After all the warnings, here is the reassuring part: for the common cases you do not need any of the dangerous knobs. The recipes below are safe as shown - meaning the safety depends on two things together: the sanitize call and the sink. Each example shows both. Keep HTML going into an HTML sink, insert the result without post-processing it, and don't change the sink contract afterward (see Non-Goals → markup context flipping).

1. The everyday default - rich text from users. Zero config. Secure defaults are the product; this is what most apps want.

const clean = DOMPurify.sanitize(dirty);
element.innerHTML = clean;        // HTML sink - matches what we sanitized for

Script, event handlers, javascript: URLs and the like are gone, while ordinary formatting (<b>, <p>, <a>, <img src>, …) survives.

2. The super-safe bet - HTML only, no foreign content. If your content is plain rich text (CMS bodies, rendered Markdown, email previews), restrict to the HTML profile so SVG and MathML - the namespace-confusion surface - are dropped entirely.

const clean = DOMPurify.sanitize(dirty, { USE_PROFILES: { html: true } });
element.innerHTML = clean;

Same as the default, but a <svg>/<math> subtree is removed rather than kept. Smaller surface, nothing to reason about across namespaces.

3. The tight allow-list - comments / Markdown output. A common pattern for comment systems or Markdown-derived HTML is to permit only a handful of formatting tags and a couple of attributes.

const clean = DOMPurify.sanitize(dirty, {
  ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'ol', 'li',
                 'code', 'pre', 'br', 'blockquote'],
  ALLOWED_ATTR: ['href', 'title']
});
element.innerHTML = clean;

This is safe because it only narrows - it never uses ADD_*. URL attributes are still validated, so <a href="https://example.com"> is kept while <a href="javascript:…"> keeps the link text but loses the dangerous href.

4. Skip the round-trip - return a fragment and append. Returning a DOM fragment instead of a string lets you insert nodes directly, which avoids the serialize → reparse step entirely (and with it a whole class of mutation-XSS concerns). Common in component code.

const fragment = DOMPurify.sanitize(dirty, { RETURN_DOM_FRAGMENT: true });
element.replaceChildren(fragment);   // or element.appendChild(fragment)

You get a DocumentFragment of already-clean nodes; append it as-is and don't serialize it back to a string in between.

The single thread through all four: sanitize for the sink you actually use, insert without post-processing, and don't widen the allow-list unless you have a reason. That's the safe, simple 90% - the dangerous-tags list above is the other 10% you opt into deliberately.


Will DOMPurify help in my specific case?

Possibly! When in doubt, ask - better to ask and be sure than to guess and be vulnerable. Use the issue tracker for questions and configuration help, and the project's security advisories for anything that looks like an actual bypass (please report those privately).