critical

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:

  1. GHSA-cg2g-xh64-v4q7 (CVSS v4.0 9.2 Critical): When SAFE_FOR_TEMPLATES: true is paired with a DOM-returning mode (RETURN_DOM: true, RETURN_DOM_FRAGMENT: true, or IN_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().
  2. GHSA-mrv2-3pqr-j5jr (CVSS v4.0 7.6 High): An incomplete fix for GHSA-p98j-92pf-mc4p in 3.4.16 only neutralized a node when an IN_PLACE: true hook 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-only NodeIterator cursor, that subtree was never walked or stripped of armed event handlers (such as an already-loading <img onerror>).
  3. GHSA-c6gr-qvv4-5v3f (Low): When PARSER_MEDIA_TYPE: 'application/xhtml+xml' is set and transformCaseFunc preserves case, lcName === 'attributename' missed the camelCase SVG SMIL attributeName="href" attribute on <animate> / <set> elements.

Upgrade dompurify to 3.4.17 across frontend and SSR bundles.

Affected packages and projects

EcosystemPackageGHSASeverityAffected versionsFixed versionConfiguration / surface
npmdompurifyGHSA-cg2g-xh64-v4q7CVSS v4.0 9.2 Critical>= 3.4.4, <= 3.4.163.4.17SAFE_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)
npmdompurifyGHSA-mrv2-3pqr-j5jrCVSS v4.0 7.6 High<= 3.4.163.4.17IN_PLACE: true with custom hooks or custom-element reactions that detach or move ancestor/sibling nodes
npmdompurifyGHSA-c6gr-qvv4-5v3fLow<= 3.4.163.4.17PARSER_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:

  1. Detaching a parent or unvisited sibling: If a hook called currentNode.parentNode.remove() or currentNode.nextElementSibling.remove(), NodeIterator skipped the detached sibling subtree and DOMPurify.removed never recorded it, leaving its onerror / onload attributes armed.
  2. Moving an unvisited node behind the iterator: Because NodeIterator only 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

  1. Upgrade dompurify to 3.4.17 or later:
    npm install dompurify@^3.4.17
  2. Prefer string output before template compilation: If you must pass sanitized markup into a downstream template engine, avoid combining SAFE_FOR_TEMPLATES: true with pre-parsed DOM nodes or declarative shadow DOM until 3.4.17 is deployed everywhere.
  3. Pass detached or inert trees to IN_PLACE: As noted in the updated DOMPurify 3.4.17 README, avoid building IN_PLACE input inside a live, connected document where resource loads (<img onerror>) or custom-element lifecycle callbacks can fire before DOMPurify.sanitize() starts.

Timeline

  • 2026-10-12: Cure53 merges PR #1672 (a77a3e78b5) and publishes dompurify@3.4.17 to 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), and GHSA-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