dompurify 3.4.8 → 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.8 | (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.8/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
  'use strict';
4
4
 
@@ -405,7 +405,7 @@ const _createHooksMap = function _createHooksMap() {
405
405
  function createDOMPurify() {
406
406
  let window = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : getGlobal();
407
407
  const DOMPurify = root => createDOMPurify(root);
408
- DOMPurify.version = '3.4.8';
408
+ DOMPurify.version = '3.4.9';
409
409
  DOMPurify.removed = [];
410
410
  if (!window || !window.document || window.document.nodeType !== NODE_TYPE.document || !window.Element) {
411
411
  // Not running in a browser, provide a factory function
@@ -450,23 +450,54 @@ function createDOMPurify() {
450
450
  }
451
451
  let trustedTypesPolicy;
452
452
  let emptyHTML = '';
453
+ // The instance's own internal Trusted Types policy. Unlike a caller-supplied
454
+ // `TRUSTED_TYPES_POLICY`, this is created at most once — Trusted Types throws
455
+ // on duplicate policy names — and is the only policy allowed to persist
456
+ // across configurations and survive `clearConfig()`.
457
+ let defaultTrustedTypesPolicy;
458
+ let defaultTrustedTypesPolicyResolved = false;
453
459
  // Tracks whether we are already inside a call to the configured Trusted Types
454
- // policy's `createHTML`. If the supplied `TRUSTED_TYPES_POLICY.createHTML`
460
+ // policy (`createHTML` or `createScriptURL`). If a supplied policy callback
455
461
  // itself calls `DOMPurify.sanitize` (the cause of #1422), `sanitize` would
456
462
  // re-enter the policy and recurse until the stack overflows. We detect that
457
- // re-entry and throw a clear, actionable error instead.
458
- let IN_POLICY_CREATE_HTML = 0;
459
- const _createTrustedHTML = function _createTrustedHTML(html) {
460
- if (IN_POLICY_CREATE_HTML > 0) {
461
- throw typeErrorCreate('The configured TRUSTED_TYPES_POLICY.createHTML must not call ' + 'DOMPurify.sanitize, as that causes infinite recursion. Do not pass ' + 'a policy whose createHTML wraps DOMPurify as TRUSTED_TYPES_POLICY; ' + 'see the "DOMPurify and Trusted Types" section of the README.');
463
+ // re-entry and throw a clear, actionable error instead. The guard is shared
464
+ // across both callbacks, because either one re-entering `sanitize` triggers
465
+ // the same unbounded recursion.
466
+ let IN_TRUSTED_TYPES_POLICY = 0;
467
+ const _assertNotInTrustedTypesPolicy = function _assertNotInTrustedTypesPolicy() {
468
+ if (IN_TRUSTED_TYPES_POLICY > 0) {
469
+ 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.');
462
470
  }
463
- IN_POLICY_CREATE_HTML++;
471
+ };
472
+ const _createTrustedHTML = function _createTrustedHTML(html) {
473
+ _assertNotInTrustedTypesPolicy();
474
+ IN_TRUSTED_TYPES_POLICY++;
464
475
  try {
465
476
  return trustedTypesPolicy.createHTML(html);
466
477
  } finally {
467
- IN_POLICY_CREATE_HTML--;
478
+ IN_TRUSTED_TYPES_POLICY--;
468
479
  }
469
480
  };
481
+ const _createTrustedScriptURL = function _createTrustedScriptURL(scriptUrl) {
482
+ _assertNotInTrustedTypesPolicy();
483
+ IN_TRUSTED_TYPES_POLICY++;
484
+ try {
485
+ return trustedTypesPolicy.createScriptURL(scriptUrl);
486
+ } finally {
487
+ IN_TRUSTED_TYPES_POLICY--;
488
+ }
489
+ };
490
+ // Lazily resolve (and cache) the instance's internal default policy.
491
+ // Resolution is attempted at most once: a successful `createPolicy` cannot be
492
+ // repeated (Trusted Types throws on duplicate names), and a failed or
493
+ // unsupported attempt must not be retried on every parse.
494
+ const _getDefaultTrustedTypesPolicy = function _getDefaultTrustedTypesPolicy() {
495
+ if (!defaultTrustedTypesPolicyResolved) {
496
+ defaultTrustedTypesPolicy = _createTrustedTypesPolicy(trustedTypes, currentScript);
497
+ defaultTrustedTypesPolicyResolved = true;
498
+ }
499
+ return defaultTrustedTypesPolicy;
500
+ };
470
501
  const _document = document,
471
502
  implementation = _document.implementation,
472
503
  createNodeIterator = _document.createNodeIterator,
@@ -605,7 +636,17 @@ function createDOMPurify() {
605
636
  let USE_PROFILES = {};
606
637
  /* Tags to ignore content of when KEEP_CONTENT is true */
607
638
  let FORBID_CONTENTS = null;
608
- 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']);
639
+ const DEFAULT_FORBID_CONTENTS = addToSet({}, ['annotation-xml', 'audio', 'colgroup', 'desc', 'foreignobject', 'head', 'iframe', 'math', 'mi', 'mn', 'mo', 'ms', 'mtext', 'noembed', 'noframes', 'noscript', 'plaintext', 'script',
640
+ // <selectedcontent> mirrors the selected <option>'s subtree, cloned by
641
+ // the UA (customizable <select>) — including any on* handlers — and the
642
+ // engine re-mirrors synchronously whenever a removal changes which
643
+ // option/selectedcontent is current, even inside DOMPurify's inert
644
+ // DOMParser document. Hoisting its children on removal re-inserts a fresh
645
+ // mirror target ahead of the walk, which the engine refills, looping
646
+ // forever (DoS) and amplifying output. Dropping its content on removal
647
+ // (rather than hoisting) breaks that cascade; the content is a duplicate
648
+ // of the option, which is sanitized on its own. See campaign-3 F1/F6.
649
+ 'selectedcontent', 'style', 'svg', 'template', 'thead', 'title', 'video', 'xmp']);
609
650
  /* Tags that are safe for data: URIs */
610
651
  let DATA_URI_TAGS = null;
611
652
  const DEFAULT_DATA_URI_TAGS = addToSet({}, ['audio', 'video', 'img', 'source', 'image', 'track']);
@@ -786,6 +827,13 @@ function createDOMPurify() {
786
827
  addToSet(ALLOWED_TAGS, ['tbody']);
787
828
  delete FORBID_TAGS.tbody;
788
829
  }
830
+ // Re-derive the active Trusted Types policy from this configuration on
831
+ // every parse. The active policy must never be sticky closure state that
832
+ // outlives the config that set it: a caller-supplied policy left in place
833
+ // after `clearConfig()` — or after a later call that supplied none, or
834
+ // `TRUSTED_TYPES_POLICY: null` — could sign a subsequent "default"
835
+ // `RETURN_TRUSTED_TYPE` result with a foreign, possibly unsafe policy.
836
+ // See GHSA-vxr8-fq34-vvx9.
789
837
  if (cfg.TRUSTED_TYPES_POLICY) {
790
838
  if (typeof cfg.TRUSTED_TYPES_POLICY.createHTML !== 'function') {
791
839
  throw typeErrorCreate('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');
@@ -793,7 +841,7 @@ function createDOMPurify() {
793
841
  if (typeof cfg.TRUSTED_TYPES_POLICY.createScriptURL !== 'function') {
794
842
  throw typeErrorCreate('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');
795
843
  }
796
- // Overwrite existing TrustedTypes policy.
844
+ // A caller-supplied policy applies to this configuration only.
797
845
  const previousTrustedTypesPolicy = trustedTypesPolicy;
798
846
  trustedTypesPolicy = cfg.TRUSTED_TYPES_POLICY;
799
847
  // Sign local variables required by `sanitize`. If the supplied policy's
@@ -806,16 +854,30 @@ function createDOMPurify() {
806
854
  trustedTypesPolicy = previousTrustedTypesPolicy;
807
855
  throw error;
808
856
  }
857
+ } else if (cfg.TRUSTED_TYPES_POLICY === null) {
858
+ // Explicit opt-out for this call: perform no Trusted Types signing and
859
+ // create nothing (so a strict `trusted-types` CSP that disallows a
860
+ // `dompurify` policy can still call `sanitize` from inside its own
861
+ // policy — see #1422). Resetting to `undefined` rather than a sticky
862
+ // `null` also drops any previously retained caller policy, so it cannot
863
+ // resurface on a later call, while still allowing the next config-less
864
+ // call to restore the internal default policy. See GHSA-vxr8-fq34-vvx9.
865
+ trustedTypesPolicy = undefined;
866
+ emptyHTML = '';
809
867
  } else {
810
- // Uninitialized policy, attempt to initialize the internal dompurify policy.
811
- if (trustedTypesPolicy === undefined && cfg.TRUSTED_TYPES_POLICY !== null) {
812
- trustedTypesPolicy = _createTrustedTypesPolicy(trustedTypes, currentScript);
813
- }
814
- // If creating the internal policy succeeded sign internal variables.
815
- // Note: a falsy `trustedTypesPolicy` (null when policy creation failed or
816
- // was skipped via `TRUSTED_TYPES_POLICY: null`, or undefined when no
817
- // policy has been initialized yet) must be excluded here, otherwise we
818
- // would call `.createHTML` on a non-policy and throw. See #1422.
868
+ // No policy supplied: keep the currently active policy if one is set — a
869
+ // previously supplied policy is intentionally sticky across config-less
870
+ // calls — otherwise fall back to the instance's own internal policy,
871
+ // created at most once. (A policy supplied for a *single* call still
872
+ // lingers by design; what must not linger is a policy whose configuration
873
+ // has been torn down via `clearConfig()`, which restores the default.)
874
+ if (trustedTypesPolicy === undefined) {
875
+ trustedTypesPolicy = _getDefaultTrustedTypesPolicy();
876
+ }
877
+ // Sign internal variables only when a policy is active. A falsy policy
878
+ // (Trusted Types unsupported, creation failed, or an explicit opt-out)
879
+ // leaves `emptyHTML` as a plain string, so we never call `.createHTML` on
880
+ // a non-policy and throw. See #1422.
819
881
  if (trustedTypesPolicy && typeof emptyHTML === 'string') {
820
882
  emptyHTML = _createTrustedHTML('');
821
883
  }
@@ -938,7 +1000,74 @@ function createDOMPurify() {
938
1000
  // eslint-disable-next-line unicorn/prefer-dom-node-remove
939
1001
  getParentNode(node).removeChild(node);
940
1002
  } catch (_) {
1003
+ /* The normal detach failed — this is reached for a parentless node
1004
+ (getParentNode() is null, so .removeChild throws). Element.prototype
1005
+ .remove() is itself a spec no-op on a parentless node, so a recorded
1006
+ "removal" would otherwise hand the caller back an intact,
1007
+ payload-bearing node (e.g. a detached IN_PLACE root the mXSS canary or
1008
+ the style-with-element-child rule decided to kill). Fail closed by
1009
+ throwing — exactly as a clobbered root does at the IN_PLACE entry —
1010
+ rather than trying to "neutralize" the node via its own methods.
1011
+ Neutralizing would mean calling getAttributeNames()/removeAttribute()
1012
+ on the node, both of which a <form> root can clobber via a named child
1013
+ (and _isClobbered does not even probe getAttributeNames), so the
1014
+ neutralize step could itself be silently defeated, leaving the payload
1015
+ intact. A throw touches only the cached, clobber-safe remove() and
1016
+ getParentNode(). Generalizes GHSA-r47g-fvhr-h676 (clobbered-form root)
1017
+ to every root-kill reason. REPORT-3.
1018
+ This lives inside the catch, so it never fires for a normally-removed
1019
+ in-tree node: those have a parent, removeChild() succeeds, and the
1020
+ catch is not entered. Only a kept (parentless) root reaches here. */
941
1021
  remove(node);
1022
+ if (!getParentNode(node)) {
1023
+ throw typeErrorCreate('a node selected for removal could not be detached from its tree ' + 'and cannot be safely returned; refusing to sanitize in place');
1024
+ }
1025
+ }
1026
+ };
1027
+ /**
1028
+ * _neutralizeRoot
1029
+ *
1030
+ * Fail-closed teardown of an in-place root after the sanitize walk aborts
1031
+ * (campaign-3 F2). An internal throw mid-walk — e.g. a page-registered
1032
+ * custom element's reaction detaches a node so `_forceRemove`'s deliberate
1033
+ * parentless guard throws, or any other re-entrant engine mutation — would
1034
+ * otherwise leave the caller's *live* tree half-sanitized, with everything
1035
+ * after the abort point still carrying its handlers. There is no safe way
1036
+ * to resume the walk (the tree mutated under us), so we strip the root bare:
1037
+ * remove every child and every attribute, then let the caller's catch see
1038
+ * the original error. Clobber-safe (cached `remove`/`childNodes`/`attributes`
1039
+ * getters; the root was already clobber-pre-flighted at the IN_PLACE entry).
1040
+ *
1041
+ * @param root the in-place root to empty
1042
+ */
1043
+ const _neutralizeRoot = function _neutralizeRoot(root) {
1044
+ const childNodes = getChildNodes ? getChildNodes(root) : root.childNodes;
1045
+ if (childNodes) {
1046
+ const snapshot = [];
1047
+ arrayForEach(childNodes, child => {
1048
+ arrayPush(snapshot, child);
1049
+ });
1050
+ arrayForEach(snapshot, child => {
1051
+ try {
1052
+ remove(child);
1053
+ } catch (_) {
1054
+ /* Best-effort teardown; a still-attached child is handled below */
1055
+ }
1056
+ });
1057
+ }
1058
+ const attributes = getAttributes ? getAttributes(root) : null;
1059
+ if (attributes) {
1060
+ for (let i = attributes.length - 1; i >= 0; --i) {
1061
+ const attribute = attributes[i];
1062
+ const name = attribute && attribute.name;
1063
+ if (typeof name === 'string') {
1064
+ try {
1065
+ root.removeAttribute(name);
1066
+ } catch (_) {
1067
+ /* Clobbered removeAttribute — ignore (fail-closed best effort) */
1068
+ }
1069
+ }
1070
+ }
942
1071
  }
943
1072
  };
944
1073
  /**
@@ -973,6 +1102,72 @@ function createDOMPurify() {
973
1102
  }
974
1103
  }
975
1104
  };
1105
+ /**
1106
+ * _stripDisallowedAttributes
1107
+ *
1108
+ * Removes every attribute the active configuration does not allow from a
1109
+ * single element, using the same allowlist as the main attribute pass (so
1110
+ * `on*` handlers go, but no `/^on/` blocklist is introduced). Used only to
1111
+ * neutralise nodes that are being discarded from an in-place tree.
1112
+ *
1113
+ * @param element the element to strip
1114
+ */
1115
+ const _stripDisallowedAttributes = function _stripDisallowedAttributes(element) {
1116
+ const attributes = getAttributes ? getAttributes(element) : element.attributes;
1117
+ if (!attributes) {
1118
+ return;
1119
+ }
1120
+ for (let i = attributes.length - 1; i >= 0; --i) {
1121
+ const attribute = attributes[i];
1122
+ const name = attribute && attribute.name;
1123
+ if (typeof name !== 'string' || ALLOWED_ATTR[transformCaseFunc(name)]) {
1124
+ continue;
1125
+ }
1126
+ try {
1127
+ element.removeAttribute(name);
1128
+ } catch (_) {
1129
+ /* Clobbered removeAttribute on a doomed node — ignore */
1130
+ }
1131
+ }
1132
+ };
1133
+ /**
1134
+ * _neutralizeSubtree
1135
+ *
1136
+ * Completes the audit-5 F1 fix across every removal path. The KEEP_CONTENT
1137
+ * move-hoist neutralises only disallowed-tag removals; clobber, mXSS-canary,
1138
+ * namespace, comment, processing-instruction and KEEP_CONTENT:false removals
1139
+ * all drop their subtree wholesale via `_forceRemove`. On the IN_PLACE path
1140
+ * those dropped nodes are detached from the caller's LIVE tree but a
1141
+ * handler-bearing original among them (an `<img onerror>`/`<video>` that was
1142
+ * loading) keeps its queued resource event, which fires in page scope after
1143
+ * sanitize returns. This walks a removed subtree and strips every attribute
1144
+ * the active configuration does not allow — so `on*` handlers are cancelled
1145
+ * through the SAME allowlist that governs kept nodes, not a separate `/^on/`
1146
+ * blocklist. Run synchronously before sanitize returns, i.e. before any
1147
+ * queued event can fire. Hook-free by design: these nodes leave the output,
1148
+ * so firing attribute hooks for them would be surprising. Clobber-safe reads;
1149
+ * a doomed clobbered node may shadow `removeAttribute` (its own attributes are
1150
+ * irrelevant — it is discarded — while its non-clobbered descendants, e.g.
1151
+ * the `<img>`, are reached and scrubbed).
1152
+ *
1153
+ * @param root the root of a removed subtree to neutralise
1154
+ */
1155
+ const _neutralizeSubtree = function _neutralizeSubtree(root) {
1156
+ const stack = [root];
1157
+ while (stack.length > 0) {
1158
+ const node = stack.pop();
1159
+ const nodeType = getNodeType ? getNodeType(node) : node.nodeType;
1160
+ if (nodeType === NODE_TYPE.element) {
1161
+ _stripDisallowedAttributes(node);
1162
+ }
1163
+ const childNodes = getChildNodes ? getChildNodes(node) : node.childNodes;
1164
+ if (childNodes) {
1165
+ for (let i = childNodes.length - 1; i >= 0; --i) {
1166
+ stack.push(childNodes[i]);
1167
+ }
1168
+ }
1169
+ }
1170
+ };
976
1171
  /**
977
1172
  * _initDocument
978
1173
  *
@@ -1245,9 +1440,28 @@ function createDOMPurify() {
1245
1440
  const childNodes = getChildNodes(currentNode);
1246
1441
  if (childNodes && parentNode) {
1247
1442
  const childCount = childNodes.length;
1443
+ /* In-place: hoist the *original* children so the iterator visits
1444
+ and sanitises them through the same allowlist pass as every other
1445
+ node. The caller built the tree in the live document, so the
1446
+ originals carry already-queued resource events (`<img onerror>`,
1447
+ `<video>`/`<audio>` error, lazy/`onload`, …); cloning would leave
1448
+ those originals detached but still armed, firing in page scope
1449
+ while the returned tree looked clean. Moving is safe in-place: the
1450
+ root is pre-validated as an allowed tag and so is never the node
1451
+ being removed, which keeps `parentNode` inside the iterator root
1452
+ and the relocated child inside the serialised tree.
1453
+ Otherwise (string / DOM-copy paths): clone. The iterator is rooted
1454
+ at — and the result serialised from — `body`, so a restrictive
1455
+ ALLOWED_TAGS that removes `body` itself must leave its content in
1456
+ place, which only cloning does; and those paths parse into an
1457
+ inert document, so their discarded originals never had a queued
1458
+ event to neutralise.
1459
+ `childNodes` is live; a tail-to-head walk keeps `childNodes[i]`
1460
+ valid whether we move (drops the trailing entry) or clone (leaves
1461
+ the list intact). */
1248
1462
  for (let i = childCount - 1; i >= 0; --i) {
1249
- const childClone = cloneNode(childNodes[i], true);
1250
- parentNode.insertBefore(childClone, getNextSibling(currentNode));
1463
+ const hoisted = IN_PLACE ? childNodes[i] : cloneNode(childNodes[i], true);
1464
+ parentNode.insertBefore(hoisted, getNextSibling(currentNode));
1251
1465
  }
1252
1466
  }
1253
1467
  }
@@ -1443,7 +1657,7 @@ function createDOMPurify() {
1443
1657
  }
1444
1658
  case 'TrustedScriptURL':
1445
1659
  {
1446
- value = trustedTypesPolicy.createScriptURL(value);
1660
+ value = _createTrustedScriptURL(value);
1447
1661
  break;
1448
1662
  }
1449
1663
  }
@@ -1509,7 +1723,7 @@ function createDOMPurify() {
1509
1723
  if (shadowNodeType === NODE_TYPE.element) {
1510
1724
  const innerSr = getShadowRoot ? getShadowRoot(shadowNode) : shadowNode.shadowRoot;
1511
1725
  if (_isDocumentFragment(innerSr)) {
1512
- _sanitizeAttachedShadowRoots2(innerSr);
1726
+ _sanitizeAttachedShadowRoots(innerSr);
1513
1727
  _sanitizeShadowDOM2(innerSr);
1514
1728
  }
1515
1729
  }
@@ -1536,46 +1750,81 @@ function createDOMPurify() {
1536
1750
  *
1537
1751
  * @param root the subtree root to walk for attached shadow roots
1538
1752
  */
1539
- const _sanitizeAttachedShadowRoots2 = function _sanitizeAttachedShadowRoots(root) {
1540
- const nodeType = getNodeType ? getNodeType(root) : root.nodeType;
1541
- if (nodeType === NODE_TYPE.element) {
1542
- const sr = getShadowRoot ? getShadowRoot(root) : root.shadowRoot;
1543
- // Realm-safe check (GHSA-hpcv-96wg-7vj8): use nodeType-based
1544
- // detection rather than `instanceof DocumentFragment`, which is
1545
- // realm-bound and silently skipped shadow roots whose host element
1546
- // belonged to a foreign realm (e.g. iframe.contentDocument
1547
- // attachShadow). A foreign-realm ShadowRoot extends the foreign
1548
- // realm's DocumentFragment, not ours, so the old instanceof check
1549
- // returned false and the shadow subtree was never walked.
1550
- if (_isDocumentFragment(sr)) {
1551
- // Recurse first so that nested shadow roots are reached even if
1552
- // _sanitizeShadowDOM removes hosts at this level.
1553
- _sanitizeAttachedShadowRoots2(sr);
1554
- _sanitizeShadowDOM2(sr);
1555
- }
1556
- }
1557
- // Snapshot children before recursing. Sanitization of one subtree
1558
- // (e.g. via an uponSanitizeShadowNode hook) may detach siblings,
1559
- // and naive nextSibling traversal would silently skip the rest of
1560
- // the list once a node is detached.
1561
- const childNodes = getChildNodes ? getChildNodes(root) : root.childNodes;
1562
- if (!childNodes) {
1563
- return;
1564
- }
1565
- const snapshot = [];
1566
- arrayForEach(childNodes, child => {
1567
- arrayPush(snapshot, child);
1568
- });
1569
- for (const child of snapshot) {
1570
- _sanitizeAttachedShadowRoots2(child);
1571
- }
1572
- /* When the root is a <template>, also descend into root.content */
1573
- if (nodeType === NODE_TYPE.element) {
1574
- const rootName = getNodeName ? getNodeName(root) : null;
1575
- if (typeof rootName === 'string' && transformCaseFunc(rootName) === 'template') {
1576
- const content = root.content;
1577
- if (_isDocumentFragment(content)) {
1578
- _sanitizeAttachedShadowRoots2(content);
1753
+ const _sanitizeAttachedShadowRoots = function _sanitizeAttachedShadowRoots(root) {
1754
+ /* Iterative (explicit stack) rather than per-child recursion. DOM APIs
1755
+ impose no depth cap, so an attacker-shaped tree (JSON/CRDT/editor data
1756
+ built straight into the DOM — the IN_PLACE surface) deeper than the JS
1757
+ call-stack budget would otherwise overflow native recursion here and
1758
+ throw at the IN_PLACE entry pre-pass, before a single node is
1759
+ sanitized, leaving the caller's live tree untouched (fail-open). See
1760
+ campaign-3 F4. A heap stack keeps depth off the call stack.
1761
+ Each work item is either a node to descend into, or a deferred
1762
+ `_sanitizeShadowDOM` for an already-walked shadow root. The deferred
1763
+ form preserves the original post-order discipline: a shadow root's
1764
+ nested shadow roots are discovered before the outer shadow is
1765
+ sanitized (which may remove hosts). Pushes are in reverse of the
1766
+ desired processing order (LIFO): template content, then children, then
1767
+ the shadow-sanitize, then the shadow walk — so the order matches the
1768
+ previous recursion exactly. */
1769
+ const stack = [{
1770
+ node: root,
1771
+ shadow: null
1772
+ }];
1773
+ while (stack.length > 0) {
1774
+ const item = stack.pop();
1775
+ /* Deferred shadow-DOM sanitisation: runs after its subtree was walked. */
1776
+ if (item.shadow) {
1777
+ _sanitizeShadowDOM2(item.shadow);
1778
+ continue;
1779
+ }
1780
+ const node = item.node;
1781
+ const nodeType = getNodeType ? getNodeType(node) : node.nodeType;
1782
+ const isElement = nodeType === NODE_TYPE.element;
1783
+ /* (pushed last processed first) Children, snapshotted in reverse so
1784
+ the first child is processed first. Snapshotting matters because a
1785
+ hook may detach siblings mid-walk. */
1786
+ const childNodes = getChildNodes ? getChildNodes(node) : node.childNodes;
1787
+ if (childNodes) {
1788
+ for (let i = childNodes.length - 1; i >= 0; --i) {
1789
+ stack.push({
1790
+ node: childNodes[i],
1791
+ shadow: null
1792
+ });
1793
+ }
1794
+ }
1795
+ /* (pushed before children → processed after them, matching the old
1796
+ "template content last" order) When the node is a <template>,
1797
+ descend into its content. */
1798
+ if (isElement) {
1799
+ const rootName = getNodeName ? getNodeName(node) : null;
1800
+ if (typeof rootName === 'string' && transformCaseFunc(rootName) === 'template') {
1801
+ const content = node.content;
1802
+ if (_isDocumentFragment(content)) {
1803
+ stack.push({
1804
+ node: content,
1805
+ shadow: null
1806
+ });
1807
+ }
1808
+ }
1809
+ }
1810
+ /* Shadow root (processed first): walk its subtree, then sanitise it.
1811
+ Realm-safe check (GHSA-hpcv-96wg-7vj8): nodeType-based detection
1812
+ rather than `instanceof DocumentFragment`, which is realm-bound and
1813
+ silently skipped foreign-realm shadow roots (e.g.
1814
+ iframe.contentDocument attachShadow). */
1815
+ if (isElement) {
1816
+ const sr = getShadowRoot ? getShadowRoot(node) : node.shadowRoot;
1817
+ if (_isDocumentFragment(sr)) {
1818
+ /* Push the deferred sanitise first so it pops after the shadow
1819
+ walk we push next, i.e. nested shadow roots are discovered
1820
+ before this one is sanitised. */
1821
+ stack.push({
1822
+ node: null,
1823
+ shadow: sr
1824
+ }, {
1825
+ node: sr,
1826
+ shadow: null
1827
+ });
1579
1828
  }
1580
1829
  }
1581
1830
  }
@@ -1611,11 +1860,14 @@ function createDOMPurify() {
1611
1860
  }
1612
1861
  /* Clean up removed elements */
1613
1862
  DOMPurify.removed = [];
1614
- /* Check if dirty is correctly typed for IN_PLACE */
1615
- if (typeof dirty === 'string') {
1616
- IN_PLACE = false;
1617
- }
1618
- if (IN_PLACE) {
1863
+ /* Resolve IN_PLACE for this call without mutating persistent config.
1864
+ Writing the IN_PLACE closure variable here leaks under setConfig(),
1865
+ where _parseConfig is skipped on later calls: a single string call would
1866
+ disable in-place mode for every subsequent node call, returning a
1867
+ sanitized copy while leaving the caller's node — which in-place callers
1868
+ keep using and whose return value they ignore — unsanitized. REPORT-2. */
1869
+ const inPlace = IN_PLACE && typeof dirty !== 'string' && _isNode(dirty);
1870
+ if (inPlace) {
1619
1871
  /* Do some early pre-sanitization to avoid unsafe root nodes.
1620
1872
  Read nodeName through the cached prototype getter — a clobbering
1621
1873
  child named "nodeName" on the form root would otherwise shadow
@@ -1642,8 +1894,16 @@ function createDOMPurify() {
1642
1894
  throw typeErrorCreate('root node is clobbered and cannot be sanitized in-place');
1643
1895
  }
1644
1896
  /* Sanitize attached shadow roots before the main iterator runs.
1645
- The iterator does not descend into shadow trees. */
1646
- _sanitizeAttachedShadowRoots2(dirty);
1897
+ The iterator does not descend into shadow trees. Same fail-closed
1898
+ barrier as the main walk (campaign-3 F2): a custom-element reaction
1899
+ inside a shadow root could abort this pre-pass before the walk runs,
1900
+ which would otherwise leave the entire live tree unsanitized. */
1901
+ try {
1902
+ _sanitizeAttachedShadowRoots(dirty);
1903
+ } catch (error) {
1904
+ _neutralizeRoot(dirty);
1905
+ throw error;
1906
+ }
1647
1907
  } else if (_isNode(dirty)) {
1648
1908
  /* If dirty is a DOM element, append to an empty document to avoid
1649
1909
  elements being stripped by the parser */
@@ -1663,7 +1923,7 @@ function createDOMPurify() {
1663
1923
  descend into shadow trees. The walk routes every read through a
1664
1924
  cached prototype getter so clobbering descendants on a form root
1665
1925
  cannot hide a shadow host from this pass. */
1666
- _sanitizeAttachedShadowRoots2(importedNode);
1926
+ _sanitizeAttachedShadowRoots(importedNode);
1667
1927
  } else {
1668
1928
  /* Exit directly if we have nothing to do */
1669
1929
  if (!RETURN_DOM && !SAFE_FOR_TEMPLATES && !WHOLE_DOCUMENT &&
@@ -1683,23 +1943,50 @@ function createDOMPurify() {
1683
1943
  _forceRemove(body.firstChild);
1684
1944
  }
1685
1945
  /* Get node iterator */
1686
- const nodeIterator = _createNodeIterator(IN_PLACE ? dirty : body);
1687
- /* Now start iterating over the created document */
1688
- while (currentNode = nodeIterator.nextNode()) {
1689
- /* Sanitize tags and elements */
1690
- _sanitizeElements(currentNode);
1691
- /* Check attributes next */
1692
- _sanitizeAttributes(currentNode);
1693
- /* Shadow DOM detected, sanitize it.
1694
- Realm-safe check (GHSA-hpcv-96wg-7vj8): nodeType-based detection
1695
- instead of instanceof, so foreign-realm <template>.content is
1696
- walked correctly. */
1697
- if (_isDocumentFragment(currentNode.content)) {
1698
- _sanitizeShadowDOM2(currentNode.content);
1946
+ const nodeIterator = _createNodeIterator(inPlace ? dirty : body);
1947
+ /* Now start iterating over the created document.
1948
+ The walk runs inside an exception barrier (campaign-3 F2): a re-entrant
1949
+ engine/custom-element mutation can detach a node mid-walk so
1950
+ `_forceRemove`'s parentless guard throws, aborting the loop. Without the
1951
+ barrier the caller's in-place tree would be left half-sanitized with the
1952
+ unvisited tail still armed. On any throw we fail closed — strip the
1953
+ in-place root bare then rethrow so the existing throw contract is
1954
+ preserved. (String/DOM-copy paths never return the partial body, so the
1955
+ propagating throw is already fail-closed there.) */
1956
+ try {
1957
+ while (currentNode = nodeIterator.nextNode()) {
1958
+ /* Sanitize tags and elements */
1959
+ _sanitizeElements(currentNode);
1960
+ /* Check attributes next */
1961
+ _sanitizeAttributes(currentNode);
1962
+ /* Shadow DOM detected, sanitize it.
1963
+ Realm-safe check (GHSA-hpcv-96wg-7vj8): nodeType-based detection
1964
+ instead of instanceof, so foreign-realm <template>.content is
1965
+ walked correctly. */
1966
+ if (_isDocumentFragment(currentNode.content)) {
1967
+ _sanitizeShadowDOM2(currentNode.content);
1968
+ }
1969
+ }
1970
+ } catch (error) {
1971
+ if (inPlace) {
1972
+ _neutralizeRoot(dirty);
1699
1973
  }
1974
+ throw error;
1700
1975
  }
1701
1976
  /* If we sanitized `dirty` in-place, return it. */
1702
- if (IN_PLACE) {
1977
+ if (inPlace) {
1978
+ /* Fail-closed completion of the audit-5 F1 fix: every node removed from
1979
+ the caller's live tree is detached but may still hold a queued
1980
+ resource-event handler that fires in page scope after we return. The
1981
+ move-hoist covers only disallowed-tag KEEP_CONTENT removals; strip the
1982
+ non-allow-listed attributes off every other removed subtree (clobber,
1983
+ mXSS, namespace, comments, KEEP_CONTENT:false, …) so those handlers are
1984
+ cancelled before any event can fire. Runs synchronously, pre-return. */
1985
+ arrayForEach(DOMPurify.removed, entry => {
1986
+ if (entry.element) {
1987
+ _neutralizeSubtree(entry.element);
1988
+ }
1989
+ });
1703
1990
  if (SAFE_FOR_TEMPLATES) {
1704
1991
  _scrubTemplateExpressions2(dirty);
1705
1992
  }
@@ -1752,6 +2039,12 @@ function createDOMPurify() {
1752
2039
  DOMPurify.clearConfig = function () {
1753
2040
  CONFIG = null;
1754
2041
  SET_CONFIG = false;
2042
+ // Drop any caller-supplied Trusted Types policy so it cannot poison later
2043
+ // `RETURN_TRUSTED_TYPE` output. The internal default policy (cached, and
2044
+ // never recreated — Trusted Types throws on duplicate names) is restored by
2045
+ // the next `_parseConfig`. See GHSA-vxr8-fq34-vvx9.
2046
+ trustedTypesPolicy = defaultTrustedTypesPolicy;
2047
+ emptyHTML = '';
1755
2048
  };
1756
2049
  DOMPurify.isValidAttribute = function (tag, attr, value) {
1757
2050
  /* Initialize shared config vars if necessary. */