---
id: "GHSA-6688-9rhm-gjv2"
page: "https://infrastack.ai/live/GHSA-6688-9rhm-gjv2"
kind: "vulnerability"
aliases: ["GHSA-6688-9rhm-gjv2"]
title: "DOMPurify 3.4.x: IN_PLACE returns a force-removed rawtext root whose text carries attacker markup \u2014 pure HTML reparse executes"
severity: "low"
cvss: null
epss: null
exploited: false
status: "open"
category: "package"
products: [{"name": "dompurify", "fixed": ["3.4.16"], "affected": "<= 3.4.15", "ecosystem": "npm"}]
ecosystems: ["npm"]
tags: ["browser", "cwe-79", "dompurify", "javascript", "node", "package"]
not_affected_if: ["default-config", "no-in-place", "string-output-only"]
published_at: "2026-09-23T15:17:45+00:00"
first_seen_at: "2026-11-09T22:53:20+00:00"
updated_at: "2026-11-09T23:01:23+00:00"
sources: [{"source": "ghsa-repo", "url": "https://github.com/cure53/DOMPurify/security/advisories/GHSA-6688-9rhm-gjv2", "first_seen_at": "2026-11-09T22:53:20+00:00"}]
exploit_sources: []
---

# DOMPurify 3.4.x: IN_PLACE returns a force-removed rawtext root whose text carries attacker markup — pure HTML reparse executes

Link for people: https://infrastack.ai/live/GHSA-6688-9rhm-gjv2 (this .md is for agents)

DOMPurify's IN_PLACE mode can return a force-removed rawtext style element whose text still contains HTML markup. If the application serializes that node and re-parses it as HTML, the attacker's markup, such as an img with an onerror handler, becomes live and can run script in the page.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- npm:dompurify affected <= 3.4.15 fixed 3.4.16

## How to check

- Run `npm ls dompurify` to find every installed copy and version.
- Search the codebase for `IN_PLACE` near `DOMPurify.sanitize` calls.
- Check whether the returned node is serialized (outerHTML, innerHTML) and then re-inserted into the page.

## Fix

- dompurify: `3.4.16`

## Timeline

- 2026-11-09T22:53:20+00:00 ghsa-repo https://github.com/cure53/DOMPurify/security/advisories/GHSA-6688-9rhm-gjv2

## Documents

- [advisory] Maintainer's advisory: github.com https://github.com/cure53/DOMPurify/security/advisories/GHSA-6688-9rhm-gjv2

## From the sources

## 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).

CVSS 3.1: 6.1 Medium — `AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N`, aligned with the published baseline of the family (GHSA-r47g-fvhr-h676).

## 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. Movin

---

_Free to read, with fair use of about 60 pages a minute. For higher limits or API access: sales@infrastack.ai_
