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/dist/purify.js CHANGED
@@ -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 (global, factory) {
4
4
  typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
@@ -409,7 +409,7 @@
409
409
  function createDOMPurify() {
410
410
  let window = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : getGlobal();
411
411
  const DOMPurify = root => createDOMPurify(root);
412
- DOMPurify.version = '3.4.7';
412
+ DOMPurify.version = '3.4.9';
413
413
  DOMPurify.removed = [];
414
414
  if (!window || !window.document || window.document.nodeType !== NODE_TYPE.document || !window.Element) {
415
415
  // Not running in a browser, provide a factory function
@@ -454,6 +454,54 @@
454
454
  }
455
455
  let trustedTypesPolicy;
456
456
  let emptyHTML = '';
457
+ // The instance's own internal Trusted Types policy. Unlike a caller-supplied
458
+ // `TRUSTED_TYPES_POLICY`, this is created at most once — Trusted Types throws
459
+ // on duplicate policy names — and is the only policy allowed to persist
460
+ // across configurations and survive `clearConfig()`.
461
+ let defaultTrustedTypesPolicy;
462
+ let defaultTrustedTypesPolicyResolved = false;
463
+ // Tracks whether we are already inside a call to the configured Trusted Types
464
+ // policy (`createHTML` or `createScriptURL`). If a supplied policy callback
465
+ // itself calls `DOMPurify.sanitize` (the cause of #1422), `sanitize` would
466
+ // re-enter the policy and recurse until the stack overflows. We detect that
467
+ // re-entry and throw a clear, actionable error instead. The guard is shared
468
+ // across both callbacks, because either one re-entering `sanitize` triggers
469
+ // the same unbounded recursion.
470
+ let IN_TRUSTED_TYPES_POLICY = 0;
471
+ const _assertNotInTrustedTypesPolicy = function _assertNotInTrustedTypesPolicy() {
472
+ if (IN_TRUSTED_TYPES_POLICY > 0) {
473
+ 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.');
474
+ }
475
+ };
476
+ const _createTrustedHTML = function _createTrustedHTML(html) {
477
+ _assertNotInTrustedTypesPolicy();
478
+ IN_TRUSTED_TYPES_POLICY++;
479
+ try {
480
+ return trustedTypesPolicy.createHTML(html);
481
+ } finally {
482
+ IN_TRUSTED_TYPES_POLICY--;
483
+ }
484
+ };
485
+ const _createTrustedScriptURL = function _createTrustedScriptURL(scriptUrl) {
486
+ _assertNotInTrustedTypesPolicy();
487
+ IN_TRUSTED_TYPES_POLICY++;
488
+ try {
489
+ return trustedTypesPolicy.createScriptURL(scriptUrl);
490
+ } finally {
491
+ IN_TRUSTED_TYPES_POLICY--;
492
+ }
493
+ };
494
+ // Lazily resolve (and cache) the instance's internal default policy.
495
+ // Resolution is attempted at most once: a successful `createPolicy` cannot be
496
+ // repeated (Trusted Types throws on duplicate names), and a failed or
497
+ // unsupported attempt must not be retried on every parse.
498
+ const _getDefaultTrustedTypesPolicy = function _getDefaultTrustedTypesPolicy() {
499
+ if (!defaultTrustedTypesPolicyResolved) {
500
+ defaultTrustedTypesPolicy = _createTrustedTypesPolicy(trustedTypes, currentScript);
501
+ defaultTrustedTypesPolicyResolved = true;
502
+ }
503
+ return defaultTrustedTypesPolicy;
504
+ };
457
505
  const _document = document,
458
506
  implementation = _document.implementation,
459
507
  createNodeIterator = _document.createNodeIterator,
@@ -592,7 +640,17 @@
592
640
  let USE_PROFILES = {};
593
641
  /* Tags to ignore content of when KEEP_CONTENT is true */
594
642
  let FORBID_CONTENTS = null;
595
- 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']);
643
+ const DEFAULT_FORBID_CONTENTS = addToSet({}, ['annotation-xml', 'audio', 'colgroup', 'desc', 'foreignobject', 'head', 'iframe', 'math', 'mi', 'mn', 'mo', 'ms', 'mtext', 'noembed', 'noframes', 'noscript', 'plaintext', 'script',
644
+ // <selectedcontent> mirrors the selected <option>'s subtree, cloned by
645
+ // the UA (customizable <select>) — including any on* handlers — and the
646
+ // engine re-mirrors synchronously whenever a removal changes which
647
+ // option/selectedcontent is current, even inside DOMPurify's inert
648
+ // DOMParser document. Hoisting its children on removal re-inserts a fresh
649
+ // mirror target ahead of the walk, which the engine refills, looping
650
+ // forever (DoS) and amplifying output. Dropping its content on removal
651
+ // (rather than hoisting) breaks that cascade; the content is a duplicate
652
+ // of the option, which is sanitized on its own. See campaign-3 F1/F6.
653
+ 'selectedcontent', 'style', 'svg', 'template', 'thead', 'title', 'video', 'xmp']);
596
654
  /* Tags that are safe for data: URIs */
597
655
  let DATA_URI_TAGS = null;
598
656
  const DEFAULT_DATA_URI_TAGS = addToSet({}, ['audio', 'video', 'img', 'source', 'image', 'track']);
@@ -773,6 +831,13 @@
773
831
  addToSet(ALLOWED_TAGS, ['tbody']);
774
832
  delete FORBID_TAGS.tbody;
775
833
  }
834
+ // Re-derive the active Trusted Types policy from this configuration on
835
+ // every parse. The active policy must never be sticky closure state that
836
+ // outlives the config that set it: a caller-supplied policy left in place
837
+ // after `clearConfig()` — or after a later call that supplied none, or
838
+ // `TRUSTED_TYPES_POLICY: null` — could sign a subsequent "default"
839
+ // `RETURN_TRUSTED_TYPE` result with a foreign, possibly unsafe policy.
840
+ // See GHSA-vxr8-fq34-vvx9.
776
841
  if (cfg.TRUSTED_TYPES_POLICY) {
777
842
  if (typeof cfg.TRUSTED_TYPES_POLICY.createHTML !== 'function') {
778
843
  throw typeErrorCreate('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');
@@ -780,18 +845,45 @@
780
845
  if (typeof cfg.TRUSTED_TYPES_POLICY.createScriptURL !== 'function') {
781
846
  throw typeErrorCreate('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');
782
847
  }
783
- // Overwrite existing TrustedTypes policy.
848
+ // A caller-supplied policy applies to this configuration only.
849
+ const previousTrustedTypesPolicy = trustedTypesPolicy;
784
850
  trustedTypesPolicy = cfg.TRUSTED_TYPES_POLICY;
785
- // Sign local variables required by `sanitize`.
786
- emptyHTML = trustedTypesPolicy.createHTML('');
851
+ // Sign local variables required by `sanitize`. If the supplied policy's
852
+ // `createHTML` is circular (i.e. it calls `DOMPurify.sanitize`), this
853
+ // throws via the re-entrancy guard. Restore the previous policy first so
854
+ // the instance is not left in a poisoned state. See #1422.
855
+ try {
856
+ emptyHTML = _createTrustedHTML('');
857
+ } catch (error) {
858
+ trustedTypesPolicy = previousTrustedTypesPolicy;
859
+ throw error;
860
+ }
861
+ } else if (cfg.TRUSTED_TYPES_POLICY === null) {
862
+ // Explicit opt-out for this call: perform no Trusted Types signing and
863
+ // create nothing (so a strict `trusted-types` CSP that disallows a
864
+ // `dompurify` policy can still call `sanitize` from inside its own
865
+ // policy — see #1422). Resetting to `undefined` rather than a sticky
866
+ // `null` also drops any previously retained caller policy, so it cannot
867
+ // resurface on a later call, while still allowing the next config-less
868
+ // call to restore the internal default policy. See GHSA-vxr8-fq34-vvx9.
869
+ trustedTypesPolicy = undefined;
870
+ emptyHTML = '';
787
871
  } else {
788
- // Uninitialized policy, attempt to initialize the internal dompurify policy.
872
+ // No policy supplied: keep the currently active policy if one is set — a
873
+ // previously supplied policy is intentionally sticky across config-less
874
+ // calls — otherwise fall back to the instance's own internal policy,
875
+ // created at most once. (A policy supplied for a *single* call still
876
+ // lingers by design; what must not linger is a policy whose configuration
877
+ // has been torn down via `clearConfig()`, which restores the default.)
789
878
  if (trustedTypesPolicy === undefined) {
790
- trustedTypesPolicy = _createTrustedTypesPolicy(trustedTypes, currentScript);
879
+ trustedTypesPolicy = _getDefaultTrustedTypesPolicy();
791
880
  }
792
- // If creating the internal policy succeeded sign internal variables.
793
- if (trustedTypesPolicy !== null && typeof emptyHTML === 'string') {
794
- emptyHTML = trustedTypesPolicy.createHTML('');
881
+ // Sign internal variables only when a policy is active. A falsy policy
882
+ // (Trusted Types unsupported, creation failed, or an explicit opt-out)
883
+ // leaves `emptyHTML` as a plain string, so we never call `.createHTML` on
884
+ // a non-policy and throw. See #1422.
885
+ if (trustedTypesPolicy && typeof emptyHTML === 'string') {
886
+ emptyHTML = _createTrustedHTML('');
795
887
  }
796
888
  }
797
889
  /*
@@ -912,7 +1004,74 @@
912
1004
  // eslint-disable-next-line unicorn/prefer-dom-node-remove
913
1005
  getParentNode(node).removeChild(node);
914
1006
  } catch (_) {
1007
+ /* The normal detach failed — this is reached for a parentless node
1008
+ (getParentNode() is null, so .removeChild throws). Element.prototype
1009
+ .remove() is itself a spec no-op on a parentless node, so a recorded
1010
+ "removal" would otherwise hand the caller back an intact,
1011
+ payload-bearing node (e.g. a detached IN_PLACE root the mXSS canary or
1012
+ the style-with-element-child rule decided to kill). Fail closed by
1013
+ throwing — exactly as a clobbered root does at the IN_PLACE entry —
1014
+ rather than trying to "neutralize" the node via its own methods.
1015
+ Neutralizing would mean calling getAttributeNames()/removeAttribute()
1016
+ on the node, both of which a <form> root can clobber via a named child
1017
+ (and _isClobbered does not even probe getAttributeNames), so the
1018
+ neutralize step could itself be silently defeated, leaving the payload
1019
+ intact. A throw touches only the cached, clobber-safe remove() and
1020
+ getParentNode(). Generalizes GHSA-r47g-fvhr-h676 (clobbered-form root)
1021
+ to every root-kill reason. REPORT-3.
1022
+ This lives inside the catch, so it never fires for a normally-removed
1023
+ in-tree node: those have a parent, removeChild() succeeds, and the
1024
+ catch is not entered. Only a kept (parentless) root reaches here. */
915
1025
  remove(node);
1026
+ if (!getParentNode(node)) {
1027
+ throw typeErrorCreate('a node selected for removal could not be detached from its tree ' + 'and cannot be safely returned; refusing to sanitize in place');
1028
+ }
1029
+ }
1030
+ };
1031
+ /**
1032
+ * _neutralizeRoot
1033
+ *
1034
+ * Fail-closed teardown of an in-place root after the sanitize walk aborts
1035
+ * (campaign-3 F2). An internal throw mid-walk — e.g. a page-registered
1036
+ * custom element's reaction detaches a node so `_forceRemove`'s deliberate
1037
+ * parentless guard throws, or any other re-entrant engine mutation — would
1038
+ * otherwise leave the caller's *live* tree half-sanitized, with everything
1039
+ * after the abort point still carrying its handlers. There is no safe way
1040
+ * to resume the walk (the tree mutated under us), so we strip the root bare:
1041
+ * remove every child and every attribute, then let the caller's catch see
1042
+ * the original error. Clobber-safe (cached `remove`/`childNodes`/`attributes`
1043
+ * getters; the root was already clobber-pre-flighted at the IN_PLACE entry).
1044
+ *
1045
+ * @param root the in-place root to empty
1046
+ */
1047
+ const _neutralizeRoot = function _neutralizeRoot(root) {
1048
+ const childNodes = getChildNodes ? getChildNodes(root) : root.childNodes;
1049
+ if (childNodes) {
1050
+ const snapshot = [];
1051
+ arrayForEach(childNodes, child => {
1052
+ arrayPush(snapshot, child);
1053
+ });
1054
+ arrayForEach(snapshot, child => {
1055
+ try {
1056
+ remove(child);
1057
+ } catch (_) {
1058
+ /* Best-effort teardown; a still-attached child is handled below */
1059
+ }
1060
+ });
1061
+ }
1062
+ const attributes = getAttributes ? getAttributes(root) : null;
1063
+ if (attributes) {
1064
+ for (let i = attributes.length - 1; i >= 0; --i) {
1065
+ const attribute = attributes[i];
1066
+ const name = attribute && attribute.name;
1067
+ if (typeof name === 'string') {
1068
+ try {
1069
+ root.removeAttribute(name);
1070
+ } catch (_) {
1071
+ /* Clobbered removeAttribute — ignore (fail-closed best effort) */
1072
+ }
1073
+ }
1074
+ }
916
1075
  }
917
1076
  };
918
1077
  /**
@@ -947,6 +1106,72 @@
947
1106
  }
948
1107
  }
949
1108
  };
1109
+ /**
1110
+ * _stripDisallowedAttributes
1111
+ *
1112
+ * Removes every attribute the active configuration does not allow from a
1113
+ * single element, using the same allowlist as the main attribute pass (so
1114
+ * `on*` handlers go, but no `/^on/` blocklist is introduced). Used only to
1115
+ * neutralise nodes that are being discarded from an in-place tree.
1116
+ *
1117
+ * @param element the element to strip
1118
+ */
1119
+ const _stripDisallowedAttributes = function _stripDisallowedAttributes(element) {
1120
+ const attributes = getAttributes ? getAttributes(element) : element.attributes;
1121
+ if (!attributes) {
1122
+ return;
1123
+ }
1124
+ for (let i = attributes.length - 1; i >= 0; --i) {
1125
+ const attribute = attributes[i];
1126
+ const name = attribute && attribute.name;
1127
+ if (typeof name !== 'string' || ALLOWED_ATTR[transformCaseFunc(name)]) {
1128
+ continue;
1129
+ }
1130
+ try {
1131
+ element.removeAttribute(name);
1132
+ } catch (_) {
1133
+ /* Clobbered removeAttribute on a doomed node — ignore */
1134
+ }
1135
+ }
1136
+ };
1137
+ /**
1138
+ * _neutralizeSubtree
1139
+ *
1140
+ * Completes the audit-5 F1 fix across every removal path. The KEEP_CONTENT
1141
+ * move-hoist neutralises only disallowed-tag removals; clobber, mXSS-canary,
1142
+ * namespace, comment, processing-instruction and KEEP_CONTENT:false removals
1143
+ * all drop their subtree wholesale via `_forceRemove`. On the IN_PLACE path
1144
+ * those dropped nodes are detached from the caller's LIVE tree but a
1145
+ * handler-bearing original among them (an `<img onerror>`/`<video>` that was
1146
+ * loading) keeps its queued resource event, which fires in page scope after
1147
+ * sanitize returns. This walks a removed subtree and strips every attribute
1148
+ * the active configuration does not allow — so `on*` handlers are cancelled
1149
+ * through the SAME allowlist that governs kept nodes, not a separate `/^on/`
1150
+ * blocklist. Run synchronously before sanitize returns, i.e. before any
1151
+ * queued event can fire. Hook-free by design: these nodes leave the output,
1152
+ * so firing attribute hooks for them would be surprising. Clobber-safe reads;
1153
+ * a doomed clobbered node may shadow `removeAttribute` (its own attributes are
1154
+ * irrelevant — it is discarded — while its non-clobbered descendants, e.g.
1155
+ * the `<img>`, are reached and scrubbed).
1156
+ *
1157
+ * @param root the root of a removed subtree to neutralise
1158
+ */
1159
+ const _neutralizeSubtree = function _neutralizeSubtree(root) {
1160
+ const stack = [root];
1161
+ while (stack.length > 0) {
1162
+ const node = stack.pop();
1163
+ const nodeType = getNodeType ? getNodeType(node) : node.nodeType;
1164
+ if (nodeType === NODE_TYPE.element) {
1165
+ _stripDisallowedAttributes(node);
1166
+ }
1167
+ const childNodes = getChildNodes ? getChildNodes(node) : node.childNodes;
1168
+ if (childNodes) {
1169
+ for (let i = childNodes.length - 1; i >= 0; --i) {
1170
+ stack.push(childNodes[i]);
1171
+ }
1172
+ }
1173
+ }
1174
+ };
950
1175
  /**
951
1176
  * _initDocument
952
1177
  *
@@ -968,7 +1193,7 @@
968
1193
  // Root of XHTML doc must contain xmlns declaration (see https://www.w3.org/TR/xhtml1/normative.html#strict)
969
1194
  dirty = '<html xmlns="http://www.w3.org/1999/xhtml"><head></head><body>' + dirty + '</body></html>';
970
1195
  }
971
- const dirtyPayload = trustedTypesPolicy ? trustedTypesPolicy.createHTML(dirty) : dirty;
1196
+ const dirtyPayload = trustedTypesPolicy ? _createTrustedHTML(dirty) : dirty;
972
1197
  /*
973
1198
  * Use the DOMParser API by default, fallback later if needs be
974
1199
  * DOMParser not work for svg when has multiple root element.
@@ -1027,7 +1252,8 @@
1027
1252
  *
1028
1253
  * @param node The root element whose character data should be scrubbed.
1029
1254
  */
1030
- const _scrubTemplateExpressions = function _scrubTemplateExpressions(node) {
1255
+ const _scrubTemplateExpressions2 = function _scrubTemplateExpressions(node) {
1256
+ var _node$querySelectorAl, _node$querySelectorAl2;
1031
1257
  node.normalize();
1032
1258
  const walker = createNodeIterator.call(node.ownerDocument || node, node,
1033
1259
  // eslint-disable-next-line no-bitwise
@@ -1041,6 +1267,15 @@
1041
1267
  currentNode.data = data;
1042
1268
  currentNode = walker.nextNode();
1043
1269
  }
1270
+ // NodeIterator does not descend into <template>.content per the DOM spec,
1271
+ // so we must explicitly recurse into each template's content fragment,
1272
+ // mirroring the approach used by _sanitizeShadowDOM.
1273
+ 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 : [];
1274
+ arrayForEach(Array.from(templates), tmpl => {
1275
+ if (_isDocumentFragment(tmpl.content)) {
1276
+ _scrubTemplateExpressions2(tmpl.content);
1277
+ }
1278
+ });
1044
1279
  };
1045
1280
  /**
1046
1281
  * _isClobbered
@@ -1159,7 +1394,7 @@
1159
1394
  return true;
1160
1395
  }
1161
1396
  /* Now let's check the element's type and name */
1162
- const tagName = transformCaseFunc(currentNode.nodeName);
1397
+ const tagName = transformCaseFunc(getNodeName ? getNodeName(currentNode) : currentNode.nodeName);
1163
1398
  /* Execute a hook if present */
1164
1399
  _executeHooks(hooks.uponSanitizeElement, currentNode, {
1165
1400
  tagName,
@@ -1209,9 +1444,28 @@
1209
1444
  const childNodes = getChildNodes(currentNode);
1210
1445
  if (childNodes && parentNode) {
1211
1446
  const childCount = childNodes.length;
1447
+ /* In-place: hoist the *original* children so the iterator visits
1448
+ and sanitises them through the same allowlist pass as every other
1449
+ node. The caller built the tree in the live document, so the
1450
+ originals carry already-queued resource events (`<img onerror>`,
1451
+ `<video>`/`<audio>` error, lazy/`onload`, …); cloning would leave
1452
+ those originals detached but still armed, firing in page scope
1453
+ while the returned tree looked clean. Moving is safe in-place: the
1454
+ root is pre-validated as an allowed tag and so is never the node
1455
+ being removed, which keeps `parentNode` inside the iterator root
1456
+ and the relocated child inside the serialised tree.
1457
+ Otherwise (string / DOM-copy paths): clone. The iterator is rooted
1458
+ at — and the result serialised from — `body`, so a restrictive
1459
+ ALLOWED_TAGS that removes `body` itself must leave its content in
1460
+ place, which only cloning does; and those paths parse into an
1461
+ inert document, so their discarded originals never had a queued
1462
+ event to neutralise.
1463
+ `childNodes` is live; a tail-to-head walk keeps `childNodes[i]`
1464
+ valid whether we move (drops the trailing entry) or clone (leaves
1465
+ the list intact). */
1212
1466
  for (let i = childCount - 1; i >= 0; --i) {
1213
- const childClone = cloneNode(childNodes[i], true);
1214
- parentNode.insertBefore(childClone, getNextSibling(currentNode));
1467
+ const hoisted = IN_PLACE ? childNodes[i] : cloneNode(childNodes[i], true);
1468
+ parentNode.insertBefore(hoisted, getNextSibling(currentNode));
1215
1469
  }
1216
1470
  }
1217
1471
  }
@@ -1402,12 +1656,12 @@
1402
1656
  switch (trustedTypes.getAttributeType(lcTag, lcName)) {
1403
1657
  case 'TrustedHTML':
1404
1658
  {
1405
- value = trustedTypesPolicy.createHTML(value);
1659
+ value = _createTrustedHTML(value);
1406
1660
  break;
1407
1661
  }
1408
1662
  case 'TrustedScriptURL':
1409
1663
  {
1410
- value = trustedTypesPolicy.createScriptURL(value);
1664
+ value = _createTrustedScriptURL(value);
1411
1665
  break;
1412
1666
  }
1413
1667
  }
@@ -1473,7 +1727,7 @@
1473
1727
  if (shadowNodeType === NODE_TYPE.element) {
1474
1728
  const innerSr = getShadowRoot ? getShadowRoot(shadowNode) : shadowNode.shadowRoot;
1475
1729
  if (_isDocumentFragment(innerSr)) {
1476
- _sanitizeAttachedShadowRoots2(innerSr);
1730
+ _sanitizeAttachedShadowRoots(innerSr);
1477
1731
  _sanitizeShadowDOM2(innerSr);
1478
1732
  }
1479
1733
  }
@@ -1500,46 +1754,81 @@
1500
1754
  *
1501
1755
  * @param root the subtree root to walk for attached shadow roots
1502
1756
  */
1503
- const _sanitizeAttachedShadowRoots2 = function _sanitizeAttachedShadowRoots(root) {
1504
- const nodeType = getNodeType ? getNodeType(root) : root.nodeType;
1505
- if (nodeType === NODE_TYPE.element) {
1506
- const sr = getShadowRoot ? getShadowRoot(root) : root.shadowRoot;
1507
- // Realm-safe check (GHSA-hpcv-96wg-7vj8): use nodeType-based
1508
- // detection rather than `instanceof DocumentFragment`, which is
1509
- // realm-bound and silently skipped shadow roots whose host element
1510
- // belonged to a foreign realm (e.g. iframe.contentDocument
1511
- // attachShadow). A foreign-realm ShadowRoot extends the foreign
1512
- // realm's DocumentFragment, not ours, so the old instanceof check
1513
- // returned false and the shadow subtree was never walked.
1514
- if (_isDocumentFragment(sr)) {
1515
- // Recurse first so that nested shadow roots are reached even if
1516
- // _sanitizeShadowDOM removes hosts at this level.
1517
- _sanitizeAttachedShadowRoots2(sr);
1518
- _sanitizeShadowDOM2(sr);
1519
- }
1520
- }
1521
- // Snapshot children before recursing. Sanitization of one subtree
1522
- // (e.g. via an uponSanitizeShadowNode hook) may detach siblings,
1523
- // and naive nextSibling traversal would silently skip the rest of
1524
- // the list once a node is detached.
1525
- const childNodes = getChildNodes ? getChildNodes(root) : root.childNodes;
1526
- if (!childNodes) {
1527
- return;
1528
- }
1529
- const snapshot = [];
1530
- arrayForEach(childNodes, child => {
1531
- arrayPush(snapshot, child);
1532
- });
1533
- for (const child of snapshot) {
1534
- _sanitizeAttachedShadowRoots2(child);
1535
- }
1536
- /* When the root is a <template>, also descend into root.content */
1537
- if (nodeType === NODE_TYPE.element) {
1538
- const rootName = getNodeName ? getNodeName(root) : null;
1539
- if (typeof rootName === 'string' && transformCaseFunc(rootName) === 'template') {
1540
- const content = root.content;
1541
- if (_isDocumentFragment(content)) {
1542
- _sanitizeAttachedShadowRoots2(content);
1757
+ const _sanitizeAttachedShadowRoots = function _sanitizeAttachedShadowRoots(root) {
1758
+ /* Iterative (explicit stack) rather than per-child recursion. DOM APIs
1759
+ impose no depth cap, so an attacker-shaped tree (JSON/CRDT/editor data
1760
+ built straight into the DOM — the IN_PLACE surface) deeper than the JS
1761
+ call-stack budget would otherwise overflow native recursion here and
1762
+ throw at the IN_PLACE entry pre-pass, before a single node is
1763
+ sanitized, leaving the caller's live tree untouched (fail-open). See
1764
+ campaign-3 F4. A heap stack keeps depth off the call stack.
1765
+ Each work item is either a node to descend into, or a deferred
1766
+ `_sanitizeShadowDOM` for an already-walked shadow root. The deferred
1767
+ form preserves the original post-order discipline: a shadow root's
1768
+ nested shadow roots are discovered before the outer shadow is
1769
+ sanitized (which may remove hosts). Pushes are in reverse of the
1770
+ desired processing order (LIFO): template content, then children, then
1771
+ the shadow-sanitize, then the shadow walk — so the order matches the
1772
+ previous recursion exactly. */
1773
+ const stack = [{
1774
+ node: root,
1775
+ shadow: null
1776
+ }];
1777
+ while (stack.length > 0) {
1778
+ const item = stack.pop();
1779
+ /* Deferred shadow-DOM sanitisation: runs after its subtree was walked. */
1780
+ if (item.shadow) {
1781
+ _sanitizeShadowDOM2(item.shadow);
1782
+ continue;
1783
+ }
1784
+ const node = item.node;
1785
+ const nodeType = getNodeType ? getNodeType(node) : node.nodeType;
1786
+ const isElement = nodeType === NODE_TYPE.element;
1787
+ /* (pushed last processed first) Children, snapshotted in reverse so
1788
+ the first child is processed first. Snapshotting matters because a
1789
+ hook may detach siblings mid-walk. */
1790
+ const childNodes = getChildNodes ? getChildNodes(node) : node.childNodes;
1791
+ if (childNodes) {
1792
+ for (let i = childNodes.length - 1; i >= 0; --i) {
1793
+ stack.push({
1794
+ node: childNodes[i],
1795
+ shadow: null
1796
+ });
1797
+ }
1798
+ }
1799
+ /* (pushed before children → processed after them, matching the old
1800
+ "template content last" order) When the node is a <template>,
1801
+ descend into its content. */
1802
+ if (isElement) {
1803
+ const rootName = getNodeName ? getNodeName(node) : null;
1804
+ if (typeof rootName === 'string' && transformCaseFunc(rootName) === 'template') {
1805
+ const content = node.content;
1806
+ if (_isDocumentFragment(content)) {
1807
+ stack.push({
1808
+ node: content,
1809
+ shadow: null
1810
+ });
1811
+ }
1812
+ }
1813
+ }
1814
+ /* Shadow root (processed first): walk its subtree, then sanitise it.
1815
+ Realm-safe check (GHSA-hpcv-96wg-7vj8): nodeType-based detection
1816
+ rather than `instanceof DocumentFragment`, which is realm-bound and
1817
+ silently skipped foreign-realm shadow roots (e.g.
1818
+ iframe.contentDocument attachShadow). */
1819
+ if (isElement) {
1820
+ const sr = getShadowRoot ? getShadowRoot(node) : node.shadowRoot;
1821
+ if (_isDocumentFragment(sr)) {
1822
+ /* Push the deferred sanitise first so it pops after the shadow
1823
+ walk we push next, i.e. nested shadow roots are discovered
1824
+ before this one is sanitised. */
1825
+ stack.push({
1826
+ node: null,
1827
+ shadow: sr
1828
+ }, {
1829
+ node: sr,
1830
+ shadow: null
1831
+ });
1543
1832
  }
1544
1833
  }
1545
1834
  }
@@ -1575,11 +1864,14 @@
1575
1864
  }
1576
1865
  /* Clean up removed elements */
1577
1866
  DOMPurify.removed = [];
1578
- /* Check if dirty is correctly typed for IN_PLACE */
1579
- if (typeof dirty === 'string') {
1580
- IN_PLACE = false;
1581
- }
1582
- if (IN_PLACE) {
1867
+ /* Resolve IN_PLACE for this call without mutating persistent config.
1868
+ Writing the IN_PLACE closure variable here leaks under setConfig(),
1869
+ where _parseConfig is skipped on later calls: a single string call would
1870
+ disable in-place mode for every subsequent node call, returning a
1871
+ sanitized copy while leaving the caller's node — which in-place callers
1872
+ keep using and whose return value they ignore — unsanitized. REPORT-2. */
1873
+ const inPlace = IN_PLACE && typeof dirty !== 'string' && _isNode(dirty);
1874
+ if (inPlace) {
1583
1875
  /* Do some early pre-sanitization to avoid unsafe root nodes.
1584
1876
  Read nodeName through the cached prototype getter — a clobbering
1585
1877
  child named "nodeName" on the form root would otherwise shadow
@@ -1606,8 +1898,16 @@
1606
1898
  throw typeErrorCreate('root node is clobbered and cannot be sanitized in-place');
1607
1899
  }
1608
1900
  /* Sanitize attached shadow roots before the main iterator runs.
1609
- The iterator does not descend into shadow trees. */
1610
- _sanitizeAttachedShadowRoots2(dirty);
1901
+ The iterator does not descend into shadow trees. Same fail-closed
1902
+ barrier as the main walk (campaign-3 F2): a custom-element reaction
1903
+ inside a shadow root could abort this pre-pass before the walk runs,
1904
+ which would otherwise leave the entire live tree unsanitized. */
1905
+ try {
1906
+ _sanitizeAttachedShadowRoots(dirty);
1907
+ } catch (error) {
1908
+ _neutralizeRoot(dirty);
1909
+ throw error;
1910
+ }
1611
1911
  } else if (_isNode(dirty)) {
1612
1912
  /* If dirty is a DOM element, append to an empty document to avoid
1613
1913
  elements being stripped by the parser */
@@ -1627,13 +1927,13 @@
1627
1927
  descend into shadow trees. The walk routes every read through a
1628
1928
  cached prototype getter so clobbering descendants on a form root
1629
1929
  cannot hide a shadow host from this pass. */
1630
- _sanitizeAttachedShadowRoots2(importedNode);
1930
+ _sanitizeAttachedShadowRoots(importedNode);
1631
1931
  } else {
1632
1932
  /* Exit directly if we have nothing to do */
1633
1933
  if (!RETURN_DOM && !SAFE_FOR_TEMPLATES && !WHOLE_DOCUMENT &&
1634
1934
  // eslint-disable-next-line unicorn/prefer-includes
1635
1935
  dirty.indexOf('<') === -1) {
1636
- return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? trustedTypesPolicy.createHTML(dirty) : dirty;
1936
+ return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? _createTrustedHTML(dirty) : dirty;
1637
1937
  }
1638
1938
  /* Initialize the document to work on */
1639
1939
  body = _initDocument(dirty);
@@ -1647,32 +1947,59 @@
1647
1947
  _forceRemove(body.firstChild);
1648
1948
  }
1649
1949
  /* Get node iterator */
1650
- const nodeIterator = _createNodeIterator(IN_PLACE ? dirty : body);
1651
- /* Now start iterating over the created document */
1652
- while (currentNode = nodeIterator.nextNode()) {
1653
- /* Sanitize tags and elements */
1654
- _sanitizeElements(currentNode);
1655
- /* Check attributes next */
1656
- _sanitizeAttributes(currentNode);
1657
- /* Shadow DOM detected, sanitize it.
1658
- Realm-safe check (GHSA-hpcv-96wg-7vj8): nodeType-based detection
1659
- instead of instanceof, so foreign-realm <template>.content is
1660
- walked correctly. */
1661
- if (_isDocumentFragment(currentNode.content)) {
1662
- _sanitizeShadowDOM2(currentNode.content);
1950
+ const nodeIterator = _createNodeIterator(inPlace ? dirty : body);
1951
+ /* Now start iterating over the created document.
1952
+ The walk runs inside an exception barrier (campaign-3 F2): a re-entrant
1953
+ engine/custom-element mutation can detach a node mid-walk so
1954
+ `_forceRemove`'s parentless guard throws, aborting the loop. Without the
1955
+ barrier the caller's in-place tree would be left half-sanitized with the
1956
+ unvisited tail still armed. On any throw we fail closed — strip the
1957
+ in-place root bare then rethrow so the existing throw contract is
1958
+ preserved. (String/DOM-copy paths never return the partial body, so the
1959
+ propagating throw is already fail-closed there.) */
1960
+ try {
1961
+ while (currentNode = nodeIterator.nextNode()) {
1962
+ /* Sanitize tags and elements */
1963
+ _sanitizeElements(currentNode);
1964
+ /* Check attributes next */
1965
+ _sanitizeAttributes(currentNode);
1966
+ /* Shadow DOM detected, sanitize it.
1967
+ Realm-safe check (GHSA-hpcv-96wg-7vj8): nodeType-based detection
1968
+ instead of instanceof, so foreign-realm <template>.content is
1969
+ walked correctly. */
1970
+ if (_isDocumentFragment(currentNode.content)) {
1971
+ _sanitizeShadowDOM2(currentNode.content);
1972
+ }
1973
+ }
1974
+ } catch (error) {
1975
+ if (inPlace) {
1976
+ _neutralizeRoot(dirty);
1663
1977
  }
1978
+ throw error;
1664
1979
  }
1665
1980
  /* If we sanitized `dirty` in-place, return it. */
1666
- if (IN_PLACE) {
1981
+ if (inPlace) {
1982
+ /* Fail-closed completion of the audit-5 F1 fix: every node removed from
1983
+ the caller's live tree is detached but may still hold a queued
1984
+ resource-event handler that fires in page scope after we return. The
1985
+ move-hoist covers only disallowed-tag KEEP_CONTENT removals; strip the
1986
+ non-allow-listed attributes off every other removed subtree (clobber,
1987
+ mXSS, namespace, comments, KEEP_CONTENT:false, …) so those handlers are
1988
+ cancelled before any event can fire. Runs synchronously, pre-return. */
1989
+ arrayForEach(DOMPurify.removed, entry => {
1990
+ if (entry.element) {
1991
+ _neutralizeSubtree(entry.element);
1992
+ }
1993
+ });
1667
1994
  if (SAFE_FOR_TEMPLATES) {
1668
- _scrubTemplateExpressions(dirty);
1995
+ _scrubTemplateExpressions2(dirty);
1669
1996
  }
1670
1997
  return dirty;
1671
1998
  }
1672
1999
  /* Return sanitized string or DOM */
1673
2000
  if (RETURN_DOM) {
1674
2001
  if (SAFE_FOR_TEMPLATES) {
1675
- _scrubTemplateExpressions(body);
2002
+ _scrubTemplateExpressions2(body);
1676
2003
  }
1677
2004
  if (RETURN_DOM_FRAGMENT) {
1678
2005
  returnNode = createDocumentFragment.call(body.ownerDocument);
@@ -1706,7 +2033,7 @@
1706
2033
  serializedHTML = stringReplace(serializedHTML, expr, ' ');
1707
2034
  });
1708
2035
  }
1709
- return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? trustedTypesPolicy.createHTML(serializedHTML) : serializedHTML;
2036
+ return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? _createTrustedHTML(serializedHTML) : serializedHTML;
1710
2037
  };
1711
2038
  DOMPurify.setConfig = function () {
1712
2039
  let cfg = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
@@ -1716,6 +2043,12 @@
1716
2043
  DOMPurify.clearConfig = function () {
1717
2044
  CONFIG = null;
1718
2045
  SET_CONFIG = false;
2046
+ // Drop any caller-supplied Trusted Types policy so it cannot poison later
2047
+ // `RETURN_TRUSTED_TYPE` output. The internal default policy (cached, and
2048
+ // never recreated — Trusted Types throws on duplicate names) is restored by
2049
+ // the next `_parseConfig`. See GHSA-vxr8-fq34-vvx9.
2050
+ trustedTypesPolicy = defaultTrustedTypesPolicy;
2051
+ emptyHTML = '';
1719
2052
  };
1720
2053
  DOMPurify.isValidAttribute = function (tag, attr, value) {
1721
2054
  /* Initialize shared config vars if necessary. */