dompurify 3.4.16 → 3.4.17

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.16 | (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.16/LICENSE */
1
+ /*! @license DOMPurify 3.4.17 | (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.17/LICENSE */
2
2
  (function(global, factory) {
3
3
  typeof exports === "object" && typeof module !== "undefined" ? module.exports = factory() : typeof define === "function" && define.amd ? define([], factory) : (global = typeof globalThis !== "undefined" ? globalThis : global || self, global.DOMPurify = factory());
4
4
  })(this, function() {
@@ -126,9 +126,12 @@
126
126
  const arrayLastIndexOf = unapply(Array.prototype.lastIndexOf);
127
127
  const arrayPop = unapply(Array.prototype.pop);
128
128
  const arrayPush = unapply(Array.prototype.push);
129
- Array.prototype.slice;
129
+ const arraySlice = unapply(Array.prototype.slice);
130
130
  const arraySplice = unapply(Array.prototype.splice);
131
131
  const arrayIsArray = Array.isArray;
132
+ const setCreate = unconstruct(Set);
133
+ const setAdd = unapply(Set.prototype.add);
134
+ const setHas = unapply(Set.prototype.has);
132
135
  const stringToLowerCase = unapply(String.prototype.toLowerCase);
133
136
  const stringToString = unapply(String.prototype.toString);
134
137
  const stringMatch = unapply(String.prototype.match);
@@ -273,6 +276,25 @@
273
276
  }
274
277
  return fallbackValue;
275
278
  }
279
+ /**
280
+ * Like lookupGetter, but for accessor properties only, and returns null
281
+ * instead of a null-returning fallback when no getter exists on the chain.
282
+ * Lets a caller fall back to a direct property read in environments that
283
+ * lack the getter, where a fallback returning null would be indistinguishable
284
+ * from a legitimate null value (e.g. namespaceURI of a no-namespace element).
285
+ *
286
+ * @param object - The object (usually a prototype) to start the lookup at.
287
+ * @param prop - The accessor property name.
288
+ * @returns The unapplied getter, or null if none was found.
289
+ */
290
+ function lookupAccessor(object, prop) {
291
+ while (object !== null) {
292
+ const desc = getOwnPropertyDescriptor(object, prop);
293
+ if (desc) return desc.get ? unapply(desc.get) : null;
294
+ object = getPrototypeOf(object);
295
+ }
296
+ return null;
297
+ }
276
298
  function isRegex(value) {
277
299
  try {
278
300
  regExpTest(value, "");
@@ -957,7 +979,7 @@
957
979
  documentFragment: 11,
958
980
  notation: 12
959
981
  };
960
- const LITERAL_TEXT_ELEMENT_NAMES = [
982
+ const LITERAL_TEXT_ELEMENTS = freeze(addToSet({}, [
961
983
  "style",
962
984
  "script",
963
985
  "xmp",
@@ -966,15 +988,7 @@
966
988
  "noframes",
967
989
  "plaintext",
968
990
  "noscript"
969
- ];
970
- const LITERAL_TEXT_ELEMENTS = freeze(addToSet({}, LITERAL_TEXT_ELEMENT_NAMES));
971
- const LITERAL_TEXT_CLOSE = function() {
972
- const map = {};
973
- arrayForEach(LITERAL_TEXT_ELEMENT_NAMES, (name) => {
974
- map[name] = seal(new RegExp("</" + name + "(?=[\\t\\n\\f\\r />])", "i"));
975
- });
976
- return freeze(map);
977
- }();
991
+ ]));
978
992
  const getGlobal = function getGlobal() {
979
993
  return typeof window === "undefined" ? null : window;
980
994
  };
@@ -1052,7 +1066,7 @@
1052
1066
  function createDOMPurify() {
1053
1067
  let window = arguments.length > 0 && arguments[0] !== void 0 ? arguments[0] : getGlobal();
1054
1068
  const DOMPurify = (root) => createDOMPurify(root);
1055
- DOMPurify.version = "3.4.16";
1069
+ DOMPurify.version = "3.4.17";
1056
1070
  DOMPurify.removed = [];
1057
1071
  if (!window || !window.document || window.document.nodeType !== NODE_TYPE.document || !window.Element) {
1058
1072
  DOMPurify.isSupported = false;
@@ -1084,6 +1098,10 @@
1084
1098
  const _readNodeName = function _readNodeName(node) {
1085
1099
  return getNodeName ? getNodeName(node) : node.nodeName;
1086
1100
  };
1101
+ const getNamespaceURI = lookupAccessor(ElementPrototype, "namespaceURI");
1102
+ const _readNamespaceURI = function _readNamespaceURI(element) {
1103
+ return getNamespaceURI ? getNamespaceURI(element) : element.namespaceURI;
1104
+ };
1087
1105
  if (typeof HTMLTemplateElement === "function") {
1088
1106
  const template = document.createElement("template");
1089
1107
  if (template.content && template.content.ownerDocument) document = template.content.ownerDocument;
@@ -1204,6 +1222,7 @@
1204
1222
  const SANITIZE_NAMED_PROPS_PREFIX = "user-content-";
1205
1223
  let KEEP_CONTENT = true;
1206
1224
  let IN_PLACE = false;
1225
+ let IN_PLACE_VISITED = null;
1207
1226
  let USE_PROFILES = {};
1208
1227
  let FORBID_CONTENTS = null;
1209
1228
  const DEFAULT_FORBID_CONTENTS = addToSet({}, [
@@ -1477,18 +1496,23 @@
1477
1496
  * return. Return true otherwise.
1478
1497
  */
1479
1498
  const _checkValidNamespace = function _checkValidNamespace(element) {
1480
- let parent = getParentNode(element);
1481
- if (!parent || !parent.tagName) parent = {
1482
- namespaceURI: NAMESPACE,
1483
- tagName: "template"
1484
- };
1485
- const tagName = stringToLowerCase(element.tagName);
1486
- const parentTagName = stringToLowerCase(parent.tagName);
1487
- if (!ALLOWED_NAMESPACES[element.namespaceURI]) return false;
1488
- if (element.namespaceURI === SVG_NAMESPACE) return _checkSvgNamespace(tagName, parent, parentTagName);
1489
- if (element.namespaceURI === MATHML_NAMESPACE) return _checkMathMlNamespace(tagName, parent, parentTagName);
1490
- if (element.namespaceURI === HTML_NAMESPACE) return _checkHtmlNamespace(tagName, parent, parentTagName);
1491
- if (PARSER_MEDIA_TYPE === "application/xhtml+xml" && ALLOWED_NAMESPACES[element.namespaceURI]) return true;
1499
+ const elementNamespace = _readNamespaceURI(element);
1500
+ const tagName = stringToLowerCase(_readNodeName(element));
1501
+ const parentNode = getParentNode(element);
1502
+ let parent;
1503
+ let parentTagName;
1504
+ if (parentNode && _readNodeType(parentNode) === NODE_TYPE.element) {
1505
+ parent = { namespaceURI: _readNamespaceURI(parentNode) };
1506
+ parentTagName = stringToLowerCase(_readNodeName(parentNode));
1507
+ } else {
1508
+ parent = { namespaceURI: NAMESPACE };
1509
+ parentTagName = "template";
1510
+ }
1511
+ if (!ALLOWED_NAMESPACES[elementNamespace]) return false;
1512
+ if (elementNamespace === SVG_NAMESPACE) return _checkSvgNamespace(tagName, parent, parentTagName);
1513
+ if (elementNamespace === MATHML_NAMESPACE) return _checkMathMlNamespace(tagName, parent, parentTagName);
1514
+ if (elementNamespace === HTML_NAMESPACE) return _checkHtmlNamespace(tagName, parent, parentTagName);
1515
+ if (PARSER_MEDIA_TYPE === "application/xhtml+xml" && ALLOWED_NAMESPACES[elementNamespace]) return true;
1492
1516
  return false;
1493
1517
  };
1494
1518
  /**
@@ -1532,36 +1556,21 @@
1532
1556
  }
1533
1557
  };
1534
1558
  /**
1535
- * _neutralizeRoot
1536
- *
1537
- * Fail-closed teardown of an in-place root after the sanitize walk aborts
1538
- * (campaign-3 F2). An internal throw mid-walk — e.g. a page-registered
1539
- * custom element's reaction detaches a node so `_forceRemove`'s deliberate
1540
- * parentless guard throws, or any other re-entrant engine mutation — would
1541
- * otherwise leave the caller's *live* tree half-sanitized, with everything
1542
- * after the abort point still carrying its handlers. There is no safe way
1543
- * to resume the walk (the tree mutated under us), so we strip the root bare:
1544
- * remove every child and every attribute, then let the caller's catch see
1545
- * the original error. Clobber-safe (cached `remove`/`childNodes`/`attributes`
1546
- * getters; the root was already clobber-pre-flighted at the IN_PLACE entry).
1559
+ * Fail-closed teardown of an in-place root: strip every disallowed attribute
1560
+ * in the subtree first, so nothing armed is left behind, then remove all
1561
+ * children and the root's own attributes.
1547
1562
  *
1548
1563
  * @param root the in-place root to empty
1549
1564
  */
1550
1565
  const _neutralizeRoot = function _neutralizeRoot(root) {
1551
1566
  _neutralizeSubtree(root);
1552
1567
  const childNodes = getChildNodes(root);
1553
- if (childNodes) {
1554
- const snapshot = [];
1555
- arrayForEach(childNodes, (child) => {
1556
- arrayPush(snapshot, child);
1557
- });
1558
- arrayForEach(snapshot, (child) => {
1559
- try {
1560
- remove(child);
1561
- } catch (_) {}
1562
- });
1563
- }
1564
- const attributes = getAttributes(root);
1568
+ if (childNodes) arrayForEach(arraySlice(childNodes), (child) => {
1569
+ try {
1570
+ remove(child);
1571
+ } catch (_) {}
1572
+ });
1573
+ const attributes = _readNodeType(root) === NODE_TYPE.element ? getAttributes(root) : null;
1565
1574
  if (attributes) for (let i = attributes.length - 1; i >= 0; --i) {
1566
1575
  const attribute = attributes[i];
1567
1576
  const name = attribute && attribute.name;
@@ -1569,18 +1578,9 @@
1569
1578
  }
1570
1579
  };
1571
1580
  /**
1572
- * _removeAttribute
1573
- *
1574
- * Name-based getAttributeNode()/removeAttribute() ASCII-lowercase their
1575
- * lookup key for HTML elements in an HTML document, so they silently miss an
1576
- * attribute whose stored qualified name still contains uppercase ASCII
1577
- * letters. That happens when the node came from a case-preserving source
1578
- * (an XML/XHTML document imported via importNode(), or createAttributeNS()),
1579
- * where e.g. `ONERROR` survives the walk: the policy check lowercases to
1580
- * `onerror` and rejects it, but `removeAttribute('ONERROR')` looks up
1581
- * `onerror` and finds nothing. Remove the exact Attr node instead, which is
1582
- * case- and namespace-exact, and fall back to name-based removal only when
1583
- * the caller could not supply the node.
1581
+ * Removes an attribute, preferably as its exact Attr node: name-based
1582
+ * removal lowercases the name in HTML documents and misses case-preserved
1583
+ * names, e.g. from imported XHTML.
1584
1584
  *
1585
1585
  * @param name an Attribute name
1586
1586
  * @param element a DOM node
@@ -1614,12 +1614,8 @@
1614
1614
  }
1615
1615
  };
1616
1616
  /**
1617
- * _stripDisallowedAttributes
1618
- *
1619
- * Removes every attribute the active configuration does not allow from a
1620
- * single element, using the same allowlist as the main attribute pass (so
1621
- * `on*` handlers go, but no `/^on/` blocklist is introduced). Used only to
1622
- * neutralise nodes that are being discarded from an in-place tree.
1617
+ * Removes every attribute the configuration does not allow. Used to
1618
+ * neutralize nodes discarded from an in-place tree.
1623
1619
  *
1624
1620
  * @param element the element to strip
1625
1621
  */
@@ -1634,77 +1630,158 @@
1634
1630
  }
1635
1631
  };
1636
1632
  /**
1637
- * _neutralizeSubtree
1633
+ * Iterative depth-first walk, so deep trees cannot overflow the call stack,
1634
+ * reading through the cached getters, so clobbering cannot hide children.
1635
+ * When `visit` returns false, the node's children are skipped. With
1636
+ * `intoShadow`, open shadow roots are walked after their host's children.
1638
1637
  *
1639
- * Completes the audit-5 F1 fix across every removal path. The KEEP_CONTENT
1640
- * move-hoist neutralises only disallowed-tag removals; clobber, mXSS-canary,
1641
- * namespace, comment, processing-instruction and KEEP_CONTENT:false removals
1642
- * all drop their subtree wholesale via `_forceRemove`. On the IN_PLACE path
1643
- * those dropped nodes are detached from the caller's LIVE tree but a
1644
- * handler-bearing original among them (an `<img onerror>`/`<video>` that was
1645
- * loading) keeps its queued resource event, which fires in page scope after
1646
- * sanitize returns. This walks a removed subtree and strips every attribute
1647
- * the active configuration does not allow — so `on*` handlers are cancelled
1648
- * through the SAME allowlist that governs kept nodes, not a separate `/^on/`
1649
- * blocklist. Run synchronously before sanitize returns, i.e. before any
1650
- * queued event can fire. Hook-free by design: these nodes leave the output,
1651
- * so firing attribute hooks for them would be surprising. Clobber-safe reads;
1652
- * a doomed clobbered node may shadow `removeAttribute` (its own attributes are
1653
- * irrelevant — it is discarded — while its non-clobbered descendants, e.g.
1654
- * the `<img>`, are reached and scrubbed).
1655
- *
1656
- * @param root the root of a removed subtree to neutralise
1638
+ * @param root where to start
1639
+ * @param visit called for every node
1640
+ * @param intoShadow whether to enter open shadow roots
1657
1641
  */
1658
- const _neutralizeSubtree = function _neutralizeSubtree(root) {
1642
+ const _walkTree = function _walkTree(root, visit) {
1643
+ let intoShadow = arguments.length > 2 && arguments[2] !== void 0 ? arguments[2] : false;
1659
1644
  const stack = [root];
1660
1645
  while (stack.length > 0) {
1661
1646
  const node = stack.pop();
1662
- if (_readNodeType(node) === NODE_TYPE.element) _stripDisallowedAttributes(node);
1647
+ if (visit(node) === false) continue;
1648
+ if (intoShadow && _readNodeType(node) === NODE_TYPE.element) {
1649
+ const shadowRoot = getShadowRoot(node);
1650
+ if (_isDocumentFragment(shadowRoot)) stack.push(shadowRoot);
1651
+ }
1663
1652
  const childNodes = getChildNodes(node);
1664
1653
  if (childNodes) for (let i = childNodes.length - 1; i >= 0; --i) stack.push(childNodes[i]);
1665
1654
  }
1666
1655
  };
1667
1656
  /**
1668
- * _neutralizePatchLinkage
1657
+ * Strips disallowed attributes from every element of a discarded subtree,
1658
+ * so a handler that is already queued (a loading image) cannot fire.
1669
1659
  *
1670
- * IN_PLACE entry pre-pass (declarative-partial-updates / streaming
1671
- * hardening, https://github.com/WICG/declarative-partial-updates).
1660
+ * @param root the root of a removed subtree
1661
+ */
1662
+ const _neutralizeSubtree = function _neutralizeSubtree(root) {
1663
+ _walkTree(root, (node) => {
1664
+ if (_readNodeType(node) === NODE_TYPE.element) _stripDisallowedAttributes(node);
1665
+ });
1666
+ };
1667
+ /**
1668
+ * All nodes under `root`, root first, including open shadow roots. Template
1669
+ * content is not entered, it is inert.
1672
1670
  *
1673
- * The main walk strips patch linkage (`for`/`patchsrc`) and removes range
1674
- * markers (PIs / markup comments) node-by-node, in document order, AS it
1675
- * reaches each node. On a live in-place root that leaves a window: from the
1676
- * moment the root is connected until the walk arrives at a given node, that
1677
- * node's linkage is live. A patch applied on connection/stream can fire as
1678
- * a microtask during the walk and inject or teleport an unsanitized DOM
1679
- * range into a region the iterator has already passed and will not revisit,
1680
- * so the post-return "tree is sanitized" contract is violated. Sweep the
1681
- * whole tree once up front and sever every linkage before the walk begins,
1682
- * closing that window.
1671
+ * @param root the in-place root
1672
+ * @return the nodes
1673
+ */
1674
+ const _collectLiveNodes = function _collectLiveNodes(root) {
1675
+ const nodes = [];
1676
+ _walkTree(root, (node) => {
1677
+ nodes.push(node);
1678
+ }, true);
1679
+ return nodes;
1680
+ };
1681
+ /**
1682
+ * Strips disallowed attributes from every element that was under the
1683
+ * in-place root when the call started and no longer is, whatever removed
1684
+ * or moved it.
1683
1685
  *
1684
- * This CANNOT undo a patch that already fired before sanitize ran — that is
1685
- * the irreducible "do not IN_PLACE a live-connected attacker tree" caveat —
1686
- * but it closes everything from sanitize-start onward. Gated on SAFE_FOR_XML
1687
- * to group with the rest of the declarative-partial-updates handling and
1688
- * stay overridable, consistent with the codebase.
1686
+ * @param root the in-place root
1687
+ * @param before the root's nodes, collected before the walk
1688
+ */
1689
+ const _neutralizeEscapedElements = function _neutralizeEscapedElements(root, before) {
1690
+ const reachable = setCreate();
1691
+ arrayForEach(_collectLiveNodes(root), (node) => {
1692
+ if (_readNodeType(node) === NODE_TYPE.element) setAdd(reachable, node);
1693
+ });
1694
+ arrayForEach(before, (node) => {
1695
+ if (_readNodeType(node) === NODE_TYPE.element && !setHas(reachable, node)) _stripDisallowedAttributes(node);
1696
+ });
1697
+ };
1698
+ /**
1699
+ * Neutralizes everything that left the in-place tree during the call: what
1700
+ * DOMPurify removed, and what hooks or custom elements removed or moved out.
1689
1701
  *
1690
- * Clobber-safe traversal (cached childNodes getter); per-node try/catch so a
1691
- * clobbered root cannot defeat the sweep of its non-clobbered descendants.
1702
+ * @param root the in-place root
1703
+ * @param before the root's nodes, collected before the walk
1704
+ * @return true if the root itself was removed
1705
+ */
1706
+ const _neutralizeDiscarded = function _neutralizeDiscarded(root, before) {
1707
+ let rootRemoved = false;
1708
+ arrayForEach(DOMPurify.removed, (entry) => {
1709
+ if (entry.element) {
1710
+ rootRemoved = rootRemoved || entry.element === root;
1711
+ _neutralizeSubtree(entry.element);
1712
+ }
1713
+ });
1714
+ _neutralizeEscapedElements(root, before);
1715
+ return rootRemoved;
1716
+ };
1717
+ const MAX_UNVISITED_ROUNDS = 8;
1718
+ /**
1719
+ * The walk only moves forward, so a node moved to a position it already
1720
+ * passed is never visited. Give every original node that is still in the
1721
+ * tree but unvisited the normal treatment, hooks included. Nodes created
1722
+ * during the call are left alone. Fails closed if the tree keeps changing.
1692
1723
  *
1693
- * NOTE (pending real-Chrome confirmation, see test/declarative-patch-probe
1694
- * .html Q1): this mirrors the existing policy of keeping `for` on
1695
- * <label>/<output>. If the shipping feature can drive a patch through a
1696
- * surviving `for`-on-label/output + `id` pair, this pre-pass and the
1697
- * attribute check at _isBasicCustomElement's caller must additionally drop
1698
- * that pair on the IN_PLACE path. Left as-is until the taxonomy is verified.
1724
+ * @param root the in-place root
1725
+ * @param before the root's nodes, collected before the walk
1726
+ */
1727
+ const _sanitizeUnvisitedNodes = function _sanitizeUnvisitedNodes(root, before) {
1728
+ const visited = IN_PLACE_VISITED;
1729
+ let original = null;
1730
+ const getOriginal = () => {
1731
+ if (!original) {
1732
+ original = setCreate();
1733
+ arrayForEach(before, (beforeNode) => {
1734
+ setAdd(original, beforeNode);
1735
+ });
1736
+ }
1737
+ return original;
1738
+ };
1739
+ for (let round = 0;; round++) {
1740
+ const live = _collectLiveNodes(root);
1741
+ const containers = [root];
1742
+ let pending = false;
1743
+ arrayForEach(live, (node) => {
1744
+ if (_readNodeType(node) === NODE_TYPE.documentFragment) arrayPush(containers, node);
1745
+ else if (!pending && !setHas(visited, node)) pending = setHas(getOriginal(), node);
1746
+ });
1747
+ if (!pending) return;
1748
+ if (round >= MAX_UNVISITED_ROUNDS) throw typeErrorCreate("the tree kept changing during in-place sanitization; refusing to return it");
1749
+ arrayForEach(containers, (container, index) => {
1750
+ _sanitizeUnvisitedIn(container, index > 0, root, getOriginal());
1751
+ });
1752
+ }
1753
+ };
1754
+ /**
1755
+ * One _sanitizeUnvisitedNodes pass over the root or an open shadow root.
1699
1756
  *
1700
- * @param root the in-place root to sweep
1757
+ * @param container the root or an open shadow root
1758
+ * @param inShadow whether container is a shadow root
1759
+ * @param root the in-place root
1760
+ * @param original the root's nodes collected before the walk, as a set
1701
1761
  */
1762
+ const _sanitizeUnvisitedIn = function _sanitizeUnvisitedIn(container, inShadow, root, original) {
1763
+ const iterator = _createNodeIterator(container);
1764
+ let node;
1765
+ while (node = iterator.nextNode()) {
1766
+ if (!setHas(original, node) || setHas(IN_PLACE_VISITED, node)) continue;
1767
+ if (inShadow) _executeHooks(hooks.uponSanitizeShadowNode, node, null);
1768
+ _sanitizeElements(node, root);
1769
+ _sanitizeAttributes(node, root);
1770
+ if (_isDocumentFragment(node.content)) _sanitizeShadowDOM2(node.content);
1771
+ }
1772
+ };
1702
1773
  /**
1703
- * Central policy for declarative-partial-updates patch-linkage attributes,
1704
- * shared by the _neutralizePatchLinkage pre-pass and _isValidAttribute so
1705
- * the two sites cannot drift: `patchsrc` always links, `for` links
1706
- * everywhere except on <label>/<output>, and the whole policy is gated on
1707
- * SAFE_FOR_XML (see the rationale block in _isValidAttribute).
1774
+ * Records that _sanitizeElements processed `node`. No-op outside IN_PLACE.
1775
+ *
1776
+ * @param node the node being processed
1777
+ */
1778
+ const _markVisited = function _markVisited(node) {
1779
+ if (IN_PLACE_VISITED) setAdd(IN_PLACE_VISITED, node);
1780
+ };
1781
+ /**
1782
+ * Patch-linkage attributes (declarative partial updates): `patchsrc`
1783
+ * always, `for` everywhere except on <label> and <output>. Gated on
1784
+ * SAFE_FOR_XML. Shared by the IN_PLACE pre-pass and _isValidAttribute.
1708
1785
  *
1709
1786
  * @param lcName the transformCaseFunc'd attribute name
1710
1787
  * @param lcTag the transformCaseFunc'd tag name of the carrying element
@@ -1715,17 +1792,26 @@
1715
1792
  if (lcName === "patchsrc") return true;
1716
1793
  return lcName === "for" && lcTag !== "label" && lcTag !== "output";
1717
1794
  };
1795
+ /**
1796
+ * IN_PLACE pre-pass: sever all patch linkage before the walk, so no patch
1797
+ * can fire mid-walk into a part of the tree the walker already passed.
1798
+ * Removes range markers (processing instructions, comments with markup)
1799
+ * and strips linkage attributes. It cannot undo a patch that already fired.
1800
+ *
1801
+ * TODO: if the shipped feature can patch through `for` on <label>/<output>,
1802
+ * drop that pair here too (see test/declarative-patch-probe.html).
1803
+ *
1804
+ * @param root the in-place root to sweep
1805
+ */
1718
1806
  const _neutralizePatchLinkage = function _neutralizePatchLinkage(root) {
1719
1807
  if (!SAFE_FOR_XML) return;
1720
- const stack = [root];
1721
- while (stack.length > 0) {
1722
- const node = stack.pop();
1808
+ _walkTree(root, (node) => {
1723
1809
  const nodeType = _readNodeType(node);
1724
1810
  if (nodeType === NODE_TYPE.processingInstruction || nodeType === NODE_TYPE.comment && regExpTest(COMMENT_MARKUP_PROBE, node.data)) {
1725
1811
  try {
1726
1812
  remove(node);
1727
1813
  } catch (_) {}
1728
- continue;
1814
+ return false;
1729
1815
  }
1730
1816
  if (nodeType === NODE_TYPE.element) {
1731
1817
  const element = node;
@@ -1735,9 +1821,8 @@
1735
1821
  if (element.hasAttribute && element.hasAttribute("for") && _isPatchLinkageAttribute("for", lcTag)) element.removeAttribute("for");
1736
1822
  } catch (_) {}
1737
1823
  }
1738
- const childNodes = getChildNodes(node);
1739
- if (childNodes) for (let i = childNodes.length - 1; i >= 0; --i) stack.push(childNodes[i]);
1740
- }
1824
+ return true;
1825
+ });
1741
1826
  };
1742
1827
  /**
1743
1828
  * _initDocument
@@ -1814,6 +1899,27 @@
1814
1899
  */
1815
1900
  const _scrubTemplateExpressions2 = function _scrubTemplateExpressions(node) {
1816
1901
  var _node$querySelectorAl;
1902
+ _scrubOwnCharacterData(node);
1903
+ const templates = (_node$querySelectorAl = node.querySelectorAll) === null || _node$querySelectorAl === void 0 ? void 0 : _node$querySelectorAl.call(node, "template");
1904
+ if (templates) arrayForEach(templates, (tmpl) => {
1905
+ if (_isDocumentFragment(tmpl.content)) _scrubTemplateExpressions2(tmpl.content);
1906
+ });
1907
+ };
1908
+ /**
1909
+ * _scrubOwnCharacterData
1910
+ *
1911
+ * Non-recursive core of _scrubTemplateExpressions: normalize `node` and
1912
+ * strip template expressions from its own character data only. Does not
1913
+ * enter <template>.content or attached shadow roots (NodeIterator never
1914
+ * does). Used by _sanitizeShadowDOM, where every nested template content
1915
+ * and shadow root is already walked - and therefore scrubbed - by its own
1916
+ * recursive _sanitizeShadowDOM call; recursing here as well would rescrub
1917
+ * each nested fragment once per ancestor, which is quadratic in nesting
1918
+ * depth.
1919
+ *
1920
+ * @param node The root whose own character data should be scrubbed.
1921
+ */
1922
+ const _scrubOwnCharacterData = function _scrubOwnCharacterData(node) {
1817
1923
  node.normalize();
1818
1924
  const doc = getOwnerDocument ? getOwnerDocument(node) : node.ownerDocument;
1819
1925
  const walker = createNodeIterator.call(doc || node, node, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_CDATA_SECTION | NodeFilter.SHOW_PROCESSING_INSTRUCTION, null);
@@ -1822,10 +1928,6 @@
1822
1928
  currentNode.data = _stripTemplateExpressions(currentNode.data);
1823
1929
  currentNode = walker.nextNode();
1824
1930
  }
1825
- const templates = (_node$querySelectorAl = node.querySelectorAll) === null || _node$querySelectorAl === void 0 ? void 0 : _node$querySelectorAl.call(node, "template");
1826
- if (templates) arrayForEach(templates, (tmpl) => {
1827
- if (_isDocumentFragment(tmpl.content)) _scrubTemplateExpressions2(tmpl.content);
1828
- });
1829
1931
  };
1830
1932
  /**
1831
1933
  * _isClobbered
@@ -1901,7 +2003,7 @@
1901
2003
  */
1902
2004
  const _isUnsafeNode = function _isUnsafeNode(currentNode, tagName) {
1903
2005
  if (SAFE_FOR_XML && currentNode.hasChildNodes() && !_isNode(currentNode.firstElementChild) && regExpTest(ELEMENT_MARKUP_PROBE, currentNode.textContent) && regExpTest(ELEMENT_MARKUP_PROBE, currentNode.innerHTML)) return true;
1904
- if (SAFE_FOR_XML && currentNode.namespaceURI === HTML_NAMESPACE && LITERAL_TEXT_ELEMENTS[tagName] && (_isNode(currentNode.firstElementChild) || typeof currentNode.textContent === "string" && regExpTest(LITERAL_TEXT_CLOSE[tagName], currentNode.textContent))) return true;
2006
+ if (SAFE_FOR_XML && LITERAL_TEXT_ELEMENTS[tagName] && _readNodeType(currentNode) === NODE_TYPE.element && _readNamespaceURI(currentNode) === HTML_NAMESPACE && (_isNode(currentNode.firstElementChild) || typeof currentNode.textContent === "string" && regExpTest(ELEMENT_MARKUP_PROBE, currentNode.textContent))) return true;
1905
2007
  if (currentNode.nodeType === NODE_TYPE.processingInstruction) return true;
1906
2008
  if (SAFE_FOR_XML && currentNode.nodeType === NODE_TYPE.comment && regExpTest(COMMENT_MARKUP_PROBE, currentNode.data)) return true;
1907
2009
  return false;
@@ -1945,7 +2047,7 @@
1945
2047
  */
1946
2048
  const _sanitizeDisallowedNode = function _sanitizeDisallowedNode(currentNode, tagName, root) {
1947
2049
  if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName) && _matchesNameCheck(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)) return false;
1948
- if (KEEP_CONTENT && !FORBID_CONTENTS[tagName]) {
2050
+ if (KEEP_CONTENT && !FORBID_CONTENTS[tagName] && !FORBID_CONTENTS[stringToLowerCase(tagName)]) {
1949
2051
  const parentNode = getParentNode(currentNode);
1950
2052
  const childNodes = getChildNodes(currentNode);
1951
2053
  if (childNodes && parentNode) {
@@ -1978,35 +2080,13 @@
1978
2080
  return set === defaultSet || set === setConfigSet ? clone(set) : set;
1979
2081
  };
1980
2082
  /**
1981
- * Shared guard for a node that a hook has detached from the walk tree,
1982
- * used after each element-hook site in _sanitizeElements. Detaching is a
1983
- * long-standing user pattern (issue #469; draw.io-style foreignObject
1984
- * filtering). Per the cached, unclobberable parentNode getter the node is
1985
- * genuinely out of the tree, so it can reach neither the serialized
1986
- * output nor an IN_PLACE live tree; treat it as removed and stop
1987
- * processing it. Without this guard, the unsafe-node / namespace checks
1988
- * would call _forceRemove on a parentless node and hit the REPORT-3
1989
- * fail-closed throw — which exists for nodes DOMPurify wants gone but
1990
- * *cannot* detach (clobbered / parentless roots), the opposite of a node
1991
- * that is already safely gone. The walk root is exempt: a detached
1992
- * IN_PLACE root is legitimate input and must still be fully sanitized,
1993
- * and a kill-decision on it must keep hitting the REPORT-3 throw.
1994
- *
1995
- * Nodes detached by hooks stay the hook's responsibility for placement:
1996
- * they are not recorded in DOMPurify.removed, so the post-walk IN_PLACE
1997
- * pass (which iterates DOMPurify.removed) does not reach them. But a
1998
- * hook-detached subtree can still hold a queued resource-event handler -
1999
- * e.g. an <img onload> that began loading when the caller built the live
2000
- * tree - which fires in page scope after sanitize returns even though the
2001
- * handler never reached the returned tree. That is the audit-5 F1 hazard,
2002
- * and the documented node.remove() hook pattern walks straight into it.
2003
- * So on the IN_PLACE path we neutralize the detached subtree inline,
2004
- * stripping its non-allow-listed attributes before returning, exactly as
2005
- * the post-walk pass does for _forceRemove'd subtrees.
2083
+ * A hook may have detached the node it was called for. If so, treat it as
2084
+ * removed and stop processing it; the walk root is exempt. On the IN_PLACE
2085
+ * path the detached subtree is neutralized right away.
2006
2086
  *
2007
2087
  * @param currentNode the node a hook may have detached
2008
2088
  * @param root the current walk root
2009
- * @return true if the node is detached and now handled, false otherwise
2089
+ * @return true if the node was detached
2010
2090
  */
2011
2091
  const _handleHookDetachedNode = function _handleHookDetachedNode(currentNode, root) {
2012
2092
  if (currentNode === root || getParentNode(currentNode) !== null) return false;
@@ -2023,6 +2103,7 @@
2023
2103
  * @return true if node was killed, false if left alive
2024
2104
  */
2025
2105
  const _sanitizeElements = function _sanitizeElements(currentNode, root) {
2106
+ _markVisited(currentNode);
2026
2107
  _executeHooks(hooks.beforeSanitizeElements, currentNode, null);
2027
2108
  if (_handleHookDetachedNode(currentNode, root)) return true;
2028
2109
  if (_isClobbered(currentNode)) {
@@ -2166,6 +2247,22 @@
2166
2247
  }
2167
2248
  };
2168
2249
  /**
2250
+ * _isUnsafeAttributeValue
2251
+ *
2252
+ * Value-based attribute checks that the attribute walk applies before any
2253
+ * hook can force-keep an attribute, shared with the public
2254
+ * isValidAttribute() so that it never approves a value sanitize() would
2255
+ * strip.
2256
+ *
2257
+ * @param name the attribute name as found on the node (case preserved)
2258
+ * @param value the attribute value
2259
+ * @returns true if the attribute must be removed because of its value
2260
+ */
2261
+ const _isUnsafeAttributeValue = function _isUnsafeAttributeValue(name, value) {
2262
+ if (SAFE_FOR_XML && regExpTest(/((--!?|])>)|<\/(style|script|title|xmp|textarea|noscript|iframe|noembed|noframes)/i, value)) return true;
2263
+ return stringToLowerCase(name) === "attributename" && stringMatch(value, "href") !== null;
2264
+ };
2265
+ /**
2169
2266
  * _sanitizeAttributes
2170
2267
  *
2171
2268
  * @protect attributes
@@ -2209,11 +2306,7 @@
2209
2306
  value = SANITIZE_NAMED_PROPS_PREFIX + value;
2210
2307
  recreatedNamedProp = true;
2211
2308
  }
2212
- if (SAFE_FOR_XML && regExpTest(/((--!?|])>)|<\/(style|script|title|xmp|textarea|noscript|iframe|noembed|noframes)/i, value)) {
2213
- _removeAttribute(name, currentNode, attr);
2214
- continue;
2215
- }
2216
- if (lcName === "attributename" && stringMatch(value, "href")) {
2309
+ if (_isUnsafeAttributeValue(name, value)) {
2217
2310
  _removeAttribute(name, currentNode, attr);
2218
2311
  continue;
2219
2312
  }
@@ -2261,24 +2354,13 @@
2261
2354
  }
2262
2355
  }
2263
2356
  }
2357
+ if (SAFE_FOR_TEMPLATES) _scrubOwnCharacterData(fragment);
2264
2358
  _executeHooks(hooks.afterSanitizeShadowDOM, fragment, null);
2265
2359
  };
2266
2360
  /**
2267
- * _sanitizeAttachedShadowRoots
2268
- *
2269
- * Walks `root` and feeds every attached shadow root we encounter into
2270
- * the existing _sanitizeShadowDOM pipeline. The default node iterator
2271
- * does not descend into shadow trees, so nodes inside an attached
2272
- * shadow root would otherwise be skipped entirely.
2273
- *
2274
- * Two real input paths put attached shadow roots in front of us:
2275
- * 1. IN_PLACE on a DOM node that already has shadow roots attached.
2276
- * 2. DOM-node input where importNode(dirty, true) deep-clones the
2277
- * shadow root because it was created with `clonable: true`.
2278
- *
2279
- * This pass runs once, up front, so the main iteration loop (and the
2280
- * existing _sanitizeShadowDOM template-content recursion) stay
2281
- * untouched — string-input paths are not affected.
2361
+ * Sanitizes every attached shadow root under `root`, since the node
2362
+ * iterator does not enter shadow trees. Needed for IN_PLACE input and for
2363
+ * DOM input whose clonable shadow roots were copied by importNode().
2282
2364
  *
2283
2365
  * @param root the subtree root to walk for attached shadow roots
2284
2366
  */
@@ -2343,6 +2425,8 @@
2343
2425
  if (hooks.uponSanitizeAttribute.length > 0) ALLOWED_ATTR = clone(ALLOWED_ATTR);
2344
2426
  DOMPurify.removed = [];
2345
2427
  const inPlace = IN_PLACE && typeof dirty !== "string" && _isNode(dirty);
2428
+ let inPlaceNodes = [];
2429
+ const outerVisited = IN_PLACE_VISITED;
2346
2430
  if (inPlace) {
2347
2431
  _neutralizePatchLinkage(dirty);
2348
2432
  const nn = _readNodeName(dirty);
@@ -2357,12 +2441,8 @@
2357
2441
  _neutralizeRoot(dirty);
2358
2442
  throw typeErrorCreate("root node is clobbered and cannot be sanitized in-place");
2359
2443
  }
2360
- try {
2361
- _sanitizeAttachedShadowRoots(dirty);
2362
- } catch (error) {
2363
- _neutralizeRoot(dirty);
2364
- throw error;
2365
- }
2444
+ inPlaceNodes = _collectLiveNodes(dirty);
2445
+ IN_PLACE_VISITED = setCreate();
2366
2446
  } else if (_isNode(dirty)) {
2367
2447
  body = _initDocument("<!---->");
2368
2448
  importedNode = body.ownerDocument.importNode(dirty, true);
@@ -2378,30 +2458,25 @@
2378
2458
  if (body && FORCE_BODY) _forceRemove(body.firstChild);
2379
2459
  const walkRoot = inPlace ? dirty : body;
2380
2460
  try {
2461
+ if (inPlace) _sanitizeAttachedShadowRoots(dirty);
2381
2462
  const nodeIterator = _createNodeIterator(walkRoot);
2382
2463
  while (currentNode = nodeIterator.nextNode()) {
2383
2464
  _sanitizeElements(currentNode, walkRoot);
2384
2465
  _sanitizeAttributes(currentNode, walkRoot);
2385
2466
  if (_isDocumentFragment(currentNode.content)) _sanitizeShadowDOM2(currentNode.content);
2386
2467
  }
2468
+ if (inPlace) _sanitizeUnvisitedNodes(dirty, inPlaceNodes);
2387
2469
  } catch (error) {
2388
2470
  if (inPlace) {
2389
2471
  _neutralizeRoot(dirty);
2390
- arrayForEach(DOMPurify.removed, (entry) => {
2391
- if (entry.element) _neutralizeSubtree(entry.element);
2392
- });
2472
+ _neutralizeDiscarded(dirty, inPlaceNodes);
2393
2473
  }
2394
2474
  throw error;
2475
+ } finally {
2476
+ IN_PLACE_VISITED = outerVisited;
2395
2477
  }
2396
2478
  if (inPlace) {
2397
- let rootWasRemoved = false;
2398
- arrayForEach(DOMPurify.removed, (entry) => {
2399
- if (entry.element) {
2400
- if (entry.element === dirty) rootWasRemoved = true;
2401
- _neutralizeSubtree(entry.element);
2402
- }
2403
- });
2404
- if (rootWasRemoved) throw typeErrorCreate("a node selected for removal could not be safely returned; refusing to sanitize in place");
2479
+ if (_neutralizeDiscarded(dirty, inPlaceNodes)) throw typeErrorCreate("a node selected for removal could not be safely returned; refusing to sanitize in place");
2405
2480
  if (SAFE_FOR_TEMPLATES) _scrubTemplateExpressions2(dirty);
2406
2481
  return dirty;
2407
2482
  }
@@ -2438,6 +2513,7 @@
2438
2513
  if (!CONFIG) _parseConfig({});
2439
2514
  const lcTag = transformCaseFunc(tag);
2440
2515
  const lcName = transformCaseFunc(attr);
2516
+ if (typeof value === "string" && (_isUnsafeAttributeValue(attr, value) || !ALLOW_SELF_CLOSE_IN_ATTR && regExpTest(SELF_CLOSING_TAG, value))) return false;
2441
2517
  return _isValidAttribute(lcTag, lcName, value);
2442
2518
  };
2443
2519
  DOMPurify.addHook = function(entryPoint, hookFunction) {