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