CVE
Not assigned
CWE
CWE-79, CWE-94, CWE-178
Affected Surface
- npm package `dompurify` versions `>= 3.4.4, <= 3.4.16` (`GHSA-cg2g-xh64-v4q7`) and `<= 3.4.16` (`GHSA-mrv2-3pqr-j5jr` and `GHSA-c6gr-qvv4-5v3f`), fixed in `3.4.17`
- Web applications and SSR/browser pipelines using `DOMPurify.sanitize()` with `SAFE_FOR_TEMPLATES: true` and a DOM-returning mode (`RETURN_DOM`, `RETURN_DOM_FRAGMENT`, or `IN_PLACE`) on DOM trees or declarative shadow DOM inputs
- Applications using `IN_PLACE: true` with custom DOMPurify hooks that detach or relocate ancestor or sibling nodes during sanitization, or running with `PARSER_MEDIA_TYPE: 'application/xhtml+xml'`
TL;DR
On 22 October 2026, the GitHub Advisory Database published three coordinated security advisories for Cure53’s DOMPurify (dompurify on npm), all patched in 3.4.17:
GHSA-cg2g-xh64-v4q7(CVSS v4.0 9.2 Critical): WhenSAFE_FOR_TEMPLATES: trueis paired with a DOM-returning mode (RETURN_DOM: true,RETURN_DOM_FRAGMENT: true, orIN_PLACE: true), template expressions ({{...}},${...},<%...%>) split across text nodes by a disallowed element (such as$<object>{alert(1)}) survive inside attached shadow roots. The post-walk safety pass_scrubTemplateExpressions()merged and re-scrubbed<template>.content, but never ran on shadow roots sanitized by_sanitizeShadowDOM().GHSA-mrv2-3pqr-j5jr(CVSS v4.0 7.6 High): An incomplete fix forGHSA-p98j-92pf-mc4pin3.4.16only neutralized a node when anIN_PLACE: truehook detached the exact node the hook was called for. If a hook detached a parent or an unvisited sibling, or moved an unvisited node behind the forward-onlyNodeIteratorcursor, that subtree was never walked or stripped of armed event handlers (such as an already-loading<img onerror>).GHSA-c6gr-qvv4-5v3f(Low): WhenPARSER_MEDIA_TYPE: 'application/xhtml+xml'is set andtransformCaseFuncpreserves case,lcName === 'attributename'missed the camelCase SVG SMILattributeName="href"attribute on<animate>/<set>elements.
Upgrade dompurify to 3.4.17 across frontend and SSR bundles.
Affected packages and projects
| Ecosystem | Package | GHSA | Severity | Affected versions | Fixed version | Configuration / surface |
|---|---|---|---|---|---|---|
| npm | dompurify | GHSA-cg2g-xh64-v4q7 | CVSS v4.0 9.2 Critical | >= 3.4.4, <= 3.4.16 | 3.4.17 | SAFE_FOR_TEMPLATES: true with RETURN_DOM, RETURN_DOM_FRAGMENT, or IN_PLACE on DOM nodes carrying attached shadow roots (including declarative <template shadowrootmode="open"> parsed via parseHTMLUnsafe / setHTMLUnsafe) |
| npm | dompurify | GHSA-mrv2-3pqr-j5jr | CVSS v4.0 7.6 High | <= 3.4.16 | 3.4.17 | IN_PLACE: true with custom hooks or custom-element reactions that detach or move ancestor/sibling nodes |
| npm | dompurify | GHSA-c6gr-qvv4-5v3f | Low | <= 3.4.16 | 3.4.17 | PARSER_MEDIA_TYPE: 'application/xhtml+xml' with SVG SMIL animation tags (<animate>, <set>) allowlisted |
Technical analysis
1. GHSA-cg2g-xh64-v4q7: split template expressions survive inside attached shadow roots
When SAFE_FOR_TEMPLATES: true is enabled, DOMPurify strips template delimiters ({{...}}, ${...}, <%...%>) from text nodes so the sanitized output can be fed safely into template evaluators (such as Angular, Vue, Handlebars, Underscore/EJS, or JavaScript template literals).
During the main NodeIterator walk, _sanitizeElements() inspects each text node one at a time. If an attacker splits an expression across two sibling text nodes separated by a disallowed element, as in this markup:
<div>host<template shadowrootmode="open">$<object></object>{alert(1)}</template></div>
Neither "$" nor "{alert(1)}" matches the template-expression regex on its own. Once _sanitizeElements() removes <object>, the two text nodes become adjacent siblings whose combined .textContent or serialized markup is ${alert(1)}.
For the light DOM and <template>.content, DOMPurify 3.4.4 and 3.4.8 added a post-walk safety pass _scrubTemplateExpressions(node) that calls node.normalize() (merging adjacent text nodes) and walks NodeFilter.SHOW_TEXT a second time. However, NodeIterator never crosses shadow boundaries and node.normalize() does not merge text nodes inside an attached ShadowRoot. When _sanitizeAttachedShadowRoots() handed an attached shadow root to _sanitizeShadowDOM(fragment), _sanitizeShadowDOM() finished its loop and fired afterSanitizeShadowDOM without ever normalizing or re-scrubbing the shadow root’s character data.
In commit a77a3e78b5 (PR #1672), Cure53 splits out _scrubOwnCharacterData() and runs it at the end of _sanitizeShadowDOM() whenever SAFE_FOR_TEMPLATES is active:
--- a/src/purify.ts
+++ b/src/purify.ts
@@ -2751,6 +2930,10 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
+ if (SAFE_FOR_TEMPLATES) {
+ _scrubOwnCharacterData(fragment as unknown as Element);
+ }
+
/* Execute a hook if present */
_executeHooks(hooks.afterSanitizeShadowDOM, fragment, null);
};
Because _sanitizeShadowDOM() already recurses into nested <template>.content fragments and nested shadow roots in post-order, calling the non-recursive _scrubOwnCharacterData() at each level scrubs every shadow root and template fragment once without introducing quadratic re-traversal on deeply nested trees.
2. GHSA-mrv2-3pqr-j5jr: IN_PLACE hooks that detach or move other nodes leave armed subtrees behind
In IN_PLACE: true mode, DOMPurify mutates a live DOM tree in place rather than cloning it into an inert document. If the caller built that live tree before calling DOMPurify.sanitize(root, { IN_PLACE: true }), any <img src=x onerror=...> in the tree has already started its network request; even if the <img> is detached from the tree during sanitization, its queued error or load event will still fire in the page’s window context after sanitize() returns unless its on* attributes are explicitly stripped.
DOMPurify 3.4.16 (GHSA-p98j-92pf-mc4p) added _handleHookDetachedNode(currentNode, root) to neutralize currentNode if a hook called currentNode.remove(). That left two gaps:
- Detaching a parent or unvisited sibling: If a hook called
currentNode.parentNode.remove()orcurrentNode.nextElementSibling.remove(),NodeIteratorskipped the detached sibling subtree andDOMPurify.removednever recorded it, leaving itsonerror/onloadattributes armed. - Moving an unvisited node behind the iterator: Because
NodeIteratoronly moves forward in document order, a hook or custom-element reaction that moved a later node into an already-visited ancestor caused the walker to skip that node while leaving it attached to the returned tree.
Commit a77a3e78b5 snapshots all live nodes before the walk (inPlaceNodes = _collectLiveNodes(dirty)), tracks every node visited by _sanitizeElements() in IN_PLACE_VISITED, runs up to MAX_UNVISITED_ROUNDS = 8 passes of _sanitizeUnvisitedNodes() to catch relocated nodes, and calls _neutralizeEscapedElements(root, inPlaceNodes) at teardown so any original element that left the live tree during the call has its disallowed attributes stripped:
const _neutralizeEscapedElements = function (
root: Node,
before: Node[]
): void {
const reachable = setCreate();
arrayForEach(_collectLiveNodes(root), (node: Node) => {
if (_readNodeType(node) === NODE_TYPE.element) {
setAdd(reachable, node);
}
});
arrayForEach(before, (node: Node) => {
if (
_readNodeType(node) === NODE_TYPE.element &&
!setHas(reachable, node)
) {
_stripDisallowedAttributes(node as Element);
}
});
};
3. GHSA-c6gr-qvv4-5v3f: XHTML case-sensitive attributeName check in _sanitizeAttributes()
To prevent SVG SMIL animations (<animate>, <set>) from animating an anchor’s href or xlink:href to a javascript: URL when developers explicitly add SMIL tags to ADD_TAGS, DOMPurify rejects any attribute where name === 'attributename' and the value contains 'href'.
However, when PARSER_MEDIA_TYPE: 'application/xhtml+xml' is configured, transformCaseFunc preserves attribute case instead of lowercasing it, so lcName remained 'attributeName' and never matched 'attributename'. Commit a77a3e78b5 moves the check into _isUnsafeAttributeValue() (shared with DOMPurify.isValidAttribute()) and compares stringToLowerCase(name) === 'attributename' unconditionally.
Detection and remediation
1. Audit installed dompurify versions and configuration flags
Search your lockfiles and source code for dompurify versions below 3.4.17 and check whether your application uses SAFE_FOR_TEMPLATES, IN_PLACE, RETURN_DOM, or PARSER_MEDIA_TYPE:
# Check installed dompurify version
npm ls dompurify 2>/dev/null || true
# Search lockfiles and package manifests
rg -n '"dompurify"\s*:\s*"' package.json package-lock.json pnpm-lock.yaml yarn.lock 2>/dev/null
# Find DOMPurify configuration call sites using the affected options
rg -n 'SAFE_FOR_TEMPLATES|IN_PLACE|RETURN_DOM|PARSER_MEDIA_TYPE|parseHTMLUnsafe|setHTMLUnsafe' \
--glob '*.{js,jsx,ts,tsx,mjs,cjs,vue,svelte}' . 2>/dev/null
2. Upgrade to dompurify@3.4.17
- Upgrade
dompurifyto3.4.17or later:npm install dompurify@^3.4.17 - Prefer string output before template compilation: If you must pass sanitized markup into a downstream template engine, avoid combining
SAFE_FOR_TEMPLATES: truewith pre-parsed DOM nodes or declarative shadow DOM until3.4.17is deployed everywhere. - Pass detached or inert trees to
IN_PLACE: As noted in the updated DOMPurify3.4.17README, avoid buildingIN_PLACEinput inside a live, connected document where resource loads (<img onerror>) or custom-element lifecycle callbacks can fire beforeDOMPurify.sanitize()starts.
Timeline
- 2026-10-12: Cure53 merges PR
#1672(a77a3e78b5) and publishesdompurify@3.4.17to npm. - 2026-10-22 (16:24 to 16:25 UTC): GitHub publishes
GHSA-cg2g-xh64-v4q7(Critical, CVSS v4.0 9.2),GHSA-mrv2-3pqr-j5jr(High, CVSS v4.0 7.6), andGHSA-c6gr-qvv4-5v3f(Low) to the global GitHub Advisory Database.
From research to remediation
Check whether this pattern exists in your codebase
Turn this research into a remediation workflow. Scan dependencies and package manifests for similar supply-chain risk, then prioritize fixes with reachability context.
References
- DOMPurify advisory GHSA-cg2g-xh64-v4q7 (SAFE_FOR_TEMPLATES shadow root bypass)
- DOMPurify advisory GHSA-mrv2-3pqr-j5jr (IN_PLACE hook detachment bypass)
- DOMPurify advisory GHSA-c6gr-qvv4-5v3f (XHTML SMIL attributeName=href bypass)
- cure53/DOMPurify PR #1672 and commit a77a3e78b5 (release 3.4.17)
- DOMPurify 3.4.17 release notes
- Junglewise: DOMPurify SAFE_FOR_TEMPLATES bypass in shadow root content