GHSA-6688-9rhm-gjv2: XSS

Published Oct 5, 2026
·
Updated

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, { INPLACE: true }) on a Node input; SAFEFORXML at its default (true)

Summary

The 3.4.9 fix for the INPLACE detached-root class added two protections on the INPLACE 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 INPLACE 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 INPLACE returns an empty result: the only difference is the INPLACE 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, { INPLACE: 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 INPLACE: RETURNDOM: true → <body></body>; RETURNDOMFRAGMENT: true → 0 children — the payload is fully sanitized away. The only difference is the INPLACE return path.

Contrast on the removal trigger: SAFEFORXML: 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 LITERALTEXTCLOSE probe (line 385) alongside the ELEMENTMARKUPPROBE (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 INPLACE 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 INPLACE family: an application that sanitizes a Node in INPLACE 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 INPLACE 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 INPLACE 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 LITERALTEXTELEMENTNAMES (style, script, xmp, iframe, noembed, noframes, plaintext, noscript) — by rewriting textContent to a defanged form, matching the probe coverage of isUnsafeNode/LITERALTEXTCLOSE.

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): INPLACE 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-INPLACE) serialize path — different mechanism; INPLACE is not involved. - The 3.4.9 release notes credit @mozfreedyb for the INPLACE 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 INPLACE are explicitly warned against by upstream's own source comment; this report concerns the serialize-and-reinsert consumption pattern that the INPLACE mode exists to serve.

Affected Software

1 affected componentFixes available
npm/dompurify<=3.4.15
3.4.16

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade npm/dompurify to a version that resolves this vulnerability.

    Fixed in 3.4.16
  2. Configuration

    At the IN_PLACE exit, check whether the returned root was recorded during sanitization; if so, throw a TypeError stating "a node selected for removal could not be safely returned; refusing to sanitize in place" or return null.

    DOMPurify IN_PLACE root return handling = fail closed when DOMPurify.removed.some(e => e.element === root) is true
  3. Configuration

    Extend _neutralizeSubtree to rewrite the textContent of rawtext descendants in LITERAL_TEXT_ELEMENT_NAMES: style, script, xmp, iframe, noembed, noframes, plaintext, and noscript.

    DOMPurify _neutralizeSubtree rawtext text content = defanged
  4. Operational

    Add a regression test asserting that a force-removed rawtext root does not return and that its textContent contains no /<[\/\w!]/ match.

Event History

Oct 5, 2026
Advisory Published
via GitHub·11:43 PM
Data Sourced
via GitHub·11:43 PM
DescriptionWeaknessAffected Software

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203