-
-
Notifications
You must be signed in to change notification settings - Fork 860
Security Goals & Threat Model
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.
-
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
noscriptwith scripting disabled) differently from a browser, so test the exact DOM you deploy.
- The 3.x line dropped Microsoft Internet Explorer to be able to move forward and use modern platform features. The old
- 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.
- 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, underSAFE_FOR_XML(default-on), by a narrow attribute-value guard that rejects comment/CDATA closers and rawtext/RCDATA closing tags hidden inside attribute values. DisablingSAFE_FOR_XMLremoves 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 ondocument,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) andSANITIZE_NAMED_PROPS(rewrites userid/nameinto auser-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_JQUERYflag 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 fromObject.prototypewas a real default-config bypass (CVE-2026-41238, fixed in 3.4.0), andUSE_PROFILESarray pollution was another (GHSA-cj63-jhhr-wcxv). Treat "an attacker can polluteObject.prototype" as a realistic precondition. See also: Prototype Pollution. -
Integrate with Trusted Types: DOMPurify supports the Trusted Types API and can return a
TrustedHTMLobject (RETURN_TRUSTED_TYPE: true), so it can serve as the sanitizing policy behind a Trusted-Types CSP.
-
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 thestyleattribute are allowed by default (DOMPurify keeps CSS). DOMPurify does not stop CSS-based data exfiltration (attribute-selector + resource-URL tricks), nor the long-deadexpression()-style legacy-engine attacks. If you don't need CSS, drop it withFORBID_TAGS: ['style']andFORBID_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
defaultpolicy 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_TEMPLATESflag 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.
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) andiframe 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.
| 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. |
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. |
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.
These are safe by default; the danger appears only when an application opts in.
-
afterSanitize*hooks run after validation. Anything written inafterSanitizeElements/afterSanitizeAttributesis not re-checked - that is the defining purpose of an "after" hook. A hook that doesnode.setAttribute('href', 'javascript:…')there re-introduces a payload sanitization already removed. Put attacker-influenced values throughuponSanitize*hooks instead (they run before validation, so their output is re-checked), or validate them yourself. -
Prefer
data.keepAttroverdata.allowedAttributes[name] = true. Both can keep an attribute from inside a hook, but writing toallowedAttributesis the persistent-shaped tool and has repeatedly been the source of cross-call / cross-element leaks.keepAttr(andforceKeepAttr) are per-element and cannot leak. -
ALLOWED_URI_REGEXPruns 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. AftersetConfig(), per-call options passed tosanitize()are ignored by design - includingTRUSTED_TYPES_POLICY: nulland a per-callFORBID_ATTR. UseclearConfig()to reset (it also drops any caller-supplied Trusted Types policy), or set a new persistent config.
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 forScript, 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.
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).