GHSA-6688-9RHM-GJV2

Vulnerability from github – Published: 2026-10-05 23:43 – Updated: 2026-10-05 23:43
VLAI
Summary
DOMPurify: IN_PLACE returns a force-removed rawtext root whose text carries attacker markup — pure HTML reparse executes
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

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.

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 3.4.15"
      },
      "package": {
        "ecosystem": "npm",
        "name": "dompurify"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "3.4.16"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-79"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-10-05T23:43:53Z",
    "nvd_published_at": null,
    "severity": "LOW"
  },
  "details": "## Environment\n\n- dompurify 3.4.15 (current npm release); reproduced independently on jsdom 30.0.1 and 29.1.1 (Node.js 20.x / 26.x)\n- Config: `DOMPurify.sanitize(node, { IN_PLACE: true })` on a Node input; `SAFE_FOR_XML` at its default (`true`)\n\n## Summary\n\nThe 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.\n\nBoth miss the rawtext **text-content** form. When the force-removed root is a rawtext element (`\u003cstyle\u003e`), the payload lives in the node\u0027s *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 `\u003cstyle\u003e` whose text still carries live markup. Serializing that node and re-parsing it in **plain HTML context** materializes the payload \u2014 no foreign-content context required.\n\nThe 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.\n\n## Steps to reproduce\n\n```js\nconst { JSDOM } = require(\u0027jsdom\u0027);\nconst createDOMPurify = require(\u0027dompurify\u0027);   // 3.4.15\n\nconst window = new JSDOM(\u0027\u0027).window;\nconst DOMPurify = createDOMPurify(window);\n\nconst styleRoot = window.document.createElement(\u0027style\u0027);\nstyleRoot.setAttribute(\u0027onclick\u0027, \u0027alert(1)\u0027);    // attribute payload\nstyleRoot.textContent = \u0027\u003c/style\u003e\u003cimg src=x onerror=1\u003e\u0027;  // text payload\nwindow.document.body.appendChild(styleRoot);\n\nconst returned = DOMPurify.sanitize(styleRoot, { IN_PLACE: true });\n\nconsole.log(returned === styleRoot);                       // true (same node)\nconsole.log(styleRoot.parentNode === null);                // true (detached)\nconsole.log(styleRoot.outerHTML);\n// \u003cstyle\u003e\u003c/style\u003e\u003cimg src=x onerror=1\u003e\u003c/style\u003e\nconsole.log(styleRoot.getAttribute(\u0027onclick\u0027));            // null  (attribute neutralized)\nconsole.log(styleRoot.textContent);                        // \u0027\u003c/style\u003e\u003cimg src=x onerror=1\u003e\u0027 (text survives)\n\n// plain HTML reparse (no foreign-content context involved):\nconst probe = window.document.createElement(\u0027div\u0027);\nprobe.innerHTML = returned.outerHTML || styleRoot.outerHTML;\nconsole.log(probe.querySelectorAll(\u0027img\u0027).length);         // 1\nconsole.log(probe.querySelector(\u0027img\u0027).getAttribute(\u0027onerror\u0027)); // \"1\"\n```\n\nObserved on 3.4.15: one node, one call \u2014 the `onclick` **attribute** is neutralized while the **text** payload (`\u003c/style\u003e\u003cimg src=x onerror=1\u003e`) survives verbatim; serializing and re-parsing the returned node in plain HTML context materializes the `img` with the live `onerror` handler.\n\nContrast on the same Node input without `IN_PLACE`: `RETURN_DOM: true` \u2192 `\u003cbody\u003e\u003c/body\u003e`; `RETURN_DOM_FRAGMENT: true` \u2192 0 children \u2014 the payload is fully sanitized away. The only difference is the IN_PLACE return path.\n\nContrast on the removal trigger: `SAFE_FOR_XML: false` \u2192 the node is not removed (detached stays false); plain CSS text \u2192 not removed. The removal is gated by the mXSS text probes and happens *specifically because* the serialized node would re-open tags on reparse.\n\n## Root cause\n\n`_isUnsafeNode` (`dist/purify.js` 3.4.15, lines 1700\u20131714) removes nodes whose literal text would re-open tags on reparse \u2014 shape (b) in the source comment is \"text-only content that already carries the element\u0027s 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**.\n\nThe IN_PLACE exit then hands the force-removed root back to the caller \u2014 the very node whose removal `DOMPurify.removed` just recorded (verified: `DOMPurify.removed.some(e =\u003e e.element === root)` is `true` on the returned instance). The 3.4.9 `_neutralizeSubtree` pass (line 1336) addresses only the attribute form \u2014 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.\n\nDifferential (one node, one call, same removal path): the `onclick` attribute is neutralized by the existing pass while the text payload survives verbatim \u2014 the attribute axis is covered, the text axis is the gap.\n\n## Impact\n\nIdentical 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.\n\n## Affected versions\n\n- Verified live: 3.4.15 (current).\n- Source-verified: the attribute-only `_neutralizeSubtree` and the IN_PLACE return path are present in 3.4.9\u20133.4.14; releases before 3.4.9 predate the fix entirely (unconditional return; individual pre-3.4.9 releases not dynamically tested).\n- Per cure53 advisory convention the affected range is reported as `\u003c= 3.4.15` (current at time of writing).\n\n## Suggested remediation\n\n**Primary (root-cause, covers every form):** at the IN_PLACE exit, check whether the returned root was recorded during sanitization \u2014 `DOMPurify.removed.some(e =\u003e e.element === root)` \u2014 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.\n\n**Secondary (form-specific):** extend `_neutralizeSubtree` to neutralize **text content of rawtext descendants** \u2014 the elements in `LITERAL_TEXT_ELEMENT_NAMES` (`style`, `script`, `xmp`, `iframe`, `noembed`, `noframes`, `plaintext`, `noscript`) \u2014 by rewriting `textContent` to a defanged form, matching the probe coverage of `_isUnsafeNode`/`LITERAL_TEXT_CLOSE`.\n\nA regression test asserting that a force-removed rawtext root comes back with no `/\u003c[/\\w!]/` match in `textContent` (and is not returned at all under the primary fix) prevents re-introduction.\n\n## Prior art / differentiation\n\n- GHSA-r47g-fvhr-h676 (fixed 3.4.6): clobbered-form **root** removal \u2014 different trigger; this report\u0027s root is a normal allowlisted `style` element killed by the text probe.\n- GHSA-55q2-fjhq-7xh7 (low): IN_PLACE **hook removal** leaves a detached subtree executable \u2014 the attribute-form twin (hook-stripped subtree retains onload-class handlers). This report\u0027s rawtext **text** form is not covered by `_neutralizeSubtree`\u0027s attribute stripping and is not that advisory.\n- GHSA-h8r8-wccr-v5f2 (medium): mXSS via re-contextualization in the standard (non-IN_PLACE) serialize path \u2014 different mechanism; IN_PLACE is not involved.\n- The 3.4.9 release notes credit @mozfreedyb for the IN_PLACE handling improvements that this residual escapes on the text axis.\n\n## Applicability scope (stated up front)\n\nThe 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\u0027s 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\u0027s own source comment; this report concerns the serialize-and-reinsert consumption pattern that the IN_PLACE mode exists to serve.",
  "id": "GHSA-6688-9rhm-gjv2",
  "modified": "2026-10-05T23:43:53Z",
  "published": "2026-10-05T23:43:53Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/cure53/DOMPurify/security/advisories/GHSA-6688-9rhm-gjv2"
    },
    {
      "type": "WEB",
      "url": "https://github.com/cure53/DOMPurify/pull/1636"
    },
    {
      "type": "WEB",
      "url": "https://github.com/cure53/DOMPurify/commit/b9b9d80f7e401771c2ccaef5f45def7eec8f27d7"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/cure53/DOMPurify"
    },
    {
      "type": "WEB",
      "url": "https://github.com/cure53/DOMPurify/releases/tag/3.4.16"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [],
  "summary": "DOMPurify: IN_PLACE returns a force-removed rawtext root whose text carries attacker markup \u2014 pure HTML reparse executes"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…