dompurify 3.4.11 → 3.4.13

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/src/purify.ts CHANGED
@@ -213,6 +213,10 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
213
213
  Node && Node.prototype ? lookupGetter(Node.prototype, 'nodeType') : null;
214
214
  const getNodeName =
215
215
  Node && Node.prototype ? lookupGetter(Node.prototype, 'nodeName') : null;
216
+ const getOwnerDocument =
217
+ Node && Node.prototype
218
+ ? lookupGetter(Node.prototype, 'ownerDocument')
219
+ : null;
216
220
 
217
221
  // As per issue #47, the web-components registry is inherited by a
218
222
  // new document created via createHTMLDocument. As per the spec
@@ -1188,6 +1192,14 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1188
1192
  * @param root the in-place root to empty
1189
1193
  */
1190
1194
  const _neutralizeRoot = function (root: Node): void {
1195
+ /* Strip every disallowed attribute (on* handlers included) off the whole
1196
+ subtree BEFORE detaching anything. Detaching first would hand back
1197
+ handler-bearing originals (e.g. an already-loading `<img onerror>`)
1198
+ whose queued resource event still fires in page scope after we throw.
1199
+ Clobber-safe reads; a doomed clobbered node's own attributes are
1200
+ irrelevant while its non-clobbered descendants are reached and scrubbed. */
1201
+ _neutralizeSubtree(root);
1202
+
1191
1203
  const childNodes = getChildNodes(root);
1192
1204
  if (childNodes) {
1193
1205
  const snapshot: Node[] = [];
@@ -1327,6 +1339,100 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1327
1339
  }
1328
1340
  };
1329
1341
 
1342
+ /**
1343
+ * _neutralizePatchLinkage
1344
+ *
1345
+ * IN_PLACE entry pre-pass (declarative-partial-updates / streaming
1346
+ * hardening, https://github.com/WICG/declarative-partial-updates).
1347
+ *
1348
+ * The main walk strips patch linkage (`for`/`patchsrc`) and removes range
1349
+ * markers (PIs / markup comments) node-by-node, in document order, AS it
1350
+ * reaches each node. On a live in-place root that leaves a window: from the
1351
+ * moment the root is connected until the walk arrives at a given node, that
1352
+ * node's linkage is live. A patch applied on connection/stream can fire as
1353
+ * a microtask during the walk and inject or teleport an unsanitized DOM
1354
+ * range into a region the iterator has already passed and will not revisit,
1355
+ * so the post-return "tree is sanitized" contract is violated. Sweep the
1356
+ * whole tree once up front and sever every linkage before the walk begins,
1357
+ * closing that window.
1358
+ *
1359
+ * This CANNOT undo a patch that already fired before sanitize ran — that is
1360
+ * the irreducible "do not IN_PLACE a live-connected attacker tree" caveat —
1361
+ * but it closes everything from sanitize-start onward. Gated on SAFE_FOR_XML
1362
+ * to group with the rest of the declarative-partial-updates handling and
1363
+ * stay overridable, consistent with the codebase.
1364
+ *
1365
+ * Clobber-safe traversal (cached childNodes getter); per-node try/catch so a
1366
+ * clobbered root cannot defeat the sweep of its non-clobbered descendants.
1367
+ *
1368
+ * NOTE (pending real-Chrome confirmation, see test/declarative-patch-probe
1369
+ * .html Q1): this mirrors the existing policy of keeping `for` on
1370
+ * <label>/<output>. If the shipping feature can drive a patch through a
1371
+ * surviving `for`-on-label/output + `id` pair, this pre-pass and the
1372
+ * attribute check at _isBasicCustomElement's caller must additionally drop
1373
+ * that pair on the IN_PLACE path. Left as-is until the taxonomy is verified.
1374
+ *
1375
+ * @param root the in-place root to sweep
1376
+ */
1377
+ const _neutralizePatchLinkage = function (root: Node): void {
1378
+ if (!SAFE_FOR_XML) {
1379
+ return;
1380
+ }
1381
+
1382
+ const stack: Node[] = [root];
1383
+ while (stack.length > 0) {
1384
+ const node = stack.pop();
1385
+ const nodeType = getNodeType ? getNodeType(node) : (node as any).nodeType;
1386
+
1387
+ /* Remove range markers (the target side of a patch linkage): every
1388
+ processing instruction, and any markup-bearing comment. */
1389
+ if (
1390
+ nodeType === NODE_TYPE.processingInstruction ||
1391
+ (nodeType === NODE_TYPE.comment &&
1392
+ regExpTest(EXPRESSIONS.COMMENT_MARKUP_PROBE, (node as any).data))
1393
+ ) {
1394
+ try {
1395
+ remove(node);
1396
+ } catch (_) {
1397
+ /* Best-effort */
1398
+ }
1399
+
1400
+ continue;
1401
+ }
1402
+
1403
+ /* Strip patch-source attributes (the source side) off elements. */
1404
+ if (nodeType === NODE_TYPE.element) {
1405
+ const element = node as Element;
1406
+ const lcTag = transformCaseFunc(
1407
+ getNodeName ? getNodeName(node) : (node as any).nodeName
1408
+ );
1409
+ try {
1410
+ if (element.hasAttribute && element.hasAttribute('patchsrc')) {
1411
+ element.removeAttribute('patchsrc');
1412
+ }
1413
+
1414
+ if (
1415
+ element.hasAttribute &&
1416
+ element.hasAttribute('for') &&
1417
+ lcTag !== 'label' &&
1418
+ lcTag !== 'output'
1419
+ ) {
1420
+ element.removeAttribute('for');
1421
+ }
1422
+ } catch (_) {
1423
+ /* Clobbered removeAttribute/hasAttribute on a doomed node — ignore */
1424
+ }
1425
+ }
1426
+
1427
+ const childNodes = getChildNodes(node);
1428
+ if (childNodes) {
1429
+ for (let i = childNodes.length - 1; i >= 0; --i) {
1430
+ stack.push(childNodes[i]);
1431
+ }
1432
+ }
1433
+ }
1434
+ };
1435
+
1330
1436
  /**
1331
1437
  * _initDocument
1332
1438
  *
@@ -1407,8 +1513,18 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1407
1513
  * @return The created NodeIterator
1408
1514
  */
1409
1515
  const _createNodeIterator = function (root: Node): NodeIterator {
1516
+ /* Read ownerDocument through the cached Node.prototype getter, never the
1517
+ direct property. HTMLFormElement has [LegacyOverrideBuiltIns], so a
1518
+ clobbering child (<input name="ownerDocument"> or a form-associated
1519
+ external input) shadows the prototype getter and makes a direct read
1520
+ return that <input>. createNodeIterator.call(<input>, ...) then throws
1521
+ "Illegal invocation", and on the IN_PLACE path that throw lands before
1522
+ the walk's fail-closed barrier - leaving the caller's live tree, with
1523
+ any already-armed handler in it, un-neutralized. The cached getter
1524
+ returns the real Document regardless of the clobber. */
1525
+ const doc = getOwnerDocument ? getOwnerDocument(root) : root.ownerDocument;
1410
1526
  return createNodeIterator.call(
1411
- root.ownerDocument || root,
1527
+ doc || root,
1412
1528
  root,
1413
1529
  // eslint-disable-next-line no-bitwise
1414
1530
  NodeFilter.SHOW_ELEMENT |
@@ -1456,8 +1572,12 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1456
1572
  */
1457
1573
  const _scrubTemplateExpressions = function (node: Element): void {
1458
1574
  node.normalize();
1575
+ /* Clobber-safe ownerDocument read, same reasoning as _createNodeIterator:
1576
+ under SAFE_FOR_TEMPLATES this runs on the live IN_PLACE root, which may
1577
+ carry a form-named-getter override of ownerDocument. */
1578
+ const doc = getOwnerDocument ? getOwnerDocument(node) : node.ownerDocument;
1459
1579
  const walker = createNodeIterator.call(
1460
- node.ownerDocument || node,
1580
+ doc || node,
1461
1581
  node,
1462
1582
  // eslint-disable-next-line no-bitwise
1463
1583
  NodeFilter.SHOW_TEXT |
@@ -1661,9 +1781,15 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1661
1781
  /**
1662
1782
  * Handle a node whose tag is forbidden or not allowlisted: keep
1663
1783
  * allowed custom elements (false return exits _sanitizeElements
1664
- * early - namespace/fallback checks and the afterSanitizeElements
1665
- * hook are intentionally skipped for kept custom elements), else
1666
- * hoist content per KEEP_CONTENT and remove.
1784
+ * early - the namespace and fallback-tag removal checks are
1785
+ * intentionally skipped for kept custom elements), else hoist
1786
+ * content per KEEP_CONTENT and remove.
1787
+ *
1788
+ * A kept custom element is the ONLY case in which this function
1789
+ * returns false, so the caller uses that return value to run the
1790
+ * afterSanitizeElements hook on the kept element and keep the
1791
+ * element-hook lifecycle consistent with normal allowlisted
1792
+ * elements (GHSA-c2j3-45gr-mqc4).
1667
1793
  *
1668
1794
  * @param currentNode the disallowed node
1669
1795
  * @param tagName the node's transformCaseFunc'd tag name
@@ -1671,7 +1797,8 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1671
1797
  */
1672
1798
  const _sanitizeDisallowedNode = function (
1673
1799
  currentNode: any,
1674
- tagName: string
1800
+ tagName: string,
1801
+ root: Node
1675
1802
  ): boolean {
1676
1803
  /* Check if we have a custom element to handle */
1677
1804
  if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName)) {
@@ -1705,31 +1832,31 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1705
1832
  if (childNodes && parentNode) {
1706
1833
  const childCount = childNodes.length;
1707
1834
 
1708
- /* In-place: hoist the *original* children so the iterator visits
1709
- and sanitises them through the same allowlist pass as every other
1710
- node. The caller built the tree in the live document, so the
1711
- originals carry already-queued resource events (`<img onerror>`,
1712
- `<video>`/`<audio>` error, lazy/`onload`, …); cloning would leave
1713
- those originals detached but still armed, firing in page scope
1714
- while the returned tree looked clean. Moving is safe in-place: the
1715
- root is pre-validated as an allowed tag and so is never the node
1716
- being removed, which keeps `parentNode` inside the iterator root
1717
- and the relocated child inside the serialised tree.
1718
-
1719
- Otherwise (string / DOM-copy paths): clone. The iterator is rooted
1720
- at and the result serialised from `body`, so a restrictive
1721
- ALLOWED_TAGS that removes `body` itself must leave its content in
1722
- place, which only cloning does; and those paths parse into an
1723
- inert document, so their discarded originals never had a queued
1724
- event to neutralise.
1835
+ /* Hoist by moving each child up one level rather than deep-cloning
1836
+ it. Moving transfers every descendant exactly once, so a chain of
1837
+ nested disallowed elements costs O(n) instead of the O(n^2) that
1838
+ re-cloning the shrinking subtree at each level produced; it also
1839
+ empties the removed original, so `DOMPurify.removed` no longer
1840
+ pins whole subtrees. Moving preserves the in-place guarantee too:
1841
+ an original carrying already-queued resource events (`<img
1842
+ onerror>`, `<video>`/`<audio>` error, lazy/`onload`, …) is
1843
+ relocated and sanitised rather than left detached but still armed.
1844
+
1845
+ The sole case that must clone is removing the walk root itself.
1846
+ The result is serialised from the root's subtree, so a restrictive
1847
+ ALLOWED_TAGS that strips the root (`body` on the string path) must
1848
+ leave the content inside it, which only cloning does. In IN_PLACE
1849
+ the root is pre-validated as an allowed tag and so is never removed
1850
+ here, so that path always takes the move branch.
1725
1851
 
1726
1852
  `childNodes` is live; a tail-to-head walk keeps `childNodes[i]`
1727
1853
  valid whether we move (drops the trailing entry) or clone (leaves
1728
1854
  the list intact). */
1729
1855
  for (let i = childCount - 1; i >= 0; --i) {
1730
- const hoisted = IN_PLACE
1731
- ? childNodes[i]
1732
- : cloneNode(childNodes[i], true);
1856
+ const hoisted =
1857
+ currentNode === root
1858
+ ? cloneNode(childNodes[i], true)
1859
+ : childNodes[i];
1733
1860
  parentNode.insertBefore(hoisted, getNextSibling(currentNode));
1734
1861
  }
1735
1862
  }
@@ -1739,6 +1866,33 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1739
1866
  return true;
1740
1867
  };
1741
1868
 
1869
+ /**
1870
+ * Fork a hook-mutable allowlist off its shared binding the first time a
1871
+ * (possibly lazily-installed) uponSanitize* hook is about to see it, so the
1872
+ * hook cannot widen the per-instance default or the setConfig binding by
1873
+ * reference and leak past the call. Returns the set unchanged once it is
1874
+ * already call-local, so repeated calls across elements are idempotent.
1875
+ *
1876
+ * @param hookList the uponSanitize* hook array for this event
1877
+ * @param set the current ALLOWED_TAGS / ALLOWED_ATTR binding
1878
+ * @param defaultSet the per-instance DEFAULT_ALLOWED_* constant
1879
+ * @param setConfigSet the captured setConfig() binding, or null
1880
+ * @return a call-local clone if a hook is present and set is still shared,
1881
+ * else set unchanged
1882
+ */
1883
+ const _forkSharedAllowlist = function <T extends Record<string, any>>(
1884
+ hookList: unknown[],
1885
+ set: T,
1886
+ defaultSet: T,
1887
+ setConfigSet: T | null
1888
+ ): T {
1889
+ if (hookList.length === 0) {
1890
+ return set;
1891
+ }
1892
+
1893
+ return set === defaultSet || set === setConfigSet ? clone(set) : set;
1894
+ };
1895
+
1742
1896
  /**
1743
1897
  * _sanitizeElements
1744
1898
  *
@@ -1748,10 +1902,23 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1748
1902
  * @param currentNode to check for permission to exist
1749
1903
  * @return true if node was killed, false if left alive
1750
1904
  */
1751
- const _sanitizeElements = function (currentNode: any): boolean {
1905
+ // eslint-disable-next-line complexity
1906
+ const _sanitizeElements = function (currentNode: any, root: Node): boolean {
1752
1907
  /* Execute a hook if present */
1753
1908
  _executeHooks(hooks.beforeSanitizeElements, currentNode, null);
1754
1909
 
1910
+ /* A hook may have detached the node - treat it as removed (see the
1911
+ detached-node comment after the uponSanitizeElement hook below). On
1912
+ the IN_PLACE path, neutralize the detached subtree first so a queued
1913
+ resource handler on it cannot fire in page scope after we return. */
1914
+ if (currentNode !== root && getParentNode(currentNode) === null) {
1915
+ if (IN_PLACE) {
1916
+ _neutralizeSubtree(currentNode);
1917
+ }
1918
+
1919
+ return true;
1920
+ }
1921
+
1755
1922
  /* Check if element is clobbered or can clobber */
1756
1923
  if (_isClobbered(currentNode)) {
1757
1924
  _forceRemove(currentNode);
@@ -1763,12 +1930,56 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1763
1930
  getNodeName ? getNodeName(currentNode) : currentNode.nodeName
1764
1931
  );
1765
1932
 
1933
+ /* Close the pre-walk clone-guard's timing gap: an uponSanitizeElement
1934
+ hook may have been installed after that guard sampled the hook arrays
1935
+ (e.g. lazily from beforeSanitizeElements), leaving ALLOWED_TAGS still
1936
+ aliasing a shared binding that a widening hook would mutate by
1937
+ reference. Fork it before exposing it to the hook. */
1938
+ ALLOWED_TAGS = _forkSharedAllowlist(
1939
+ hooks.uponSanitizeElement,
1940
+ ALLOWED_TAGS,
1941
+ DEFAULT_ALLOWED_TAGS,
1942
+ SET_CONFIG_ALLOWED_TAGS
1943
+ );
1944
+
1766
1945
  /* Execute a hook if present */
1767
1946
  _executeHooks(hooks.uponSanitizeElement, currentNode, {
1768
1947
  tagName,
1769
1948
  allowedTags: ALLOWED_TAGS,
1770
1949
  });
1771
1950
 
1951
+ /* A hook may have detached the node from the tree — a long-standing
1952
+ user pattern (issue #469; draw.io-style foreignObject filtering).
1953
+ Per the cached, unclobberable parentNode getter the node is
1954
+ genuinely out of the tree, so it can reach neither the serialized
1955
+ output nor an IN_PLACE live tree; treat it as removed and stop
1956
+ processing it. Without this guard, the unsafe-node / namespace
1957
+ checks below would call _forceRemove on a parentless node and hit
1958
+ the REPORT-3 fail-closed throw — which exists for nodes DOMPurify
1959
+ wants gone but *cannot* detach (clobbered / parentless roots), the
1960
+ opposite of a node that is already safely gone. The walk root is
1961
+ exempt: a detached IN_PLACE root is legitimate input and must still
1962
+ be fully sanitized, and a kill-decision on it must keep hitting the
1963
+ REPORT-3 throw. Nodes detached by hooks stay the hook's
1964
+ responsibility for placement: they are not recorded in
1965
+ DOMPurify.removed, so the post-walk IN_PLACE pass (which iterates
1966
+ DOMPurify.removed) does not reach them. But a hook-detached subtree
1967
+ can still hold a queued resource-event handler - e.g. an <img onload>
1968
+ that began loading when the caller built the live tree - which fires
1969
+ in page scope after sanitize returns even though the handler never
1970
+ reached the returned tree. That is the audit-5 F1 hazard, and the
1971
+ documented node.remove() hook pattern walks straight into it. So on
1972
+ the IN_PLACE path we neutralize the detached subtree inline here,
1973
+ stripping its non-allow-listed attributes before returning, exactly
1974
+ as the post-walk pass does for _forceRemove'd subtrees. */
1975
+ if (currentNode !== root && getParentNode(currentNode) === null) {
1976
+ if (IN_PLACE) {
1977
+ _neutralizeSubtree(currentNode);
1978
+ }
1979
+
1980
+ return true;
1981
+ }
1982
+
1772
1983
  /* Remove mXSS vectors, processing instructions and risky comments */
1773
1984
  if (_isUnsafeNode(currentNode, tagName)) {
1774
1985
  _forceRemove(currentNode);
@@ -1784,7 +1995,24 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1784
1995
  ) &&
1785
1996
  !ALLOWED_TAGS[tagName])
1786
1997
  ) {
1787
- return _sanitizeDisallowedNode(currentNode, tagName);
1998
+ const removed = _sanitizeDisallowedNode(currentNode, tagName, root);
1999
+
2000
+ /* A false return means the node is a custom element kept via
2001
+ CUSTOM_ELEMENT_HANDLING - the only keep path through
2002
+ _sanitizeDisallowedNode. Run afterSanitizeElements on it so the
2003
+ element-hook lifecycle matches normal allowlisted elements: a
2004
+ security policy applied in this hook (e.g. stripping an attribute
2005
+ from every surviving element) must not silently skip kept custom
2006
+ elements (GHSA-c2j3-45gr-mqc4). This mirrors the normal-element
2007
+ tail below - the hook runs, then the walker's subsequent
2008
+ _sanitizeAttributes pass sanitizes the element's attributes. The
2009
+ deliberately skipped namespace and fallback-tag removal checks stay
2010
+ skipped; they are removal decisions, not the hook contract. */
2011
+ if (removed === false) {
2012
+ _executeHooks(hooks.afterSanitizeElements, currentNode, null);
2013
+ }
2014
+
2015
+ return removed;
1788
2016
  }
1789
2017
 
1790
2018
  /* Check whether element has a valid namespace.
@@ -1846,6 +2074,42 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1846
2074
  return false;
1847
2075
  }
1848
2076
 
2077
+ /* Reject declarative-partial-updates patch-linkage attributes
2078
+ (https://github.com/WICG/declarative-partial-updates).
2079
+
2080
+ Empirical note (Chrome 150, verified — see
2081
+ test/declarative-patch-probe-v3.html): expansion is NOT applied after
2082
+ sanitization. For the string path it fires during sanitize()'s own
2083
+ parse, so the walk sees and sanitizes the fully materialized expanded
2084
+ tree — teleports into MathML/SVG integration points included; a
2085
+ weaponized `<template for>`->`<img onerror>` comes back with the handler
2086
+ stripped. For the IN_PLACE path it fires on connection, before the walk.
2087
+ Either way DOMPurify is NOT blind to the patch.
2088
+
2089
+ This removal is therefore defense-in-depth rather than the sole barrier:
2090
+ it prevents live linkage from surviving into the OUTPUT and re-expanding
2091
+ in the caller's context, and keeps behaviour deterministic if a future
2092
+ engine defers expansion. `for` is legitimate only on <label>/<output>;
2093
+ anywhere else (notably <template for>) it links the element to a patch
2094
+ target and teleports or removes an arbitrary DOM range by id/marker name.
2095
+ `patchsrc` fetches remote markup and is treated as a script-loading
2096
+ mechanism (CSP). Gated on SAFE_FOR_XML so the removal groups with the
2097
+ other structural-threat checks and stays overridable, consistent with
2098
+ the rest of the codebase. PI range markers are already removed by
2099
+ _isUnsafeNode. */
2100
+ if (SAFE_FOR_XML && lcName === 'patchsrc') {
2101
+ return false;
2102
+ }
2103
+
2104
+ if (
2105
+ SAFE_FOR_XML &&
2106
+ lcName === 'for' &&
2107
+ lcTag !== 'label' &&
2108
+ lcTag !== 'output'
2109
+ ) {
2110
+ return false;
2111
+ }
2112
+
1849
2113
  /* Make sure attribute cannot clobber */
1850
2114
  if (
1851
2115
  SANITIZE_DOM &&
@@ -2063,6 +2327,15 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2063
2327
  return;
2064
2328
  }
2065
2329
 
2330
+ /* Same lazy-install guard as uponSanitizeElement (see there): fork the
2331
+ attribute allowlist off its shared binding before a hook can see it. */
2332
+ ALLOWED_ATTR = _forkSharedAllowlist(
2333
+ hooks.uponSanitizeAttribute,
2334
+ ALLOWED_ATTR,
2335
+ DEFAULT_ALLOWED_ATTR,
2336
+ SET_CONFIG_ALLOWED_ATTR
2337
+ );
2338
+
2066
2339
  const hookEvent = {
2067
2340
  attrName: '',
2068
2341
  attrValue: '',
@@ -2185,7 +2458,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2185
2458
  _executeHooks(hooks.uponSanitizeShadowNode, shadowNode, null);
2186
2459
 
2187
2460
  /* Sanitize tags and elements */
2188
- _sanitizeElements(shadowNode);
2461
+ _sanitizeElements(shadowNode, fragment);
2189
2462
 
2190
2463
  /* Check attributes next */
2191
2464
  _sanitizeAttributes(shadowNode);
@@ -2391,6 +2664,12 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2391
2664
  const inPlace = IN_PLACE && typeof dirty !== 'string' && _isNode(dirty);
2392
2665
 
2393
2666
  if (inPlace) {
2667
+ /* Declarative-partial-updates / streaming pre-pass: sever every patch
2668
+ linkage across the live tree BEFORE the walk, so no patch can fire
2669
+ mid-walk and inject into an already-processed region. Runs first, so
2670
+ it also covers the forbidden/clobbered roots that throw below. */
2671
+ _neutralizePatchLinkage(dirty as Node);
2672
+
2394
2673
  /* Do some early pre-sanitization to avoid unsafe root nodes.
2395
2674
  Read nodeName through the cached prototype getter — a clobbering
2396
2675
  child named "nodeName" on the form root would otherwise shadow
@@ -2402,6 +2681,10 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2402
2681
  if (typeof nn === 'string') {
2403
2682
  const tagName = transformCaseFunc(nn);
2404
2683
  if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) {
2684
+ /* Fail closed on a live root: neutralize handlers/children before
2685
+ throwing, exactly as the mid-walk abort path does. */
2686
+ _neutralizeRoot(dirty as Node);
2687
+
2405
2688
  throw typeErrorCreate(
2406
2689
  'root node is forbidden and cannot be sanitized in-place'
2407
2690
  );
@@ -2419,6 +2702,11 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2419
2702
  the application unsanitized. Refuse to sanitize such a root
2420
2703
  the same way we refuse a forbidden tag. GHSA-r47g-fvhr-h676. */
2421
2704
  if (_isClobbered(dirty as Element)) {
2705
+ /* Fail closed on a live clobbered root before throwing.
2706
+ _neutralizeRoot's reads are clobber-safe (cached getters); the
2707
+ form's non-clobbered descendants, e.g. an armed <img>, are scrubbed. */
2708
+ _neutralizeRoot(dirty as Node);
2709
+
2422
2710
  throw typeErrorCreate(
2423
2711
  'root node is clobbered and cannot be sanitized in-place'
2424
2712
  );
@@ -2489,21 +2777,25 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2489
2777
  }
2490
2778
 
2491
2779
  /* Get node iterator */
2492
- const nodeIterator = _createNodeIterator(inPlace ? dirty : body);
2780
+ const walkRoot: Node = inPlace ? (dirty as Node) : body;
2493
2781
 
2494
2782
  /* Now start iterating over the created document.
2495
2783
  The walk runs inside an exception barrier (campaign-3 F2): a re-entrant
2496
2784
  engine/custom-element mutation can detach a node mid-walk so
2497
2785
  `_forceRemove`'s parentless guard throws, aborting the loop. Without the
2498
2786
  barrier the caller's in-place tree would be left half-sanitized with the
2499
- unvisited tail still armed. On any throw we fail closed — strip the
2500
- in-place root bare then rethrow so the existing throw contract is
2501
- preserved. (String/DOM-copy paths never return the partial body, so the
2502
- propagating throw is already fail-closed there.) */
2787
+ unvisited tail still armed. _createNodeIterator itself is inside the
2788
+ barrier too: constructing the iterator dereferences the root's document,
2789
+ and any failure there (e.g. an exotic/clobbered root) must still fail
2790
+ closed rather than skip the neutralize. On any throw we fail closed -
2791
+ strip the in-place root bare - then rethrow so the existing throw
2792
+ contract is preserved. (String/DOM-copy paths never return the partial
2793
+ body, so the propagating throw is already fail-closed there.) */
2503
2794
  try {
2795
+ const nodeIterator = _createNodeIterator(walkRoot);
2504
2796
  while ((currentNode = nodeIterator.nextNode())) {
2505
2797
  /* Sanitize tags and elements */
2506
- _sanitizeElements(currentNode);
2798
+ _sanitizeElements(currentNode, walkRoot);
2507
2799
 
2508
2800
  /* Check attributes next */
2509
2801
  _sanitizeAttributes(currentNode);
@@ -2519,6 +2811,14 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2519
2811
  } catch (error) {
2520
2812
  if (inPlace) {
2521
2813
  _neutralizeRoot(dirty as Node);
2814
+ /* Nodes _forceRemove'd earlier in the aborted walk are already
2815
+ detached from the root, so _neutralizeRoot's subtree pass does not
2816
+ reach them. Defuse them too, mirroring the success-path loop below. */
2817
+ arrayForEach(DOMPurify.removed, (entry) => {
2818
+ if (entry.element) {
2819
+ _neutralizeSubtree(entry.element as Node);
2820
+ }
2821
+ });
2522
2822
  }
2523
2823
 
2524
2824
  throw error;