dompurify 3.4.7 → 3.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/purify.ts CHANGED
@@ -189,6 +189,71 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
189
189
  let trustedTypesPolicy;
190
190
  let emptyHTML = '';
191
191
 
192
+ // The instance's own internal Trusted Types policy. Unlike a caller-supplied
193
+ // `TRUSTED_TYPES_POLICY`, this is created at most once — Trusted Types throws
194
+ // on duplicate policy names — and is the only policy allowed to persist
195
+ // across configurations and survive `clearConfig()`.
196
+ let defaultTrustedTypesPolicy;
197
+ let defaultTrustedTypesPolicyResolved = false;
198
+
199
+ // Tracks whether we are already inside a call to the configured Trusted Types
200
+ // policy (`createHTML` or `createScriptURL`). If a supplied policy callback
201
+ // itself calls `DOMPurify.sanitize` (the cause of #1422), `sanitize` would
202
+ // re-enter the policy and recurse until the stack overflows. We detect that
203
+ // re-entry and throw a clear, actionable error instead. The guard is shared
204
+ // across both callbacks, because either one re-entering `sanitize` triggers
205
+ // the same unbounded recursion.
206
+ let IN_TRUSTED_TYPES_POLICY = 0;
207
+ const _assertNotInTrustedTypesPolicy = function (): void {
208
+ if (IN_TRUSTED_TYPES_POLICY > 0) {
209
+ throw typeErrorCreate(
210
+ 'A configured TRUSTED_TYPES_POLICY callback (createHTML or ' +
211
+ 'createScriptURL) must not call DOMPurify.sanitize, as that causes ' +
212
+ 'infinite recursion. Do not pass a policy whose callbacks wrap ' +
213
+ 'DOMPurify as TRUSTED_TYPES_POLICY; see the "DOMPurify and Trusted ' +
214
+ 'Types" section of the README.'
215
+ );
216
+ }
217
+ };
218
+
219
+ const _createTrustedHTML = function (html: string): string {
220
+ _assertNotInTrustedTypesPolicy();
221
+
222
+ IN_TRUSTED_TYPES_POLICY++;
223
+ try {
224
+ return trustedTypesPolicy.createHTML(html);
225
+ } finally {
226
+ IN_TRUSTED_TYPES_POLICY--;
227
+ }
228
+ };
229
+
230
+ const _createTrustedScriptURL = function (scriptUrl: string): string {
231
+ _assertNotInTrustedTypesPolicy();
232
+
233
+ IN_TRUSTED_TYPES_POLICY++;
234
+ try {
235
+ return trustedTypesPolicy.createScriptURL(scriptUrl);
236
+ } finally {
237
+ IN_TRUSTED_TYPES_POLICY--;
238
+ }
239
+ };
240
+
241
+ // Lazily resolve (and cache) the instance's internal default policy.
242
+ // Resolution is attempted at most once: a successful `createPolicy` cannot be
243
+ // repeated (Trusted Types throws on duplicate names), and a failed or
244
+ // unsupported attempt must not be retried on every parse.
245
+ const _getDefaultTrustedTypesPolicy = function () {
246
+ if (!defaultTrustedTypesPolicyResolved) {
247
+ defaultTrustedTypesPolicy = _createTrustedTypesPolicy(
248
+ trustedTypes,
249
+ currentScript
250
+ );
251
+ defaultTrustedTypesPolicyResolved = true;
252
+ }
253
+
254
+ return defaultTrustedTypesPolicy;
255
+ };
256
+
192
257
  const {
193
258
  implementation,
194
259
  createNodeIterator,
@@ -397,6 +462,16 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
397
462
  'noscript',
398
463
  'plaintext',
399
464
  'script',
465
+ // <selectedcontent> mirrors the selected <option>'s subtree, cloned by
466
+ // the UA (customizable <select>) — including any on* handlers — and the
467
+ // engine re-mirrors synchronously whenever a removal changes which
468
+ // option/selectedcontent is current, even inside DOMPurify's inert
469
+ // DOMParser document. Hoisting its children on removal re-inserts a fresh
470
+ // mirror target ahead of the walk, which the engine refills, looping
471
+ // forever (DoS) and amplifying output. Dropping its content on removal
472
+ // (rather than hoisting) breaks that cascade; the content is a duplicate
473
+ // of the option, which is sanitized on its own. See campaign-3 F1/F6.
474
+ 'selectedcontent',
400
475
  'style',
401
476
  'svg',
402
477
  'template',
@@ -759,6 +834,13 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
759
834
  delete FORBID_TAGS.tbody;
760
835
  }
761
836
 
837
+ // Re-derive the active Trusted Types policy from this configuration on
838
+ // every parse. The active policy must never be sticky closure state that
839
+ // outlives the config that set it: a caller-supplied policy left in place
840
+ // after `clearConfig()` — or after a later call that supplied none, or
841
+ // `TRUSTED_TYPES_POLICY: null` — could sign a subsequent "default"
842
+ // `RETURN_TRUSTED_TYPE` result with a foreign, possibly unsafe policy.
843
+ // See GHSA-vxr8-fq34-vvx9.
762
844
  if (cfg.TRUSTED_TYPES_POLICY) {
763
845
  if (typeof cfg.TRUSTED_TYPES_POLICY.createHTML !== 'function') {
764
846
  throw typeErrorCreate(
@@ -772,23 +854,47 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
772
854
  );
773
855
  }
774
856
 
775
- // Overwrite existing TrustedTypes policy.
857
+ // A caller-supplied policy applies to this configuration only.
858
+ const previousTrustedTypesPolicy = trustedTypesPolicy;
776
859
  trustedTypesPolicy = cfg.TRUSTED_TYPES_POLICY;
777
860
 
778
- // Sign local variables required by `sanitize`.
779
- emptyHTML = trustedTypesPolicy.createHTML('');
861
+ // Sign local variables required by `sanitize`. If the supplied policy's
862
+ // `createHTML` is circular (i.e. it calls `DOMPurify.sanitize`), this
863
+ // throws via the re-entrancy guard. Restore the previous policy first so
864
+ // the instance is not left in a poisoned state. See #1422.
865
+ try {
866
+ emptyHTML = _createTrustedHTML('');
867
+ } catch (error) {
868
+ trustedTypesPolicy = previousTrustedTypesPolicy;
869
+ throw error;
870
+ }
871
+ } else if (cfg.TRUSTED_TYPES_POLICY === null) {
872
+ // Explicit opt-out for this call: perform no Trusted Types signing and
873
+ // create nothing (so a strict `trusted-types` CSP that disallows a
874
+ // `dompurify` policy can still call `sanitize` from inside its own
875
+ // policy — see #1422). Resetting to `undefined` rather than a sticky
876
+ // `null` also drops any previously retained caller policy, so it cannot
877
+ // resurface on a later call, while still allowing the next config-less
878
+ // call to restore the internal default policy. See GHSA-vxr8-fq34-vvx9.
879
+ trustedTypesPolicy = undefined;
880
+ emptyHTML = '';
780
881
  } else {
781
- // Uninitialized policy, attempt to initialize the internal dompurify policy.
882
+ // No policy supplied: keep the currently active policy if one is set — a
883
+ // previously supplied policy is intentionally sticky across config-less
884
+ // calls — otherwise fall back to the instance's own internal policy,
885
+ // created at most once. (A policy supplied for a *single* call still
886
+ // lingers by design; what must not linger is a policy whose configuration
887
+ // has been torn down via `clearConfig()`, which restores the default.)
782
888
  if (trustedTypesPolicy === undefined) {
783
- trustedTypesPolicy = _createTrustedTypesPolicy(
784
- trustedTypes,
785
- currentScript
786
- );
889
+ trustedTypesPolicy = _getDefaultTrustedTypesPolicy();
787
890
  }
788
891
 
789
- // If creating the internal policy succeeded sign internal variables.
790
- if (trustedTypesPolicy !== null && typeof emptyHTML === 'string') {
791
- emptyHTML = trustedTypesPolicy.createHTML('');
892
+ // Sign internal variables only when a policy is active. A falsy policy
893
+ // (Trusted Types unsupported, creation failed, or an explicit opt-out)
894
+ // leaves `emptyHTML` as a plain string, so we never call `.createHTML` on
895
+ // a non-policy and throw. See #1422.
896
+ if (trustedTypesPolicy && typeof emptyHTML === 'string') {
897
+ emptyHTML = _createTrustedHTML('');
792
898
  }
793
899
  }
794
900
 
@@ -959,7 +1065,83 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
959
1065
  // eslint-disable-next-line unicorn/prefer-dom-node-remove
960
1066
  getParentNode(node).removeChild(node);
961
1067
  } catch (_) {
1068
+ /* The normal detach failed — this is reached for a parentless node
1069
+ (getParentNode() is null, so .removeChild throws). Element.prototype
1070
+ .remove() is itself a spec no-op on a parentless node, so a recorded
1071
+ "removal" would otherwise hand the caller back an intact,
1072
+ payload-bearing node (e.g. a detached IN_PLACE root the mXSS canary or
1073
+ the style-with-element-child rule decided to kill). Fail closed by
1074
+ throwing — exactly as a clobbered root does at the IN_PLACE entry —
1075
+ rather than trying to "neutralize" the node via its own methods.
1076
+ Neutralizing would mean calling getAttributeNames()/removeAttribute()
1077
+ on the node, both of which a <form> root can clobber via a named child
1078
+ (and _isClobbered does not even probe getAttributeNames), so the
1079
+ neutralize step could itself be silently defeated, leaving the payload
1080
+ intact. A throw touches only the cached, clobber-safe remove() and
1081
+ getParentNode(). Generalizes GHSA-r47g-fvhr-h676 (clobbered-form root)
1082
+ to every root-kill reason. REPORT-3.
1083
+
1084
+ This lives inside the catch, so it never fires for a normally-removed
1085
+ in-tree node: those have a parent, removeChild() succeeds, and the
1086
+ catch is not entered. Only a kept (parentless) root reaches here. */
962
1087
  remove(node);
1088
+
1089
+ if (!getParentNode(node)) {
1090
+ throw typeErrorCreate(
1091
+ 'a node selected for removal could not be detached from its tree ' +
1092
+ 'and cannot be safely returned; refusing to sanitize in place'
1093
+ );
1094
+ }
1095
+ }
1096
+ };
1097
+
1098
+ /**
1099
+ * _neutralizeRoot
1100
+ *
1101
+ * Fail-closed teardown of an in-place root after the sanitize walk aborts
1102
+ * (campaign-3 F2). An internal throw mid-walk — e.g. a page-registered
1103
+ * custom element's reaction detaches a node so `_forceRemove`'s deliberate
1104
+ * parentless guard throws, or any other re-entrant engine mutation — would
1105
+ * otherwise leave the caller's *live* tree half-sanitized, with everything
1106
+ * after the abort point still carrying its handlers. There is no safe way
1107
+ * to resume the walk (the tree mutated under us), so we strip the root bare:
1108
+ * remove every child and every attribute, then let the caller's catch see
1109
+ * the original error. Clobber-safe (cached `remove`/`childNodes`/`attributes`
1110
+ * getters; the root was already clobber-pre-flighted at the IN_PLACE entry).
1111
+ *
1112
+ * @param root the in-place root to empty
1113
+ */
1114
+ const _neutralizeRoot = function (root: Node): void {
1115
+ const childNodes = getChildNodes
1116
+ ? getChildNodes(root)
1117
+ : (root as Element).childNodes;
1118
+ if (childNodes) {
1119
+ const snapshot: Node[] = [];
1120
+ arrayForEach(childNodes, (child) => {
1121
+ arrayPush(snapshot, child);
1122
+ });
1123
+ arrayForEach(snapshot, (child) => {
1124
+ try {
1125
+ remove(child);
1126
+ } catch (_) {
1127
+ /* Best-effort teardown; a still-attached child is handled below */
1128
+ }
1129
+ });
1130
+ }
1131
+
1132
+ const attributes = getAttributes ? getAttributes(root) : null;
1133
+ if (attributes) {
1134
+ for (let i = attributes.length - 1; i >= 0; --i) {
1135
+ const attribute = attributes[i];
1136
+ const name = attribute && attribute.name;
1137
+ if (typeof name === 'string') {
1138
+ try {
1139
+ (root as Element).removeAttribute(name);
1140
+ } catch (_) {
1141
+ /* Clobbered removeAttribute — ignore (fail-closed best effort) */
1142
+ }
1143
+ }
1144
+ }
963
1145
  }
964
1146
  };
965
1147
 
@@ -998,6 +1180,83 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
998
1180
  }
999
1181
  };
1000
1182
 
1183
+ /**
1184
+ * _stripDisallowedAttributes
1185
+ *
1186
+ * Removes every attribute the active configuration does not allow from a
1187
+ * single element, using the same allowlist as the main attribute pass (so
1188
+ * `on*` handlers go, but no `/^on/` blocklist is introduced). Used only to
1189
+ * neutralise nodes that are being discarded from an in-place tree.
1190
+ *
1191
+ * @param element the element to strip
1192
+ */
1193
+ const _stripDisallowedAttributes = function (element: Element): void {
1194
+ const attributes = getAttributes
1195
+ ? getAttributes(element)
1196
+ : element.attributes;
1197
+ if (!attributes) {
1198
+ return;
1199
+ }
1200
+
1201
+ for (let i = attributes.length - 1; i >= 0; --i) {
1202
+ const attribute = attributes[i];
1203
+ const name = attribute && attribute.name;
1204
+ if (typeof name !== 'string' || ALLOWED_ATTR[transformCaseFunc(name)]) {
1205
+ continue;
1206
+ }
1207
+
1208
+ try {
1209
+ element.removeAttribute(name);
1210
+ } catch (_) {
1211
+ /* Clobbered removeAttribute on a doomed node — ignore */
1212
+ }
1213
+ }
1214
+ };
1215
+
1216
+ /**
1217
+ * _neutralizeSubtree
1218
+ *
1219
+ * Completes the audit-5 F1 fix across every removal path. The KEEP_CONTENT
1220
+ * move-hoist neutralises only disallowed-tag removals; clobber, mXSS-canary,
1221
+ * namespace, comment, processing-instruction and KEEP_CONTENT:false removals
1222
+ * all drop their subtree wholesale via `_forceRemove`. On the IN_PLACE path
1223
+ * those dropped nodes are detached from the caller's LIVE tree but a
1224
+ * handler-bearing original among them (an `<img onerror>`/`<video>` that was
1225
+ * loading) keeps its queued resource event, which fires in page scope after
1226
+ * sanitize returns. This walks a removed subtree and strips every attribute
1227
+ * the active configuration does not allow — so `on*` handlers are cancelled
1228
+ * through the SAME allowlist that governs kept nodes, not a separate `/^on/`
1229
+ * blocklist. Run synchronously before sanitize returns, i.e. before any
1230
+ * queued event can fire. Hook-free by design: these nodes leave the output,
1231
+ * so firing attribute hooks for them would be surprising. Clobber-safe reads;
1232
+ * a doomed clobbered node may shadow `removeAttribute` (its own attributes are
1233
+ * irrelevant — it is discarded — while its non-clobbered descendants, e.g.
1234
+ * the `<img>`, are reached and scrubbed).
1235
+ *
1236
+ * @param root the root of a removed subtree to neutralise
1237
+ */
1238
+ const _neutralizeSubtree = function (root: Node): void {
1239
+ const stack: Node[] = [root];
1240
+
1241
+ while (stack.length > 0) {
1242
+ const node = stack.pop();
1243
+ const nodeType = getNodeType ? getNodeType(node) : (node as any).nodeType;
1244
+
1245
+ if (nodeType === NODE_TYPE.element) {
1246
+ _stripDisallowedAttributes(node as Element);
1247
+ }
1248
+
1249
+ const childNodes = getChildNodes
1250
+ ? getChildNodes(node)
1251
+ : (node as Element).childNodes;
1252
+ if (childNodes) {
1253
+ for (let i = childNodes.length - 1; i >= 0; --i) {
1254
+ stack.push(childNodes[i]);
1255
+ }
1256
+ }
1257
+ }
1258
+ };
1259
+
1001
1260
  /**
1002
1261
  * _initDocument
1003
1262
  *
@@ -1028,9 +1287,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1028
1287
  '</body></html>';
1029
1288
  }
1030
1289
 
1031
- const dirtyPayload = trustedTypesPolicy
1032
- ? trustedTypesPolicy.createHTML(dirty)
1033
- : dirty;
1290
+ const dirtyPayload = trustedTypesPolicy ? _createTrustedHTML(dirty) : dirty;
1034
1291
  /*
1035
1292
  * Use the DOMParser API by default, fallback later if needs be
1036
1293
  * DOMParser not work for svg when has multiple root element.
@@ -1134,6 +1391,16 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1134
1391
  currentNode.data = data;
1135
1392
  currentNode = walker.nextNode() as CharacterData | null;
1136
1393
  }
1394
+
1395
+ // NodeIterator does not descend into <template>.content per the DOM spec,
1396
+ // so we must explicitly recurse into each template's content fragment,
1397
+ // mirroring the approach used by _sanitizeShadowDOM.
1398
+ const templates = node.querySelectorAll?.('template') ?? [];
1399
+ arrayForEach(Array.from(templates), (tmpl: HTMLTemplateElement) => {
1400
+ if (_isDocumentFragment(tmpl.content)) {
1401
+ _scrubTemplateExpressions(tmpl.content as unknown as Element);
1402
+ }
1403
+ });
1137
1404
  };
1138
1405
 
1139
1406
  /**
@@ -1277,7 +1544,9 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1277
1544
  }
1278
1545
 
1279
1546
  /* Now let's check the element's type and name */
1280
- const tagName = transformCaseFunc(currentNode.nodeName);
1547
+ const tagName = transformCaseFunc(
1548
+ getNodeName ? getNodeName(currentNode) : currentNode.nodeName
1549
+ );
1281
1550
 
1282
1551
  /* Execute a hook if present */
1283
1552
  _executeHooks(hooks.uponSanitizeElement, currentNode, {
@@ -1365,9 +1634,32 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1365
1634
  if (childNodes && parentNode) {
1366
1635
  const childCount = childNodes.length;
1367
1636
 
1637
+ /* In-place: hoist the *original* children so the iterator visits
1638
+ and sanitises them through the same allowlist pass as every other
1639
+ node. The caller built the tree in the live document, so the
1640
+ originals carry already-queued resource events (`<img onerror>`,
1641
+ `<video>`/`<audio>` error, lazy/`onload`, …); cloning would leave
1642
+ those originals detached but still armed, firing in page scope
1643
+ while the returned tree looked clean. Moving is safe in-place: the
1644
+ root is pre-validated as an allowed tag and so is never the node
1645
+ being removed, which keeps `parentNode` inside the iterator root
1646
+ and the relocated child inside the serialised tree.
1647
+
1648
+ Otherwise (string / DOM-copy paths): clone. The iterator is rooted
1649
+ at — and the result serialised from — `body`, so a restrictive
1650
+ ALLOWED_TAGS that removes `body` itself must leave its content in
1651
+ place, which only cloning does; and those paths parse into an
1652
+ inert document, so their discarded originals never had a queued
1653
+ event to neutralise.
1654
+
1655
+ `childNodes` is live; a tail-to-head walk keeps `childNodes[i]`
1656
+ valid whether we move (drops the trailing entry) or clone (leaves
1657
+ the list intact). */
1368
1658
  for (let i = childCount - 1; i >= 0; --i) {
1369
- const childClone = cloneNode(childNodes[i], true);
1370
- parentNode.insertBefore(childClone, getNextSibling(currentNode));
1659
+ const hoisted = IN_PLACE
1660
+ ? childNodes[i]
1661
+ : cloneNode(childNodes[i], true);
1662
+ parentNode.insertBefore(hoisted, getNextSibling(currentNode));
1371
1663
  }
1372
1664
  }
1373
1665
  }
@@ -1683,12 +1975,12 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1683
1975
  } else {
1684
1976
  switch (trustedTypes.getAttributeType(lcTag, lcName)) {
1685
1977
  case 'TrustedHTML': {
1686
- value = trustedTypesPolicy.createHTML(value);
1978
+ value = _createTrustedHTML(value);
1687
1979
  break;
1688
1980
  }
1689
1981
 
1690
1982
  case 'TrustedScriptURL': {
1691
- value = trustedTypesPolicy.createScriptURL(value);
1983
+ value = _createTrustedScriptURL(value);
1692
1984
  break;
1693
1985
  }
1694
1986
 
@@ -1766,7 +2058,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1766
2058
  iterator also surfaces. */
1767
2059
  const shadowNodeType = getNodeType
1768
2060
  ? getNodeType(shadowNode)
1769
- : (shadowNode as Node).nodeType;
2061
+ : shadowNode.nodeType;
1770
2062
  if (shadowNodeType === NODE_TYPE.element) {
1771
2063
  const innerSr = getShadowRoot
1772
2064
  ? getShadowRoot(shadowNode)
@@ -1802,57 +2094,80 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1802
2094
  * @param root the subtree root to walk for attached shadow roots
1803
2095
  */
1804
2096
  const _sanitizeAttachedShadowRoots = function (root: Node): void {
1805
- const nodeType = getNodeType ? getNodeType(root) : (root as any).nodeType;
1806
-
1807
- if (nodeType === NODE_TYPE.element) {
1808
- const sr = getShadowRoot
1809
- ? getShadowRoot(root)
1810
- : (root as Element).shadowRoot;
1811
- // Realm-safe check (GHSA-hpcv-96wg-7vj8): use nodeType-based
1812
- // detection rather than `instanceof DocumentFragment`, which is
1813
- // realm-bound and silently skipped shadow roots whose host element
1814
- // belonged to a foreign realm (e.g. iframe.contentDocument
1815
- // attachShadow). A foreign-realm ShadowRoot extends the foreign
1816
- // realm's DocumentFragment, not ours, so the old instanceof check
1817
- // returned false and the shadow subtree was never walked.
1818
- if (_isDocumentFragment(sr)) {
1819
- // Recurse first so that nested shadow roots are reached even if
1820
- // _sanitizeShadowDOM removes hosts at this level.
1821
- _sanitizeAttachedShadowRoots(sr);
1822
- _sanitizeShadowDOM(sr);
2097
+ /* Iterative (explicit stack) rather than per-child recursion. DOM APIs
2098
+ impose no depth cap, so an attacker-shaped tree (JSON/CRDT/editor data
2099
+ built straight into the DOM — the IN_PLACE surface) deeper than the JS
2100
+ call-stack budget would otherwise overflow native recursion here and
2101
+ throw at the IN_PLACE entry pre-pass, before a single node is
2102
+ sanitized, leaving the caller's live tree untouched (fail-open). See
2103
+ campaign-3 F4. A heap stack keeps depth off the call stack.
2104
+
2105
+ Each work item is either a node to descend into, or a deferred
2106
+ `_sanitizeShadowDOM` for an already-walked shadow root. The deferred
2107
+ form preserves the original post-order discipline: a shadow root's
2108
+ nested shadow roots are discovered before the outer shadow is
2109
+ sanitized (which may remove hosts). Pushes are in reverse of the
2110
+ desired processing order (LIFO): template content, then children, then
2111
+ the shadow-sanitize, then the shadow walk so the order matches the
2112
+ previous recursion exactly. */
2113
+ const stack: Array<{ node: Node | null; shadow: DocumentFragment | null }> =
2114
+ [{ node: root, shadow: null }];
2115
+
2116
+ while (stack.length > 0) {
2117
+ const item = stack.pop();
2118
+
2119
+ /* Deferred shadow-DOM sanitisation: runs after its subtree was walked. */
2120
+ if (item.shadow) {
2121
+ _sanitizeShadowDOM(item.shadow);
2122
+ continue;
1823
2123
  }
1824
- }
1825
2124
 
1826
- // Snapshot children before recursing. Sanitization of one subtree
1827
- // (e.g. via an uponSanitizeShadowNode hook) may detach siblings,
1828
- // and naive nextSibling traversal would silently skip the rest of
1829
- // the list once a node is detached.
1830
- const childNodes = getChildNodes
1831
- ? getChildNodes(root)
1832
- : (root as Element).childNodes;
1833
- if (!childNodes) {
1834
- return;
1835
- }
1836
-
1837
- const snapshot: Node[] = [];
1838
- arrayForEach(childNodes, (child) => {
1839
- arrayPush(snapshot, child);
1840
- });
2125
+ const node = item.node;
2126
+ const nodeType = getNodeType ? getNodeType(node) : (node as any).nodeType;
2127
+ const isElement = nodeType === NODE_TYPE.element;
2128
+
2129
+ /* (pushed last → processed first) Children, snapshotted in reverse so
2130
+ the first child is processed first. Snapshotting matters because a
2131
+ hook may detach siblings mid-walk. */
2132
+ const childNodes = getChildNodes
2133
+ ? getChildNodes(node)
2134
+ : (node as Element).childNodes;
2135
+ if (childNodes) {
2136
+ for (let i = childNodes.length - 1; i >= 0; --i) {
2137
+ stack.push({ node: childNodes[i], shadow: null });
2138
+ }
2139
+ }
1841
2140
 
1842
- for (const child of snapshot) {
1843
- _sanitizeAttachedShadowRoots(child);
1844
- }
2141
+ /* (pushed before children processed after them, matching the old
2142
+ "template content last" order) When the node is a <template>,
2143
+ descend into its content. */
2144
+ if (isElement) {
2145
+ const rootName = getNodeName ? getNodeName(node) : null;
2146
+ if (
2147
+ typeof rootName === 'string' &&
2148
+ transformCaseFunc(rootName) === 'template'
2149
+ ) {
2150
+ const content = (node as HTMLTemplateElement).content;
2151
+ if (_isDocumentFragment(content)) {
2152
+ stack.push({ node: content, shadow: null });
2153
+ }
2154
+ }
2155
+ }
1845
2156
 
1846
- /* When the root is a <template>, also descend into root.content */
1847
- if (nodeType === NODE_TYPE.element) {
1848
- const rootName = getNodeName ? getNodeName(root) : null;
1849
- if (
1850
- typeof rootName === 'string' &&
1851
- transformCaseFunc(rootName) === 'template'
1852
- ) {
1853
- const content = (root as HTMLTemplateElement).content;
1854
- if (_isDocumentFragment(content)) {
1855
- _sanitizeAttachedShadowRoots(content);
2157
+ /* Shadow root (processed first): walk its subtree, then sanitise it.
2158
+ Realm-safe check (GHSA-hpcv-96wg-7vj8): nodeType-based detection
2159
+ rather than `instanceof DocumentFragment`, which is realm-bound and
2160
+ silently skipped foreign-realm shadow roots (e.g.
2161
+ iframe.contentDocument attachShadow). */
2162
+ if (isElement) {
2163
+ const sr = getShadowRoot
2164
+ ? getShadowRoot(node)
2165
+ : (node as Element).shadowRoot;
2166
+ if (_isDocumentFragment(sr)) {
2167
+ /* Push the deferred sanitise first so it pops after the shadow
2168
+ walk we push next, i.e. nested shadow roots are discovered
2169
+ before this one is sanitised. */
2170
+ stack.push({ node: null, shadow: sr }, { node: sr, shadow: null });
1856
2171
  }
1857
2172
  }
1858
2173
  }
@@ -1894,12 +2209,15 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1894
2209
  /* Clean up removed elements */
1895
2210
  DOMPurify.removed = [];
1896
2211
 
1897
- /* Check if dirty is correctly typed for IN_PLACE */
1898
- if (typeof dirty === 'string') {
1899
- IN_PLACE = false;
1900
- }
2212
+ /* Resolve IN_PLACE for this call without mutating persistent config.
2213
+ Writing the IN_PLACE closure variable here leaks under setConfig(),
2214
+ where _parseConfig is skipped on later calls: a single string call would
2215
+ disable in-place mode for every subsequent node call, returning a
2216
+ sanitized copy while leaving the caller's node — which in-place callers
2217
+ keep using and whose return value they ignore — unsanitized. REPORT-2. */
2218
+ const inPlace = IN_PLACE && typeof dirty !== 'string' && _isNode(dirty);
1901
2219
 
1902
- if (IN_PLACE) {
2220
+ if (inPlace) {
1903
2221
  /* Do some early pre-sanitization to avoid unsafe root nodes.
1904
2222
  Read nodeName through the cached prototype getter — a clobbering
1905
2223
  child named "nodeName" on the form root would otherwise shadow
@@ -1934,8 +2252,17 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1934
2252
  }
1935
2253
 
1936
2254
  /* Sanitize attached shadow roots before the main iterator runs.
1937
- The iterator does not descend into shadow trees. */
1938
- _sanitizeAttachedShadowRoots(dirty as Node);
2255
+ The iterator does not descend into shadow trees. Same fail-closed
2256
+ barrier as the main walk (campaign-3 F2): a custom-element reaction
2257
+ inside a shadow root could abort this pre-pass before the walk runs,
2258
+ which would otherwise leave the entire live tree unsanitized. */
2259
+ try {
2260
+ _sanitizeAttachedShadowRoots(dirty as Node);
2261
+ } catch (error) {
2262
+ _neutralizeRoot(dirty as Node);
2263
+
2264
+ throw error;
2265
+ }
1939
2266
  } else if (_isNode(dirty)) {
1940
2267
  /* If dirty is a DOM element, append to an empty document to avoid
1941
2268
  elements being stripped by the parser */
@@ -1970,7 +2297,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1970
2297
  dirty.indexOf('<') === -1
1971
2298
  ) {
1972
2299
  return trustedTypesPolicy && RETURN_TRUSTED_TYPE
1973
- ? trustedTypesPolicy.createHTML(dirty)
2300
+ ? _createTrustedHTML(dirty)
1974
2301
  : dirty;
1975
2302
  }
1976
2303
 
@@ -1989,27 +2316,56 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1989
2316
  }
1990
2317
 
1991
2318
  /* Get node iterator */
1992
- const nodeIterator = _createNodeIterator(IN_PLACE ? dirty : body);
1993
-
1994
- /* Now start iterating over the created document */
1995
- while ((currentNode = nodeIterator.nextNode())) {
1996
- /* Sanitize tags and elements */
1997
- _sanitizeElements(currentNode);
1998
-
1999
- /* Check attributes next */
2000
- _sanitizeAttributes(currentNode);
2001
-
2002
- /* Shadow DOM detected, sanitize it.
2003
- Realm-safe check (GHSA-hpcv-96wg-7vj8): nodeType-based detection
2004
- instead of instanceof, so foreign-realm <template>.content is
2005
- walked correctly. */
2006
- if (_isDocumentFragment(currentNode.content)) {
2007
- _sanitizeShadowDOM(currentNode.content);
2319
+ const nodeIterator = _createNodeIterator(inPlace ? dirty : body);
2320
+
2321
+ /* Now start iterating over the created document.
2322
+ The walk runs inside an exception barrier (campaign-3 F2): a re-entrant
2323
+ engine/custom-element mutation can detach a node mid-walk so
2324
+ `_forceRemove`'s parentless guard throws, aborting the loop. Without the
2325
+ barrier the caller's in-place tree would be left half-sanitized with the
2326
+ unvisited tail still armed. On any throw we fail closed — strip the
2327
+ in-place root bare — then rethrow so the existing throw contract is
2328
+ preserved. (String/DOM-copy paths never return the partial body, so the
2329
+ propagating throw is already fail-closed there.) */
2330
+ try {
2331
+ while ((currentNode = nodeIterator.nextNode())) {
2332
+ /* Sanitize tags and elements */
2333
+ _sanitizeElements(currentNode);
2334
+
2335
+ /* Check attributes next */
2336
+ _sanitizeAttributes(currentNode);
2337
+
2338
+ /* Shadow DOM detected, sanitize it.
2339
+ Realm-safe check (GHSA-hpcv-96wg-7vj8): nodeType-based detection
2340
+ instead of instanceof, so foreign-realm <template>.content is
2341
+ walked correctly. */
2342
+ if (_isDocumentFragment(currentNode.content)) {
2343
+ _sanitizeShadowDOM(currentNode.content);
2344
+ }
2345
+ }
2346
+ } catch (error) {
2347
+ if (inPlace) {
2348
+ _neutralizeRoot(dirty as Node);
2008
2349
  }
2350
+
2351
+ throw error;
2009
2352
  }
2010
2353
 
2011
2354
  /* If we sanitized `dirty` in-place, return it. */
2012
- if (IN_PLACE) {
2355
+ if (inPlace) {
2356
+ /* Fail-closed completion of the audit-5 F1 fix: every node removed from
2357
+ the caller's live tree is detached but may still hold a queued
2358
+ resource-event handler that fires in page scope after we return. The
2359
+ move-hoist covers only disallowed-tag KEEP_CONTENT removals; strip the
2360
+ non-allow-listed attributes off every other removed subtree (clobber,
2361
+ mXSS, namespace, comments, KEEP_CONTENT:false, …) so those handlers are
2362
+ cancelled before any event can fire. Runs synchronously, pre-return. */
2363
+ arrayForEach(DOMPurify.removed, (entry) => {
2364
+ if (entry.element) {
2365
+ _neutralizeSubtree(entry.element as Node);
2366
+ }
2367
+ });
2368
+
2013
2369
  if (SAFE_FOR_TEMPLATES) {
2014
2370
  _scrubTemplateExpressions(dirty as Element);
2015
2371
  }
@@ -2071,7 +2427,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2071
2427
  }
2072
2428
 
2073
2429
  return trustedTypesPolicy && RETURN_TRUSTED_TYPE
2074
- ? trustedTypesPolicy.createHTML(serializedHTML)
2430
+ ? _createTrustedHTML(serializedHTML)
2075
2431
  : serializedHTML;
2076
2432
  };
2077
2433
 
@@ -2083,6 +2439,13 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2083
2439
  DOMPurify.clearConfig = function () {
2084
2440
  CONFIG = null;
2085
2441
  SET_CONFIG = false;
2442
+
2443
+ // Drop any caller-supplied Trusted Types policy so it cannot poison later
2444
+ // `RETURN_TRUSTED_TYPE` output. The internal default policy (cached, and
2445
+ // never recreated — Trusted Types throws on duplicate names) is restored by
2446
+ // the next `_parseConfig`. See GHSA-vxr8-fq34-vvx9.
2447
+ trustedTypesPolicy = defaultTrustedTypesPolicy;
2448
+ emptyHTML = '';
2086
2449
  };
2087
2450
 
2088
2451
  DOMPurify.isValidAttribute = function (tag, attr, value) {