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.
@@ -1,4 +1,4 @@
1
- /*! @license DOMPurify 3.4.7 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.7/LICENSE */
1
+ /*! @license DOMPurify 3.4.9 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.9/LICENSE */
2
2
 
3
3
  function _arrayLikeToArray(r, a) {
4
4
  (null == a || a > r.length) && (a = r.length);
@@ -403,7 +403,7 @@ const _createHooksMap = function _createHooksMap() {
403
403
  function createDOMPurify() {
404
404
  let window = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : getGlobal();
405
405
  const DOMPurify = root => createDOMPurify(root);
406
- DOMPurify.version = '3.4.7';
406
+ DOMPurify.version = '3.4.9';
407
407
  DOMPurify.removed = [];
408
408
  if (!window || !window.document || window.document.nodeType !== NODE_TYPE.document || !window.Element) {
409
409
  // Not running in a browser, provide a factory function
@@ -448,6 +448,54 @@ function createDOMPurify() {
448
448
  }
449
449
  let trustedTypesPolicy;
450
450
  let emptyHTML = '';
451
+ // The instance's own internal Trusted Types policy. Unlike a caller-supplied
452
+ // `TRUSTED_TYPES_POLICY`, this is created at most once — Trusted Types throws
453
+ // on duplicate policy names — and is the only policy allowed to persist
454
+ // across configurations and survive `clearConfig()`.
455
+ let defaultTrustedTypesPolicy;
456
+ let defaultTrustedTypesPolicyResolved = false;
457
+ // Tracks whether we are already inside a call to the configured Trusted Types
458
+ // policy (`createHTML` or `createScriptURL`). If a supplied policy callback
459
+ // itself calls `DOMPurify.sanitize` (the cause of #1422), `sanitize` would
460
+ // re-enter the policy and recurse until the stack overflows. We detect that
461
+ // re-entry and throw a clear, actionable error instead. The guard is shared
462
+ // across both callbacks, because either one re-entering `sanitize` triggers
463
+ // the same unbounded recursion.
464
+ let IN_TRUSTED_TYPES_POLICY = 0;
465
+ const _assertNotInTrustedTypesPolicy = function _assertNotInTrustedTypesPolicy() {
466
+ if (IN_TRUSTED_TYPES_POLICY > 0) {
467
+ throw typeErrorCreate('A configured TRUSTED_TYPES_POLICY callback (createHTML or ' + 'createScriptURL) must not call DOMPurify.sanitize, as that causes ' + 'infinite recursion. Do not pass a policy whose callbacks wrap ' + 'DOMPurify as TRUSTED_TYPES_POLICY; see the "DOMPurify and Trusted ' + 'Types" section of the README.');
468
+ }
469
+ };
470
+ const _createTrustedHTML = function _createTrustedHTML(html) {
471
+ _assertNotInTrustedTypesPolicy();
472
+ IN_TRUSTED_TYPES_POLICY++;
473
+ try {
474
+ return trustedTypesPolicy.createHTML(html);
475
+ } finally {
476
+ IN_TRUSTED_TYPES_POLICY--;
477
+ }
478
+ };
479
+ const _createTrustedScriptURL = function _createTrustedScriptURL(scriptUrl) {
480
+ _assertNotInTrustedTypesPolicy();
481
+ IN_TRUSTED_TYPES_POLICY++;
482
+ try {
483
+ return trustedTypesPolicy.createScriptURL(scriptUrl);
484
+ } finally {
485
+ IN_TRUSTED_TYPES_POLICY--;
486
+ }
487
+ };
488
+ // Lazily resolve (and cache) the instance's internal default policy.
489
+ // Resolution is attempted at most once: a successful `createPolicy` cannot be
490
+ // repeated (Trusted Types throws on duplicate names), and a failed or
491
+ // unsupported attempt must not be retried on every parse.
492
+ const _getDefaultTrustedTypesPolicy = function _getDefaultTrustedTypesPolicy() {
493
+ if (!defaultTrustedTypesPolicyResolved) {
494
+ defaultTrustedTypesPolicy = _createTrustedTypesPolicy(trustedTypes, currentScript);
495
+ defaultTrustedTypesPolicyResolved = true;
496
+ }
497
+ return defaultTrustedTypesPolicy;
498
+ };
451
499
  const _document = document,
452
500
  implementation = _document.implementation,
453
501
  createNodeIterator = _document.createNodeIterator,
@@ -586,7 +634,17 @@ function createDOMPurify() {
586
634
  let USE_PROFILES = {};
587
635
  /* Tags to ignore content of when KEEP_CONTENT is true */
588
636
  let FORBID_CONTENTS = null;
589
- const DEFAULT_FORBID_CONTENTS = addToSet({}, ['annotation-xml', 'audio', 'colgroup', 'desc', 'foreignobject', 'head', 'iframe', 'math', 'mi', 'mn', 'mo', 'ms', 'mtext', 'noembed', 'noframes', 'noscript', 'plaintext', 'script', 'style', 'svg', 'template', 'thead', 'title', 'video', 'xmp']);
637
+ const DEFAULT_FORBID_CONTENTS = addToSet({}, ['annotation-xml', 'audio', 'colgroup', 'desc', 'foreignobject', 'head', 'iframe', 'math', 'mi', 'mn', 'mo', 'ms', 'mtext', 'noembed', 'noframes', 'noscript', 'plaintext', 'script',
638
+ // <selectedcontent> mirrors the selected <option>'s subtree, cloned by
639
+ // the UA (customizable <select>) — including any on* handlers — and the
640
+ // engine re-mirrors synchronously whenever a removal changes which
641
+ // option/selectedcontent is current, even inside DOMPurify's inert
642
+ // DOMParser document. Hoisting its children on removal re-inserts a fresh
643
+ // mirror target ahead of the walk, which the engine refills, looping
644
+ // forever (DoS) and amplifying output. Dropping its content on removal
645
+ // (rather than hoisting) breaks that cascade; the content is a duplicate
646
+ // of the option, which is sanitized on its own. See campaign-3 F1/F6.
647
+ 'selectedcontent', 'style', 'svg', 'template', 'thead', 'title', 'video', 'xmp']);
590
648
  /* Tags that are safe for data: URIs */
591
649
  let DATA_URI_TAGS = null;
592
650
  const DEFAULT_DATA_URI_TAGS = addToSet({}, ['audio', 'video', 'img', 'source', 'image', 'track']);
@@ -767,6 +825,13 @@ function createDOMPurify() {
767
825
  addToSet(ALLOWED_TAGS, ['tbody']);
768
826
  delete FORBID_TAGS.tbody;
769
827
  }
828
+ // Re-derive the active Trusted Types policy from this configuration on
829
+ // every parse. The active policy must never be sticky closure state that
830
+ // outlives the config that set it: a caller-supplied policy left in place
831
+ // after `clearConfig()` — or after a later call that supplied none, or
832
+ // `TRUSTED_TYPES_POLICY: null` — could sign a subsequent "default"
833
+ // `RETURN_TRUSTED_TYPE` result with a foreign, possibly unsafe policy.
834
+ // See GHSA-vxr8-fq34-vvx9.
770
835
  if (cfg.TRUSTED_TYPES_POLICY) {
771
836
  if (typeof cfg.TRUSTED_TYPES_POLICY.createHTML !== 'function') {
772
837
  throw typeErrorCreate('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');
@@ -774,18 +839,45 @@ function createDOMPurify() {
774
839
  if (typeof cfg.TRUSTED_TYPES_POLICY.createScriptURL !== 'function') {
775
840
  throw typeErrorCreate('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');
776
841
  }
777
- // Overwrite existing TrustedTypes policy.
842
+ // A caller-supplied policy applies to this configuration only.
843
+ const previousTrustedTypesPolicy = trustedTypesPolicy;
778
844
  trustedTypesPolicy = cfg.TRUSTED_TYPES_POLICY;
779
- // Sign local variables required by `sanitize`.
780
- emptyHTML = trustedTypesPolicy.createHTML('');
845
+ // Sign local variables required by `sanitize`. If the supplied policy's
846
+ // `createHTML` is circular (i.e. it calls `DOMPurify.sanitize`), this
847
+ // throws via the re-entrancy guard. Restore the previous policy first so
848
+ // the instance is not left in a poisoned state. See #1422.
849
+ try {
850
+ emptyHTML = _createTrustedHTML('');
851
+ } catch (error) {
852
+ trustedTypesPolicy = previousTrustedTypesPolicy;
853
+ throw error;
854
+ }
855
+ } else if (cfg.TRUSTED_TYPES_POLICY === null) {
856
+ // Explicit opt-out for this call: perform no Trusted Types signing and
857
+ // create nothing (so a strict `trusted-types` CSP that disallows a
858
+ // `dompurify` policy can still call `sanitize` from inside its own
859
+ // policy — see #1422). Resetting to `undefined` rather than a sticky
860
+ // `null` also drops any previously retained caller policy, so it cannot
861
+ // resurface on a later call, while still allowing the next config-less
862
+ // call to restore the internal default policy. See GHSA-vxr8-fq34-vvx9.
863
+ trustedTypesPolicy = undefined;
864
+ emptyHTML = '';
781
865
  } else {
782
- // Uninitialized policy, attempt to initialize the internal dompurify policy.
866
+ // No policy supplied: keep the currently active policy if one is set — a
867
+ // previously supplied policy is intentionally sticky across config-less
868
+ // calls — otherwise fall back to the instance's own internal policy,
869
+ // created at most once. (A policy supplied for a *single* call still
870
+ // lingers by design; what must not linger is a policy whose configuration
871
+ // has been torn down via `clearConfig()`, which restores the default.)
783
872
  if (trustedTypesPolicy === undefined) {
784
- trustedTypesPolicy = _createTrustedTypesPolicy(trustedTypes, currentScript);
873
+ trustedTypesPolicy = _getDefaultTrustedTypesPolicy();
785
874
  }
786
- // If creating the internal policy succeeded sign internal variables.
787
- if (trustedTypesPolicy !== null && typeof emptyHTML === 'string') {
788
- emptyHTML = trustedTypesPolicy.createHTML('');
875
+ // Sign internal variables only when a policy is active. A falsy policy
876
+ // (Trusted Types unsupported, creation failed, or an explicit opt-out)
877
+ // leaves `emptyHTML` as a plain string, so we never call `.createHTML` on
878
+ // a non-policy and throw. See #1422.
879
+ if (trustedTypesPolicy && typeof emptyHTML === 'string') {
880
+ emptyHTML = _createTrustedHTML('');
789
881
  }
790
882
  }
791
883
  /*
@@ -906,7 +998,74 @@ function createDOMPurify() {
906
998
  // eslint-disable-next-line unicorn/prefer-dom-node-remove
907
999
  getParentNode(node).removeChild(node);
908
1000
  } catch (_) {
1001
+ /* The normal detach failed — this is reached for a parentless node
1002
+ (getParentNode() is null, so .removeChild throws). Element.prototype
1003
+ .remove() is itself a spec no-op on a parentless node, so a recorded
1004
+ "removal" would otherwise hand the caller back an intact,
1005
+ payload-bearing node (e.g. a detached IN_PLACE root the mXSS canary or
1006
+ the style-with-element-child rule decided to kill). Fail closed by
1007
+ throwing — exactly as a clobbered root does at the IN_PLACE entry —
1008
+ rather than trying to "neutralize" the node via its own methods.
1009
+ Neutralizing would mean calling getAttributeNames()/removeAttribute()
1010
+ on the node, both of which a <form> root can clobber via a named child
1011
+ (and _isClobbered does not even probe getAttributeNames), so the
1012
+ neutralize step could itself be silently defeated, leaving the payload
1013
+ intact. A throw touches only the cached, clobber-safe remove() and
1014
+ getParentNode(). Generalizes GHSA-r47g-fvhr-h676 (clobbered-form root)
1015
+ to every root-kill reason. REPORT-3.
1016
+ This lives inside the catch, so it never fires for a normally-removed
1017
+ in-tree node: those have a parent, removeChild() succeeds, and the
1018
+ catch is not entered. Only a kept (parentless) root reaches here. */
909
1019
  remove(node);
1020
+ if (!getParentNode(node)) {
1021
+ throw typeErrorCreate('a node selected for removal could not be detached from its tree ' + 'and cannot be safely returned; refusing to sanitize in place');
1022
+ }
1023
+ }
1024
+ };
1025
+ /**
1026
+ * _neutralizeRoot
1027
+ *
1028
+ * Fail-closed teardown of an in-place root after the sanitize walk aborts
1029
+ * (campaign-3 F2). An internal throw mid-walk — e.g. a page-registered
1030
+ * custom element's reaction detaches a node so `_forceRemove`'s deliberate
1031
+ * parentless guard throws, or any other re-entrant engine mutation — would
1032
+ * otherwise leave the caller's *live* tree half-sanitized, with everything
1033
+ * after the abort point still carrying its handlers. There is no safe way
1034
+ * to resume the walk (the tree mutated under us), so we strip the root bare:
1035
+ * remove every child and every attribute, then let the caller's catch see
1036
+ * the original error. Clobber-safe (cached `remove`/`childNodes`/`attributes`
1037
+ * getters; the root was already clobber-pre-flighted at the IN_PLACE entry).
1038
+ *
1039
+ * @param root the in-place root to empty
1040
+ */
1041
+ const _neutralizeRoot = function _neutralizeRoot(root) {
1042
+ const childNodes = getChildNodes ? getChildNodes(root) : root.childNodes;
1043
+ if (childNodes) {
1044
+ const snapshot = [];
1045
+ arrayForEach(childNodes, child => {
1046
+ arrayPush(snapshot, child);
1047
+ });
1048
+ arrayForEach(snapshot, child => {
1049
+ try {
1050
+ remove(child);
1051
+ } catch (_) {
1052
+ /* Best-effort teardown; a still-attached child is handled below */
1053
+ }
1054
+ });
1055
+ }
1056
+ const attributes = getAttributes ? getAttributes(root) : null;
1057
+ if (attributes) {
1058
+ for (let i = attributes.length - 1; i >= 0; --i) {
1059
+ const attribute = attributes[i];
1060
+ const name = attribute && attribute.name;
1061
+ if (typeof name === 'string') {
1062
+ try {
1063
+ root.removeAttribute(name);
1064
+ } catch (_) {
1065
+ /* Clobbered removeAttribute — ignore (fail-closed best effort) */
1066
+ }
1067
+ }
1068
+ }
910
1069
  }
911
1070
  };
912
1071
  /**
@@ -941,6 +1100,72 @@ function createDOMPurify() {
941
1100
  }
942
1101
  }
943
1102
  };
1103
+ /**
1104
+ * _stripDisallowedAttributes
1105
+ *
1106
+ * Removes every attribute the active configuration does not allow from a
1107
+ * single element, using the same allowlist as the main attribute pass (so
1108
+ * `on*` handlers go, but no `/^on/` blocklist is introduced). Used only to
1109
+ * neutralise nodes that are being discarded from an in-place tree.
1110
+ *
1111
+ * @param element the element to strip
1112
+ */
1113
+ const _stripDisallowedAttributes = function _stripDisallowedAttributes(element) {
1114
+ const attributes = getAttributes ? getAttributes(element) : element.attributes;
1115
+ if (!attributes) {
1116
+ return;
1117
+ }
1118
+ for (let i = attributes.length - 1; i >= 0; --i) {
1119
+ const attribute = attributes[i];
1120
+ const name = attribute && attribute.name;
1121
+ if (typeof name !== 'string' || ALLOWED_ATTR[transformCaseFunc(name)]) {
1122
+ continue;
1123
+ }
1124
+ try {
1125
+ element.removeAttribute(name);
1126
+ } catch (_) {
1127
+ /* Clobbered removeAttribute on a doomed node — ignore */
1128
+ }
1129
+ }
1130
+ };
1131
+ /**
1132
+ * _neutralizeSubtree
1133
+ *
1134
+ * Completes the audit-5 F1 fix across every removal path. The KEEP_CONTENT
1135
+ * move-hoist neutralises only disallowed-tag removals; clobber, mXSS-canary,
1136
+ * namespace, comment, processing-instruction and KEEP_CONTENT:false removals
1137
+ * all drop their subtree wholesale via `_forceRemove`. On the IN_PLACE path
1138
+ * those dropped nodes are detached from the caller's LIVE tree but a
1139
+ * handler-bearing original among them (an `<img onerror>`/`<video>` that was
1140
+ * loading) keeps its queued resource event, which fires in page scope after
1141
+ * sanitize returns. This walks a removed subtree and strips every attribute
1142
+ * the active configuration does not allow — so `on*` handlers are cancelled
1143
+ * through the SAME allowlist that governs kept nodes, not a separate `/^on/`
1144
+ * blocklist. Run synchronously before sanitize returns, i.e. before any
1145
+ * queued event can fire. Hook-free by design: these nodes leave the output,
1146
+ * so firing attribute hooks for them would be surprising. Clobber-safe reads;
1147
+ * a doomed clobbered node may shadow `removeAttribute` (its own attributes are
1148
+ * irrelevant — it is discarded — while its non-clobbered descendants, e.g.
1149
+ * the `<img>`, are reached and scrubbed).
1150
+ *
1151
+ * @param root the root of a removed subtree to neutralise
1152
+ */
1153
+ const _neutralizeSubtree = function _neutralizeSubtree(root) {
1154
+ const stack = [root];
1155
+ while (stack.length > 0) {
1156
+ const node = stack.pop();
1157
+ const nodeType = getNodeType ? getNodeType(node) : node.nodeType;
1158
+ if (nodeType === NODE_TYPE.element) {
1159
+ _stripDisallowedAttributes(node);
1160
+ }
1161
+ const childNodes = getChildNodes ? getChildNodes(node) : node.childNodes;
1162
+ if (childNodes) {
1163
+ for (let i = childNodes.length - 1; i >= 0; --i) {
1164
+ stack.push(childNodes[i]);
1165
+ }
1166
+ }
1167
+ }
1168
+ };
944
1169
  /**
945
1170
  * _initDocument
946
1171
  *
@@ -962,7 +1187,7 @@ function createDOMPurify() {
962
1187
  // Root of XHTML doc must contain xmlns declaration (see https://www.w3.org/TR/xhtml1/normative.html#strict)
963
1188
  dirty = '<html xmlns="http://www.w3.org/1999/xhtml"><head></head><body>' + dirty + '</body></html>';
964
1189
  }
965
- const dirtyPayload = trustedTypesPolicy ? trustedTypesPolicy.createHTML(dirty) : dirty;
1190
+ const dirtyPayload = trustedTypesPolicy ? _createTrustedHTML(dirty) : dirty;
966
1191
  /*
967
1192
  * Use the DOMParser API by default, fallback later if needs be
968
1193
  * DOMParser not work for svg when has multiple root element.
@@ -1021,7 +1246,8 @@ function createDOMPurify() {
1021
1246
  *
1022
1247
  * @param node The root element whose character data should be scrubbed.
1023
1248
  */
1024
- const _scrubTemplateExpressions = function _scrubTemplateExpressions(node) {
1249
+ const _scrubTemplateExpressions2 = function _scrubTemplateExpressions(node) {
1250
+ var _node$querySelectorAl, _node$querySelectorAl2;
1025
1251
  node.normalize();
1026
1252
  const walker = createNodeIterator.call(node.ownerDocument || node, node,
1027
1253
  // eslint-disable-next-line no-bitwise
@@ -1035,6 +1261,15 @@ function createDOMPurify() {
1035
1261
  currentNode.data = data;
1036
1262
  currentNode = walker.nextNode();
1037
1263
  }
1264
+ // NodeIterator does not descend into <template>.content per the DOM spec,
1265
+ // so we must explicitly recurse into each template's content fragment,
1266
+ // mirroring the approach used by _sanitizeShadowDOM.
1267
+ const templates = (_node$querySelectorAl = (_node$querySelectorAl2 = node.querySelectorAll) === null || _node$querySelectorAl2 === void 0 ? void 0 : _node$querySelectorAl2.call(node, 'template')) !== null && _node$querySelectorAl !== void 0 ? _node$querySelectorAl : [];
1268
+ arrayForEach(Array.from(templates), tmpl => {
1269
+ if (_isDocumentFragment(tmpl.content)) {
1270
+ _scrubTemplateExpressions2(tmpl.content);
1271
+ }
1272
+ });
1038
1273
  };
1039
1274
  /**
1040
1275
  * _isClobbered
@@ -1153,7 +1388,7 @@ function createDOMPurify() {
1153
1388
  return true;
1154
1389
  }
1155
1390
  /* Now let's check the element's type and name */
1156
- const tagName = transformCaseFunc(currentNode.nodeName);
1391
+ const tagName = transformCaseFunc(getNodeName ? getNodeName(currentNode) : currentNode.nodeName);
1157
1392
  /* Execute a hook if present */
1158
1393
  _executeHooks(hooks.uponSanitizeElement, currentNode, {
1159
1394
  tagName,
@@ -1203,9 +1438,28 @@ function createDOMPurify() {
1203
1438
  const childNodes = getChildNodes(currentNode);
1204
1439
  if (childNodes && parentNode) {
1205
1440
  const childCount = childNodes.length;
1441
+ /* In-place: hoist the *original* children so the iterator visits
1442
+ and sanitises them through the same allowlist pass as every other
1443
+ node. The caller built the tree in the live document, so the
1444
+ originals carry already-queued resource events (`<img onerror>`,
1445
+ `<video>`/`<audio>` error, lazy/`onload`, …); cloning would leave
1446
+ those originals detached but still armed, firing in page scope
1447
+ while the returned tree looked clean. Moving is safe in-place: the
1448
+ root is pre-validated as an allowed tag and so is never the node
1449
+ being removed, which keeps `parentNode` inside the iterator root
1450
+ and the relocated child inside the serialised tree.
1451
+ Otherwise (string / DOM-copy paths): clone. The iterator is rooted
1452
+ at — and the result serialised from — `body`, so a restrictive
1453
+ ALLOWED_TAGS that removes `body` itself must leave its content in
1454
+ place, which only cloning does; and those paths parse into an
1455
+ inert document, so their discarded originals never had a queued
1456
+ event to neutralise.
1457
+ `childNodes` is live; a tail-to-head walk keeps `childNodes[i]`
1458
+ valid whether we move (drops the trailing entry) or clone (leaves
1459
+ the list intact). */
1206
1460
  for (let i = childCount - 1; i >= 0; --i) {
1207
- const childClone = cloneNode(childNodes[i], true);
1208
- parentNode.insertBefore(childClone, getNextSibling(currentNode));
1461
+ const hoisted = IN_PLACE ? childNodes[i] : cloneNode(childNodes[i], true);
1462
+ parentNode.insertBefore(hoisted, getNextSibling(currentNode));
1209
1463
  }
1210
1464
  }
1211
1465
  }
@@ -1396,12 +1650,12 @@ function createDOMPurify() {
1396
1650
  switch (trustedTypes.getAttributeType(lcTag, lcName)) {
1397
1651
  case 'TrustedHTML':
1398
1652
  {
1399
- value = trustedTypesPolicy.createHTML(value);
1653
+ value = _createTrustedHTML(value);
1400
1654
  break;
1401
1655
  }
1402
1656
  case 'TrustedScriptURL':
1403
1657
  {
1404
- value = trustedTypesPolicy.createScriptURL(value);
1658
+ value = _createTrustedScriptURL(value);
1405
1659
  break;
1406
1660
  }
1407
1661
  }
@@ -1467,7 +1721,7 @@ function createDOMPurify() {
1467
1721
  if (shadowNodeType === NODE_TYPE.element) {
1468
1722
  const innerSr = getShadowRoot ? getShadowRoot(shadowNode) : shadowNode.shadowRoot;
1469
1723
  if (_isDocumentFragment(innerSr)) {
1470
- _sanitizeAttachedShadowRoots2(innerSr);
1724
+ _sanitizeAttachedShadowRoots(innerSr);
1471
1725
  _sanitizeShadowDOM2(innerSr);
1472
1726
  }
1473
1727
  }
@@ -1494,46 +1748,81 @@ function createDOMPurify() {
1494
1748
  *
1495
1749
  * @param root the subtree root to walk for attached shadow roots
1496
1750
  */
1497
- const _sanitizeAttachedShadowRoots2 = function _sanitizeAttachedShadowRoots(root) {
1498
- const nodeType = getNodeType ? getNodeType(root) : root.nodeType;
1499
- if (nodeType === NODE_TYPE.element) {
1500
- const sr = getShadowRoot ? getShadowRoot(root) : root.shadowRoot;
1501
- // Realm-safe check (GHSA-hpcv-96wg-7vj8): use nodeType-based
1502
- // detection rather than `instanceof DocumentFragment`, which is
1503
- // realm-bound and silently skipped shadow roots whose host element
1504
- // belonged to a foreign realm (e.g. iframe.contentDocument
1505
- // attachShadow). A foreign-realm ShadowRoot extends the foreign
1506
- // realm's DocumentFragment, not ours, so the old instanceof check
1507
- // returned false and the shadow subtree was never walked.
1508
- if (_isDocumentFragment(sr)) {
1509
- // Recurse first so that nested shadow roots are reached even if
1510
- // _sanitizeShadowDOM removes hosts at this level.
1511
- _sanitizeAttachedShadowRoots2(sr);
1512
- _sanitizeShadowDOM2(sr);
1513
- }
1514
- }
1515
- // Snapshot children before recursing. Sanitization of one subtree
1516
- // (e.g. via an uponSanitizeShadowNode hook) may detach siblings,
1517
- // and naive nextSibling traversal would silently skip the rest of
1518
- // the list once a node is detached.
1519
- const childNodes = getChildNodes ? getChildNodes(root) : root.childNodes;
1520
- if (!childNodes) {
1521
- return;
1522
- }
1523
- const snapshot = [];
1524
- arrayForEach(childNodes, child => {
1525
- arrayPush(snapshot, child);
1526
- });
1527
- for (const child of snapshot) {
1528
- _sanitizeAttachedShadowRoots2(child);
1529
- }
1530
- /* When the root is a <template>, also descend into root.content */
1531
- if (nodeType === NODE_TYPE.element) {
1532
- const rootName = getNodeName ? getNodeName(root) : null;
1533
- if (typeof rootName === 'string' && transformCaseFunc(rootName) === 'template') {
1534
- const content = root.content;
1535
- if (_isDocumentFragment(content)) {
1536
- _sanitizeAttachedShadowRoots2(content);
1751
+ const _sanitizeAttachedShadowRoots = function _sanitizeAttachedShadowRoots(root) {
1752
+ /* Iterative (explicit stack) rather than per-child recursion. DOM APIs
1753
+ impose no depth cap, so an attacker-shaped tree (JSON/CRDT/editor data
1754
+ built straight into the DOM — the IN_PLACE surface) deeper than the JS
1755
+ call-stack budget would otherwise overflow native recursion here and
1756
+ throw at the IN_PLACE entry pre-pass, before a single node is
1757
+ sanitized, leaving the caller's live tree untouched (fail-open). See
1758
+ campaign-3 F4. A heap stack keeps depth off the call stack.
1759
+ Each work item is either a node to descend into, or a deferred
1760
+ `_sanitizeShadowDOM` for an already-walked shadow root. The deferred
1761
+ form preserves the original post-order discipline: a shadow root's
1762
+ nested shadow roots are discovered before the outer shadow is
1763
+ sanitized (which may remove hosts). Pushes are in reverse of the
1764
+ desired processing order (LIFO): template content, then children, then
1765
+ the shadow-sanitize, then the shadow walk — so the order matches the
1766
+ previous recursion exactly. */
1767
+ const stack = [{
1768
+ node: root,
1769
+ shadow: null
1770
+ }];
1771
+ while (stack.length > 0) {
1772
+ const item = stack.pop();
1773
+ /* Deferred shadow-DOM sanitisation: runs after its subtree was walked. */
1774
+ if (item.shadow) {
1775
+ _sanitizeShadowDOM2(item.shadow);
1776
+ continue;
1777
+ }
1778
+ const node = item.node;
1779
+ const nodeType = getNodeType ? getNodeType(node) : node.nodeType;
1780
+ const isElement = nodeType === NODE_TYPE.element;
1781
+ /* (pushed last processed first) Children, snapshotted in reverse so
1782
+ the first child is processed first. Snapshotting matters because a
1783
+ hook may detach siblings mid-walk. */
1784
+ const childNodes = getChildNodes ? getChildNodes(node) : node.childNodes;
1785
+ if (childNodes) {
1786
+ for (let i = childNodes.length - 1; i >= 0; --i) {
1787
+ stack.push({
1788
+ node: childNodes[i],
1789
+ shadow: null
1790
+ });
1791
+ }
1792
+ }
1793
+ /* (pushed before children → processed after them, matching the old
1794
+ "template content last" order) When the node is a <template>,
1795
+ descend into its content. */
1796
+ if (isElement) {
1797
+ const rootName = getNodeName ? getNodeName(node) : null;
1798
+ if (typeof rootName === 'string' && transformCaseFunc(rootName) === 'template') {
1799
+ const content = node.content;
1800
+ if (_isDocumentFragment(content)) {
1801
+ stack.push({
1802
+ node: content,
1803
+ shadow: null
1804
+ });
1805
+ }
1806
+ }
1807
+ }
1808
+ /* Shadow root (processed first): walk its subtree, then sanitise it.
1809
+ Realm-safe check (GHSA-hpcv-96wg-7vj8): nodeType-based detection
1810
+ rather than `instanceof DocumentFragment`, which is realm-bound and
1811
+ silently skipped foreign-realm shadow roots (e.g.
1812
+ iframe.contentDocument attachShadow). */
1813
+ if (isElement) {
1814
+ const sr = getShadowRoot ? getShadowRoot(node) : node.shadowRoot;
1815
+ if (_isDocumentFragment(sr)) {
1816
+ /* Push the deferred sanitise first so it pops after the shadow
1817
+ walk we push next, i.e. nested shadow roots are discovered
1818
+ before this one is sanitised. */
1819
+ stack.push({
1820
+ node: null,
1821
+ shadow: sr
1822
+ }, {
1823
+ node: sr,
1824
+ shadow: null
1825
+ });
1537
1826
  }
1538
1827
  }
1539
1828
  }
@@ -1569,11 +1858,14 @@ function createDOMPurify() {
1569
1858
  }
1570
1859
  /* Clean up removed elements */
1571
1860
  DOMPurify.removed = [];
1572
- /* Check if dirty is correctly typed for IN_PLACE */
1573
- if (typeof dirty === 'string') {
1574
- IN_PLACE = false;
1575
- }
1576
- if (IN_PLACE) {
1861
+ /* Resolve IN_PLACE for this call without mutating persistent config.
1862
+ Writing the IN_PLACE closure variable here leaks under setConfig(),
1863
+ where _parseConfig is skipped on later calls: a single string call would
1864
+ disable in-place mode for every subsequent node call, returning a
1865
+ sanitized copy while leaving the caller's node — which in-place callers
1866
+ keep using and whose return value they ignore — unsanitized. REPORT-2. */
1867
+ const inPlace = IN_PLACE && typeof dirty !== 'string' && _isNode(dirty);
1868
+ if (inPlace) {
1577
1869
  /* Do some early pre-sanitization to avoid unsafe root nodes.
1578
1870
  Read nodeName through the cached prototype getter — a clobbering
1579
1871
  child named "nodeName" on the form root would otherwise shadow
@@ -1600,8 +1892,16 @@ function createDOMPurify() {
1600
1892
  throw typeErrorCreate('root node is clobbered and cannot be sanitized in-place');
1601
1893
  }
1602
1894
  /* Sanitize attached shadow roots before the main iterator runs.
1603
- The iterator does not descend into shadow trees. */
1604
- _sanitizeAttachedShadowRoots2(dirty);
1895
+ The iterator does not descend into shadow trees. Same fail-closed
1896
+ barrier as the main walk (campaign-3 F2): a custom-element reaction
1897
+ inside a shadow root could abort this pre-pass before the walk runs,
1898
+ which would otherwise leave the entire live tree unsanitized. */
1899
+ try {
1900
+ _sanitizeAttachedShadowRoots(dirty);
1901
+ } catch (error) {
1902
+ _neutralizeRoot(dirty);
1903
+ throw error;
1904
+ }
1605
1905
  } else if (_isNode(dirty)) {
1606
1906
  /* If dirty is a DOM element, append to an empty document to avoid
1607
1907
  elements being stripped by the parser */
@@ -1621,13 +1921,13 @@ function createDOMPurify() {
1621
1921
  descend into shadow trees. The walk routes every read through a
1622
1922
  cached prototype getter so clobbering descendants on a form root
1623
1923
  cannot hide a shadow host from this pass. */
1624
- _sanitizeAttachedShadowRoots2(importedNode);
1924
+ _sanitizeAttachedShadowRoots(importedNode);
1625
1925
  } else {
1626
1926
  /* Exit directly if we have nothing to do */
1627
1927
  if (!RETURN_DOM && !SAFE_FOR_TEMPLATES && !WHOLE_DOCUMENT &&
1628
1928
  // eslint-disable-next-line unicorn/prefer-includes
1629
1929
  dirty.indexOf('<') === -1) {
1630
- return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? trustedTypesPolicy.createHTML(dirty) : dirty;
1930
+ return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? _createTrustedHTML(dirty) : dirty;
1631
1931
  }
1632
1932
  /* Initialize the document to work on */
1633
1933
  body = _initDocument(dirty);
@@ -1641,32 +1941,59 @@ function createDOMPurify() {
1641
1941
  _forceRemove(body.firstChild);
1642
1942
  }
1643
1943
  /* Get node iterator */
1644
- const nodeIterator = _createNodeIterator(IN_PLACE ? dirty : body);
1645
- /* Now start iterating over the created document */
1646
- while (currentNode = nodeIterator.nextNode()) {
1647
- /* Sanitize tags and elements */
1648
- _sanitizeElements(currentNode);
1649
- /* Check attributes next */
1650
- _sanitizeAttributes(currentNode);
1651
- /* Shadow DOM detected, sanitize it.
1652
- Realm-safe check (GHSA-hpcv-96wg-7vj8): nodeType-based detection
1653
- instead of instanceof, so foreign-realm <template>.content is
1654
- walked correctly. */
1655
- if (_isDocumentFragment(currentNode.content)) {
1656
- _sanitizeShadowDOM2(currentNode.content);
1944
+ const nodeIterator = _createNodeIterator(inPlace ? dirty : body);
1945
+ /* Now start iterating over the created document.
1946
+ The walk runs inside an exception barrier (campaign-3 F2): a re-entrant
1947
+ engine/custom-element mutation can detach a node mid-walk so
1948
+ `_forceRemove`'s parentless guard throws, aborting the loop. Without the
1949
+ barrier the caller's in-place tree would be left half-sanitized with the
1950
+ unvisited tail still armed. On any throw we fail closed — strip the
1951
+ in-place root bare then rethrow so the existing throw contract is
1952
+ preserved. (String/DOM-copy paths never return the partial body, so the
1953
+ propagating throw is already fail-closed there.) */
1954
+ try {
1955
+ while (currentNode = nodeIterator.nextNode()) {
1956
+ /* Sanitize tags and elements */
1957
+ _sanitizeElements(currentNode);
1958
+ /* Check attributes next */
1959
+ _sanitizeAttributes(currentNode);
1960
+ /* Shadow DOM detected, sanitize it.
1961
+ Realm-safe check (GHSA-hpcv-96wg-7vj8): nodeType-based detection
1962
+ instead of instanceof, so foreign-realm <template>.content is
1963
+ walked correctly. */
1964
+ if (_isDocumentFragment(currentNode.content)) {
1965
+ _sanitizeShadowDOM2(currentNode.content);
1966
+ }
1967
+ }
1968
+ } catch (error) {
1969
+ if (inPlace) {
1970
+ _neutralizeRoot(dirty);
1657
1971
  }
1972
+ throw error;
1658
1973
  }
1659
1974
  /* If we sanitized `dirty` in-place, return it. */
1660
- if (IN_PLACE) {
1975
+ if (inPlace) {
1976
+ /* Fail-closed completion of the audit-5 F1 fix: every node removed from
1977
+ the caller's live tree is detached but may still hold a queued
1978
+ resource-event handler that fires in page scope after we return. The
1979
+ move-hoist covers only disallowed-tag KEEP_CONTENT removals; strip the
1980
+ non-allow-listed attributes off every other removed subtree (clobber,
1981
+ mXSS, namespace, comments, KEEP_CONTENT:false, …) so those handlers are
1982
+ cancelled before any event can fire. Runs synchronously, pre-return. */
1983
+ arrayForEach(DOMPurify.removed, entry => {
1984
+ if (entry.element) {
1985
+ _neutralizeSubtree(entry.element);
1986
+ }
1987
+ });
1661
1988
  if (SAFE_FOR_TEMPLATES) {
1662
- _scrubTemplateExpressions(dirty);
1989
+ _scrubTemplateExpressions2(dirty);
1663
1990
  }
1664
1991
  return dirty;
1665
1992
  }
1666
1993
  /* Return sanitized string or DOM */
1667
1994
  if (RETURN_DOM) {
1668
1995
  if (SAFE_FOR_TEMPLATES) {
1669
- _scrubTemplateExpressions(body);
1996
+ _scrubTemplateExpressions2(body);
1670
1997
  }
1671
1998
  if (RETURN_DOM_FRAGMENT) {
1672
1999
  returnNode = createDocumentFragment.call(body.ownerDocument);
@@ -1700,7 +2027,7 @@ function createDOMPurify() {
1700
2027
  serializedHTML = stringReplace(serializedHTML, expr, ' ');
1701
2028
  });
1702
2029
  }
1703
- return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? trustedTypesPolicy.createHTML(serializedHTML) : serializedHTML;
2030
+ return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? _createTrustedHTML(serializedHTML) : serializedHTML;
1704
2031
  };
1705
2032
  DOMPurify.setConfig = function () {
1706
2033
  let cfg = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
@@ -1710,6 +2037,12 @@ function createDOMPurify() {
1710
2037
  DOMPurify.clearConfig = function () {
1711
2038
  CONFIG = null;
1712
2039
  SET_CONFIG = false;
2040
+ // Drop any caller-supplied Trusted Types policy so it cannot poison later
2041
+ // `RETURN_TRUSTED_TYPE` output. The internal default policy (cached, and
2042
+ // never recreated — Trusted Types throws on duplicate names) is restored by
2043
+ // the next `_parseConfig`. See GHSA-vxr8-fq34-vvx9.
2044
+ trustedTypesPolicy = defaultTrustedTypesPolicy;
2045
+ emptyHTML = '';
1713
2046
  };
1714
2047
  DOMPurify.isValidAttribute = function (tag, attr, value) {
1715
2048
  /* Initialize shared config vars if necessary. */