dompurify 3.4.12 → 3.4.14

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
@@ -65,6 +65,48 @@ const NODE_TYPE = {
65
65
  notation: 12, // Deprecated
66
66
  };
67
67
 
68
+ /* HTML-namespace elements whose child text nodes are serialized *literally*
69
+ (unescaped) by the HTML fragment-serialization algorithm. Two reparse-mXSS
70
+ shapes ride on that literal serialization:
71
+ (a) an element child - a tree the HTML parser can never build, but the DOM
72
+ API and an XML/XHTML parse can - after which a `</tag>`-bearing text
73
+ sibling breaks the element open on reparse; and
74
+ (b) text-only content that already carries the element's OWN end tag, e.g.
75
+ `<style>...</style><img onerror=x>` built as a node, which the literal
76
+ serializer emits verbatim for the HTML parser to re-open.
77
+ Shape (a) is handled by the firstElementChild branch in _isUnsafeNode; shape
78
+ (b) by the LITERAL_TEXT_CLOSE probe. Both read textContent (the raw-serialized
79
+ form for these elements) rather than innerHTML, because an XML/XHTML working
80
+ document serializes innerHTML with `<` escaped, which silently blinds the
81
+ innerHTML-based probes (rule 1's second probe and FALLBACK_TAG_CLOSE) there.
82
+ `script` is never allow-listed, but is kept here so the guard matches the
83
+ serializer's own literal-text list exactly. */
84
+ const LITERAL_TEXT_ELEMENT_NAMES = [
85
+ 'style',
86
+ 'script',
87
+ 'xmp',
88
+ 'iframe',
89
+ 'noembed',
90
+ 'noframes',
91
+ 'plaintext',
92
+ 'noscript',
93
+ ];
94
+ const LITERAL_TEXT_ELEMENTS = freeze(addToSet({}, LITERAL_TEXT_ELEMENT_NAMES));
95
+
96
+ /* Per-element end-tag matcher. On an HTML reparse the ONLY token that
97
+ terminates a literal-text element's raw content is its own end tag; a foreign
98
+ literal-text close (e.g. `</xmp>` sitting inside `<style>`) does not break
99
+ out, so matching is per-element, not a shared alternation. The lookahead
100
+ requires an HTML tag-name terminator (whitespace, `/` or `>`) so a longer
101
+ name such as `</styles` is not mistaken for `</style`. */
102
+ const LITERAL_TEXT_CLOSE = (function (): Record<string, RegExp> {
103
+ const map: Record<string, RegExp> = {};
104
+ arrayForEach(LITERAL_TEXT_ELEMENT_NAMES, (name) => {
105
+ map[name] = seal(new RegExp('</' + name + '(?=[\\t\\n\\f\\r />])', 'i'));
106
+ });
107
+ return freeze(map);
108
+ })();
109
+
68
110
  const getGlobal = function (): WindowLike {
69
111
  return typeof window === 'undefined' ? null : window;
70
112
  };
@@ -163,6 +205,28 @@ const _resolveSetOption = function (
163
205
  : fallback;
164
206
  };
165
207
 
208
+ /**
209
+ * Resolve an object-valued configuration option: a prototype-free clone
210
+ * of cfg[key] when it is an own, truthy object property, else a fresh
211
+ * fallback built by makeFallback (fresh on every parse, so a previous
212
+ * parse can never leak state into the next one).
213
+ *
214
+ * @param cfg the cloned, prototype-free configuration object
215
+ * @param key the configuration property to read
216
+ * @param makeFallback builds the fallback value when the option is absent
217
+ * @returns the resolved object
218
+ */
219
+ const _resolveObjectOption = function <T extends Record<string, any>>(
220
+ cfg: Config,
221
+ key: keyof Config,
222
+ makeFallback: () => T
223
+ ): T {
224
+ const value = objectHasOwnProperty(cfg, key) ? cfg[key] : undefined;
225
+ return value && typeof value === 'object'
226
+ ? clone(value as T)
227
+ : makeFallback();
228
+ };
229
+
166
230
  function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
167
231
  const DOMPurify: DOMPurify = (root: WindowLike) => createDOMPurify(root);
168
232
 
@@ -213,6 +277,22 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
213
277
  Node && Node.prototype ? lookupGetter(Node.prototype, 'nodeType') : null;
214
278
  const getNodeName =
215
279
  Node && Node.prototype ? lookupGetter(Node.prototype, 'nodeName') : null;
280
+ const getOwnerDocument =
281
+ Node && Node.prototype
282
+ ? lookupGetter(Node.prototype, 'ownerDocument')
283
+ : null;
284
+
285
+ /* Clobber-safe nodeType / nodeName reads through the cached Node.prototype
286
+ getters, with a direct-property fallback for environments that lack
287
+ Node.prototype. Sites that need a different fallback (e.g. _isClobbered
288
+ returns early on a null name) intentionally keep their own reads. */
289
+ const _readNodeType = function (node: Node): number {
290
+ return getNodeType ? getNodeType(node) : (node as any).nodeType;
291
+ };
292
+
293
+ const _readNodeName = function (node: Node): string {
294
+ return getNodeName ? getNodeName(node) : (node as any).nodeName;
295
+ };
216
296
 
217
297
  // As per issue #47, the web-components registry is inherited by a
218
298
  // new document created via createHTMLDocument. As per the spec
@@ -724,26 +804,23 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
724
804
  NAMESPACE =
725
805
  typeof cfg.NAMESPACE === 'string' ? cfg.NAMESPACE : HTML_NAMESPACE; // Default HTML namespace
726
806
 
727
- MATHML_TEXT_INTEGRATION_POINTS =
728
- objectHasOwnProperty(cfg, 'MATHML_TEXT_INTEGRATION_POINTS') &&
729
- cfg.MATHML_TEXT_INTEGRATION_POINTS &&
730
- typeof cfg.MATHML_TEXT_INTEGRATION_POINTS === 'object'
731
- ? clone(cfg.MATHML_TEXT_INTEGRATION_POINTS)
732
- : addToSet({}, DEFAULT_MATHML_TEXT_INTEGRATION_POINTS); // Default built-in map
733
-
734
- HTML_INTEGRATION_POINTS =
735
- objectHasOwnProperty(cfg, 'HTML_INTEGRATION_POINTS') &&
736
- cfg.HTML_INTEGRATION_POINTS &&
737
- typeof cfg.HTML_INTEGRATION_POINTS === 'object'
738
- ? clone(cfg.HTML_INTEGRATION_POINTS)
739
- : addToSet({}, DEFAULT_HTML_INTEGRATION_POINTS); // Default built-in map
740
-
741
- const customElementHandling =
742
- objectHasOwnProperty(cfg, 'CUSTOM_ELEMENT_HANDLING') &&
743
- cfg.CUSTOM_ELEMENT_HANDLING &&
744
- typeof cfg.CUSTOM_ELEMENT_HANDLING === 'object'
745
- ? clone(cfg.CUSTOM_ELEMENT_HANDLING)
746
- : create(null);
807
+ MATHML_TEXT_INTEGRATION_POINTS = _resolveObjectOption(
808
+ cfg,
809
+ 'MATHML_TEXT_INTEGRATION_POINTS',
810
+ () => addToSet({}, DEFAULT_MATHML_TEXT_INTEGRATION_POINTS) // Default built-in map
811
+ );
812
+
813
+ HTML_INTEGRATION_POINTS = _resolveObjectOption(
814
+ cfg,
815
+ 'HTML_INTEGRATION_POINTS',
816
+ () => addToSet({}, DEFAULT_HTML_INTEGRATION_POINTS) // Default built-in map
817
+ );
818
+
819
+ const customElementHandling = _resolveObjectOption(
820
+ cfg,
821
+ 'CUSTOM_ELEMENT_HANDLING',
822
+ () => create(null)
823
+ );
747
824
 
748
825
  CUSTOM_ELEMENT_HANDLING = create(null);
749
826
 
@@ -841,24 +918,6 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
841
918
  }
842
919
  }
843
920
 
844
- if (
845
- objectHasOwnProperty(cfg, 'ADD_URI_SAFE_ATTR') &&
846
- arrayIsArray(cfg.ADD_URI_SAFE_ATTR)
847
- ) {
848
- addToSet(URI_SAFE_ATTRIBUTES, cfg.ADD_URI_SAFE_ATTR, transformCaseFunc);
849
- }
850
-
851
- if (
852
- objectHasOwnProperty(cfg, 'FORBID_CONTENTS') &&
853
- arrayIsArray(cfg.FORBID_CONTENTS)
854
- ) {
855
- if (FORBID_CONTENTS === DEFAULT_FORBID_CONTENTS) {
856
- FORBID_CONTENTS = clone(FORBID_CONTENTS);
857
- }
858
-
859
- addToSet(FORBID_CONTENTS, cfg.FORBID_CONTENTS, transformCaseFunc);
860
- }
861
-
862
921
  if (
863
922
  objectHasOwnProperty(cfg, 'ADD_FORBID_CONTENTS') &&
864
923
  arrayIsArray(cfg.ADD_FORBID_CONTENTS)
@@ -1171,6 +1230,37 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1171
1230
  }
1172
1231
  };
1173
1232
 
1233
+ /**
1234
+ * _stripAttributeNode
1235
+ *
1236
+ * Remove a single Attr node case/namespace-exactly on an attribute-teardown
1237
+ * path. Name-based removeAttribute() ASCII-lowercases its lookup key for an
1238
+ * HTML element in an HTML document and so silently misses a case-preserved
1239
+ * handler (e.g. `ONERROR` off an XML/XHTML import) - the same defect
1240
+ * _removeAttribute() was fixed for, which a name-based call would reintroduce
1241
+ * on these IN_PLACE teardown paths. Unlike _removeAttribute this does not
1242
+ * record into DOMPurify.removed: the neutralize passes intentionally do not
1243
+ * book-keep. A clobbered/detached node falls back to best-effort name-based
1244
+ * removal.
1245
+ *
1246
+ * @param element the element to strip the attribute from
1247
+ * @param attribute the Attr node to remove
1248
+ * @param name the attribute's name, for the fallback path
1249
+ */
1250
+ const _stripAttributeNode = function (
1251
+ element: Element,
1252
+ attribute: Attr,
1253
+ name: string
1254
+ ): void {
1255
+ try {
1256
+ element.removeAttributeNode(attribute);
1257
+ } catch (_) {
1258
+ try {
1259
+ element.removeAttribute(name);
1260
+ } catch (_) {}
1261
+ }
1262
+ };
1263
+
1174
1264
  /**
1175
1265
  * _neutralizeRoot
1176
1266
  *
@@ -1217,11 +1307,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1217
1307
  const attribute = attributes[i];
1218
1308
  const name = attribute && attribute.name;
1219
1309
  if (typeof name === 'string') {
1220
- try {
1221
- (root as Element).removeAttribute(name);
1222
- } catch (_) {
1223
- /* Clobbered removeAttribute — ignore (fail-closed best effort) */
1224
- }
1310
+ _stripAttributeNode(root as Element, attribute, name);
1225
1311
  }
1226
1312
  }
1227
1313
  }
@@ -1230,24 +1316,53 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1230
1316
  /**
1231
1317
  * _removeAttribute
1232
1318
  *
1319
+ * Name-based getAttributeNode()/removeAttribute() ASCII-lowercase their
1320
+ * lookup key for HTML elements in an HTML document, so they silently miss an
1321
+ * attribute whose stored qualified name still contains uppercase ASCII
1322
+ * letters. That happens when the node came from a case-preserving source
1323
+ * (an XML/XHTML document imported via importNode(), or createAttributeNS()),
1324
+ * where e.g. `ONERROR` survives the walk: the policy check lowercases to
1325
+ * `onerror` and rejects it, but `removeAttribute('ONERROR')` looks up
1326
+ * `onerror` and finds nothing. Remove the exact Attr node instead, which is
1327
+ * case- and namespace-exact, and fall back to name-based removal only when
1328
+ * the caller could not supply the node.
1329
+ *
1233
1330
  * @param name an Attribute name
1234
1331
  * @param element a DOM node
1332
+ * @param attr the exact Attr node to remove, when the caller has it
1235
1333
  */
1236
- const _removeAttribute = function (name: string, element: Element): void {
1334
+ const _removeAttribute = function (
1335
+ name: string,
1336
+ element: Element,
1337
+ attr?: Attr | null
1338
+ ): void {
1339
+ if (!attr) {
1340
+ try {
1341
+ attr = element.getAttributeNode(name);
1342
+ } catch (_) {
1343
+ attr = null;
1344
+ }
1345
+ }
1346
+
1347
+ arrayPush(DOMPurify.removed, {
1348
+ attribute: attr || null,
1349
+ from: element,
1350
+ });
1351
+
1237
1352
  try {
1238
- arrayPush(DOMPurify.removed, {
1239
- attribute: element.getAttributeNode(name),
1240
- from: element,
1241
- });
1353
+ if (attr) {
1354
+ element.removeAttributeNode(attr);
1355
+ } else {
1356
+ element.removeAttribute(name);
1357
+ }
1242
1358
  } catch (_) {
1243
- arrayPush(DOMPurify.removed, {
1244
- attribute: null,
1245
- from: element,
1246
- });
1359
+ /* Clobbered or already-detached node - best-effort fall back to a
1360
+ name-based removal so the "is" handling below still runs. */
1361
+ try {
1362
+ element.removeAttribute(name);
1363
+ } catch (_) {}
1247
1364
  }
1248
1365
 
1249
- element.removeAttribute(name);
1250
-
1251
1366
  // We void attribute values for unremovable "is" attributes
1252
1367
  if (name === 'is') {
1253
1368
  if (RETURN_DOM || RETURN_DOM_FRAGMENT) {
@@ -1285,11 +1400,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1285
1400
  continue;
1286
1401
  }
1287
1402
 
1288
- try {
1289
- element.removeAttribute(name);
1290
- } catch (_) {
1291
- /* Clobbered removeAttribute on a doomed node — ignore */
1292
- }
1403
+ _stripAttributeNode(element, attribute, name);
1293
1404
  }
1294
1405
  };
1295
1406
 
@@ -1320,7 +1431,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1320
1431
 
1321
1432
  while (stack.length > 0) {
1322
1433
  const node = stack.pop();
1323
- const nodeType = getNodeType ? getNodeType(node) : (node as any).nodeType;
1434
+ const nodeType = _readNodeType(node);
1324
1435
 
1325
1436
  if (nodeType === NODE_TYPE.element) {
1326
1437
  _stripDisallowedAttributes(node as Element);
@@ -1370,6 +1481,32 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1370
1481
  *
1371
1482
  * @param root the in-place root to sweep
1372
1483
  */
1484
+ /**
1485
+ * Central policy for declarative-partial-updates patch-linkage attributes,
1486
+ * shared by the _neutralizePatchLinkage pre-pass and _isValidAttribute so
1487
+ * the two sites cannot drift: `patchsrc` always links, `for` links
1488
+ * everywhere except on <label>/<output>, and the whole policy is gated on
1489
+ * SAFE_FOR_XML (see the rationale block in _isValidAttribute).
1490
+ *
1491
+ * @param lcName the transformCaseFunc'd attribute name
1492
+ * @param lcTag the transformCaseFunc'd tag name of the carrying element
1493
+ * @return true if the attribute is patch linkage and must be dropped
1494
+ */
1495
+ const _isPatchLinkageAttribute = function (
1496
+ lcName: string,
1497
+ lcTag: string
1498
+ ): boolean {
1499
+ if (!SAFE_FOR_XML) {
1500
+ return false;
1501
+ }
1502
+
1503
+ if (lcName === 'patchsrc') {
1504
+ return true;
1505
+ }
1506
+
1507
+ return lcName === 'for' && lcTag !== 'label' && lcTag !== 'output';
1508
+ };
1509
+
1373
1510
  const _neutralizePatchLinkage = function (root: Node): void {
1374
1511
  if (!SAFE_FOR_XML) {
1375
1512
  return;
@@ -1378,7 +1515,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1378
1515
  const stack: Node[] = [root];
1379
1516
  while (stack.length > 0) {
1380
1517
  const node = stack.pop();
1381
- const nodeType = getNodeType ? getNodeType(node) : (node as any).nodeType;
1518
+ const nodeType = _readNodeType(node);
1382
1519
 
1383
1520
  /* Remove range markers (the target side of a patch linkage): every
1384
1521
  processing instruction, and any markup-bearing comment. */
@@ -1399,9 +1536,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1399
1536
  /* Strip patch-source attributes (the source side) off elements. */
1400
1537
  if (nodeType === NODE_TYPE.element) {
1401
1538
  const element = node as Element;
1402
- const lcTag = transformCaseFunc(
1403
- getNodeName ? getNodeName(node) : (node as any).nodeName
1404
- );
1539
+ const lcTag = transformCaseFunc(_readNodeName(node));
1405
1540
  try {
1406
1541
  if (element.hasAttribute && element.hasAttribute('patchsrc')) {
1407
1542
  element.removeAttribute('patchsrc');
@@ -1410,8 +1545,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1410
1545
  if (
1411
1546
  element.hasAttribute &&
1412
1547
  element.hasAttribute('for') &&
1413
- lcTag !== 'label' &&
1414
- lcTag !== 'output'
1548
+ _isPatchLinkageAttribute('for', lcTag)
1415
1549
  ) {
1416
1550
  element.removeAttribute('for');
1417
1551
  }
@@ -1509,8 +1643,18 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1509
1643
  * @return The created NodeIterator
1510
1644
  */
1511
1645
  const _createNodeIterator = function (root: Node): NodeIterator {
1646
+ /* Read ownerDocument through the cached Node.prototype getter, never the
1647
+ direct property. HTMLFormElement has [LegacyOverrideBuiltIns], so a
1648
+ clobbering child (<input name="ownerDocument"> or a form-associated
1649
+ external input) shadows the prototype getter and makes a direct read
1650
+ return that <input>. createNodeIterator.call(<input>, ...) then throws
1651
+ "Illegal invocation", and on the IN_PLACE path that throw lands before
1652
+ the walk's fail-closed barrier - leaving the caller's live tree, with
1653
+ any already-armed handler in it, un-neutralized. The cached getter
1654
+ returns the real Document regardless of the clobber. */
1655
+ const doc = getOwnerDocument ? getOwnerDocument(root) : root.ownerDocument;
1512
1656
  return createNodeIterator.call(
1513
- root.ownerDocument || root,
1657
+ doc || root,
1514
1658
  root,
1515
1659
  // eslint-disable-next-line no-bitwise
1516
1660
  NodeFilter.SHOW_ELEMENT |
@@ -1558,8 +1702,12 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1558
1702
  */
1559
1703
  const _scrubTemplateExpressions = function (node: Element): void {
1560
1704
  node.normalize();
1705
+ /* Clobber-safe ownerDocument read, same reasoning as _createNodeIterator:
1706
+ under SAFE_FOR_TEMPLATES this runs on the live IN_PLACE root, which may
1707
+ carry a form-named-getter override of ownerDocument. */
1708
+ const doc = getOwnerDocument ? getOwnerDocument(node) : node.ownerDocument;
1561
1709
  const walker = createNodeIterator.call(
1562
- node.ownerDocument || node,
1710
+ doc || node,
1563
1711
  node,
1564
1712
  // eslint-disable-next-line no-bitwise
1565
1713
  NodeFilter.SHOW_TEXT |
@@ -1733,12 +1881,22 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1733
1881
  return true;
1734
1882
  }
1735
1883
 
1736
- /* Remove risky CSS construction leading to mXSS */
1884
+ /* Remove rawtext/literal-text elements whose literal serialization
1885
+ re-opens markup on an HTML reparse - shapes (a) and (b) documented at
1886
+ LITERAL_TEXT_ELEMENTS. Both are invisible to rule 1 above (which
1887
+ self-disables once there is an element child, and whose second probe
1888
+ reads the innerHTML an XML/XHTML document serializes escaped), which
1889
+ is why both probes here read textContent instead. Previously only
1890
+ `style`-with-element-child was covered; every element in
1891
+ LITERAL_TEXT_ELEMENTS shares this literal serialization and is
1892
+ equally affected. */
1737
1893
  if (
1738
1894
  SAFE_FOR_XML &&
1739
1895
  currentNode.namespaceURI === HTML_NAMESPACE &&
1740
- tagName === 'style' &&
1741
- _isNode(currentNode.firstElementChild)
1896
+ LITERAL_TEXT_ELEMENTS[tagName] &&
1897
+ (_isNode(currentNode.firstElementChild) ||
1898
+ (typeof currentNode.textContent === 'string' &&
1899
+ regExpTest(LITERAL_TEXT_CLOSE[tagName], currentNode.textContent)))
1742
1900
  ) {
1743
1901
  return true;
1744
1902
  }
@@ -1760,6 +1918,34 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1760
1918
  return false;
1761
1919
  };
1762
1920
 
1921
+ /**
1922
+ * Evaluate a CUSTOM_ELEMENT_HANDLING check (a RegExp or a predicate
1923
+ * function, per the validation in _parseConfig) against a name.
1924
+ * Additional arguments are forwarded to predicate functions - the
1925
+ * attributeNameCheck predicate receives the tag name as its second
1926
+ * argument. A null/absent check never matches.
1927
+ *
1928
+ * @param check the configured tagNameCheck / attributeNameCheck value
1929
+ * @param name the name to test
1930
+ * @param args extra arguments forwarded to a predicate function
1931
+ * @return true if the check matches the name
1932
+ */
1933
+ const _matchesNameCheck = function (
1934
+ check: unknown,
1935
+ name: string,
1936
+ ...args: unknown[]
1937
+ ): boolean {
1938
+ if (check instanceof RegExp) {
1939
+ return regExpTest(check, name);
1940
+ }
1941
+
1942
+ if (check instanceof Function) {
1943
+ return Boolean(check(name, ...args));
1944
+ }
1945
+
1946
+ return false;
1947
+ };
1948
+
1763
1949
  /**
1764
1950
  * Handle a node whose tag is forbidden or not allowlisted: keep
1765
1951
  * allowed custom elements (false return exits _sanitizeElements
@@ -1779,23 +1965,16 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1779
1965
  */
1780
1966
  const _sanitizeDisallowedNode = function (
1781
1967
  currentNode: any,
1782
- tagName: string
1968
+ tagName: string,
1969
+ root: Node
1783
1970
  ): boolean {
1784
1971
  /* Check if we have a custom element to handle */
1785
- if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName)) {
1786
- if (
1787
- CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp &&
1788
- regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)
1789
- ) {
1790
- return false;
1791
- }
1792
-
1793
- if (
1794
- CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function &&
1795
- CUSTOM_ELEMENT_HANDLING.tagNameCheck(tagName)
1796
- ) {
1797
- return false;
1798
- }
1972
+ if (
1973
+ !FORBID_TAGS[tagName] &&
1974
+ _isBasicCustomElement(tagName) &&
1975
+ _matchesNameCheck(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)
1976
+ ) {
1977
+ return false;
1799
1978
  }
1800
1979
 
1801
1980
  /* Keep content except for bad-listed elements.
@@ -1813,31 +1992,31 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1813
1992
  if (childNodes && parentNode) {
1814
1993
  const childCount = childNodes.length;
1815
1994
 
1816
- /* In-place: hoist the *original* children so the iterator visits
1817
- and sanitises them through the same allowlist pass as every other
1818
- node. The caller built the tree in the live document, so the
1819
- originals carry already-queued resource events (`<img onerror>`,
1820
- `<video>`/`<audio>` error, lazy/`onload`, …); cloning would leave
1821
- those originals detached but still armed, firing in page scope
1822
- while the returned tree looked clean. Moving is safe in-place: the
1823
- root is pre-validated as an allowed tag and so is never the node
1824
- being removed, which keeps `parentNode` inside the iterator root
1825
- and the relocated child inside the serialised tree.
1826
-
1827
- Otherwise (string / DOM-copy paths): clone. The iterator is rooted
1828
- at and the result serialised from `body`, so a restrictive
1829
- ALLOWED_TAGS that removes `body` itself must leave its content in
1830
- place, which only cloning does; and those paths parse into an
1831
- inert document, so their discarded originals never had a queued
1832
- event to neutralise.
1995
+ /* Hoist by moving each child up one level rather than deep-cloning
1996
+ it. Moving transfers every descendant exactly once, so a chain of
1997
+ nested disallowed elements costs O(n) instead of the O(n^2) that
1998
+ re-cloning the shrinking subtree at each level produced; it also
1999
+ empties the removed original, so `DOMPurify.removed` no longer
2000
+ pins whole subtrees. Moving preserves the in-place guarantee too:
2001
+ an original carrying already-queued resource events (`<img
2002
+ onerror>`, `<video>`/`<audio>` error, lazy/`onload`, …) is
2003
+ relocated and sanitised rather than left detached but still armed.
2004
+
2005
+ The sole case that must clone is removing the walk root itself.
2006
+ The result is serialised from the root's subtree, so a restrictive
2007
+ ALLOWED_TAGS that strips the root (`body` on the string path) must
2008
+ leave the content inside it, which only cloning does. In IN_PLACE
2009
+ the root is pre-validated as an allowed tag and so is never removed
2010
+ here, so that path always takes the move branch.
1833
2011
 
1834
2012
  `childNodes` is live; a tail-to-head walk keeps `childNodes[i]`
1835
2013
  valid whether we move (drops the trailing entry) or clone (leaves
1836
2014
  the list intact). */
1837
2015
  for (let i = childCount - 1; i >= 0; --i) {
1838
- const hoisted = IN_PLACE
1839
- ? childNodes[i]
1840
- : cloneNode(childNodes[i], true);
2016
+ const hoisted =
2017
+ currentNode === root
2018
+ ? cloneNode(childNodes[i], true)
2019
+ : childNodes[i];
1841
2020
  parentNode.insertBefore(hoisted, getNextSibling(currentNode));
1842
2021
  }
1843
2022
  }
@@ -1847,6 +2026,79 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1847
2026
  return true;
1848
2027
  };
1849
2028
 
2029
+ /**
2030
+ * Fork a hook-mutable allowlist off its shared binding the first time a
2031
+ * (possibly lazily-installed) uponSanitize* hook is about to see it, so the
2032
+ * hook cannot widen the per-instance default or the setConfig binding by
2033
+ * reference and leak past the call. Returns the set unchanged once it is
2034
+ * already call-local, so repeated calls across elements are idempotent.
2035
+ *
2036
+ * @param hookList the uponSanitize* hook array for this event
2037
+ * @param set the current ALLOWED_TAGS / ALLOWED_ATTR binding
2038
+ * @param defaultSet the per-instance DEFAULT_ALLOWED_* constant
2039
+ * @param setConfigSet the captured setConfig() binding, or null
2040
+ * @return a call-local clone if a hook is present and set is still shared,
2041
+ * else set unchanged
2042
+ */
2043
+ const _forkSharedAllowlist = function <T extends Record<string, any>>(
2044
+ hookList: unknown[],
2045
+ set: T,
2046
+ defaultSet: T,
2047
+ setConfigSet: T | null
2048
+ ): T {
2049
+ if (hookList.length === 0) {
2050
+ return set;
2051
+ }
2052
+
2053
+ return set === defaultSet || set === setConfigSet ? clone(set) : set;
2054
+ };
2055
+
2056
+ /**
2057
+ * Shared guard for a node that a hook has detached from the walk tree,
2058
+ * used after each element-hook site in _sanitizeElements. Detaching is a
2059
+ * long-standing user pattern (issue #469; draw.io-style foreignObject
2060
+ * filtering). Per the cached, unclobberable parentNode getter the node is
2061
+ * genuinely out of the tree, so it can reach neither the serialized
2062
+ * output nor an IN_PLACE live tree; treat it as removed and stop
2063
+ * processing it. Without this guard, the unsafe-node / namespace checks
2064
+ * would call _forceRemove on a parentless node and hit the REPORT-3
2065
+ * fail-closed throw — which exists for nodes DOMPurify wants gone but
2066
+ * *cannot* detach (clobbered / parentless roots), the opposite of a node
2067
+ * that is already safely gone. The walk root is exempt: a detached
2068
+ * IN_PLACE root is legitimate input and must still be fully sanitized,
2069
+ * and a kill-decision on it must keep hitting the REPORT-3 throw.
2070
+ *
2071
+ * Nodes detached by hooks stay the hook's responsibility for placement:
2072
+ * they are not recorded in DOMPurify.removed, so the post-walk IN_PLACE
2073
+ * pass (which iterates DOMPurify.removed) does not reach them. But a
2074
+ * hook-detached subtree can still hold a queued resource-event handler -
2075
+ * e.g. an <img onload> that began loading when the caller built the live
2076
+ * tree - which fires in page scope after sanitize returns even though the
2077
+ * handler never reached the returned tree. That is the audit-5 F1 hazard,
2078
+ * and the documented node.remove() hook pattern walks straight into it.
2079
+ * So on the IN_PLACE path we neutralize the detached subtree inline,
2080
+ * stripping its non-allow-listed attributes before returning, exactly as
2081
+ * the post-walk pass does for _forceRemove'd subtrees.
2082
+ *
2083
+ * @param currentNode the node a hook may have detached
2084
+ * @param root the current walk root
2085
+ * @return true if the node is detached and now handled, false otherwise
2086
+ */
2087
+ const _handleHookDetachedNode = function (
2088
+ currentNode: Node,
2089
+ root: Node
2090
+ ): boolean {
2091
+ if (currentNode === root || getParentNode(currentNode) !== null) {
2092
+ return false;
2093
+ }
2094
+
2095
+ if (IN_PLACE) {
2096
+ _neutralizeSubtree(currentNode);
2097
+ }
2098
+
2099
+ return true;
2100
+ };
2101
+
1850
2102
  /**
1851
2103
  * _sanitizeElements
1852
2104
  *
@@ -1856,14 +2108,13 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1856
2108
  * @param currentNode to check for permission to exist
1857
2109
  * @return true if node was killed, false if left alive
1858
2110
  */
1859
- // eslint-disable-next-line complexity
1860
2111
  const _sanitizeElements = function (currentNode: any, root: Node): boolean {
1861
2112
  /* Execute a hook if present */
1862
2113
  _executeHooks(hooks.beforeSanitizeElements, currentNode, null);
1863
2114
 
1864
- /* A hook may have detached the node treat it as removed (see the
1865
- detached-node comment after the uponSanitizeElement hook below). */
1866
- if (currentNode !== root && getParentNode(currentNode) === null) {
2115
+ /* A hook may have detached the node - treat it as removed (see
2116
+ _handleHookDetachedNode for the full rationale). */
2117
+ if (_handleHookDetachedNode(currentNode, root)) {
1867
2118
  return true;
1868
2119
  }
1869
2120
 
@@ -1874,8 +2125,18 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1874
2125
  }
1875
2126
 
1876
2127
  /* Now let's check the element's type and name */
1877
- const tagName = transformCaseFunc(
1878
- getNodeName ? getNodeName(currentNode) : currentNode.nodeName
2128
+ const tagName = transformCaseFunc(_readNodeName(currentNode));
2129
+
2130
+ /* Close the pre-walk clone-guard's timing gap: an uponSanitizeElement
2131
+ hook may have been installed after that guard sampled the hook arrays
2132
+ (e.g. lazily from beforeSanitizeElements), leaving ALLOWED_TAGS still
2133
+ aliasing a shared binding that a widening hook would mutate by
2134
+ reference. Fork it before exposing it to the hook. */
2135
+ ALLOWED_TAGS = _forkSharedAllowlist(
2136
+ hooks.uponSanitizeElement,
2137
+ ALLOWED_TAGS,
2138
+ DEFAULT_ALLOWED_TAGS,
2139
+ SET_CONFIG_ALLOWED_TAGS
1879
2140
  );
1880
2141
 
1881
2142
  /* Execute a hook if present */
@@ -1884,22 +2145,9 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1884
2145
  allowedTags: ALLOWED_TAGS,
1885
2146
  });
1886
2147
 
1887
- /* A hook may have detached the node from the tree — a long-standing
1888
- user pattern (issue #469; draw.io-style foreignObject filtering).
1889
- Per the cached, unclobberable parentNode getter the node is
1890
- genuinely out of the tree, so it can reach neither the serialized
1891
- output nor an IN_PLACE live tree; treat it as removed and stop
1892
- processing it. Without this guard, the unsafe-node / namespace
1893
- checks below would call _forceRemove on a parentless node and hit
1894
- the REPORT-3 fail-closed throw — which exists for nodes DOMPurify
1895
- wants gone but *cannot* detach (clobbered / parentless roots), the
1896
- opposite of a node that is already safely gone. The walk root is
1897
- exempt: a detached IN_PLACE root is legitimate input and must still
1898
- be fully sanitized, and a kill-decision on it must keep hitting the
1899
- REPORT-3 throw. Nodes detached by hooks are the hook's
1900
- responsibility: they are not recorded in DOMPurify.removed and are
1901
- not neutralized by the post-walk IN_PLACE pass. */
1902
- if (currentNode !== root && getParentNode(currentNode) === null) {
2148
+ /* The uponSanitizeElement hook may have detached the node, exactly as
2149
+ above (see _handleHookDetachedNode for the full rationale). */
2150
+ if (_handleHookDetachedNode(currentNode, root)) {
1903
2151
  return true;
1904
2152
  }
1905
2153
 
@@ -1918,7 +2166,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1918
2166
  ) &&
1919
2167
  !ALLOWED_TAGS[tagName])
1920
2168
  ) {
1921
- const removed = _sanitizeDisallowedNode(currentNode, tagName);
2169
+ const removed = _sanitizeDisallowedNode(currentNode, tagName, root);
1922
2170
 
1923
2171
  /* A false return means the node is a custom element kept via
1924
2172
  CUSTOM_ELEMENT_HANDLING - the only keep path through
@@ -1944,7 +2192,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1944
2192
  bound and short-circuits to false for any node minted in a different
1945
2193
  realm — letting a foreign-realm element with a forbidden namespace
1946
2194
  slip past the namespace check entirely. */
1947
- const nt = getNodeType ? getNodeType(currentNode) : currentNode.nodeType;
2195
+ const nt = _readNodeType(currentNode);
1948
2196
  if (nt === NODE_TYPE.element && !_checkValidNamespace(currentNode)) {
1949
2197
  _forceRemove(currentNode);
1950
2198
  return true;
@@ -2020,16 +2268,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2020
2268
  other structural-threat checks and stays overridable, consistent with
2021
2269
  the rest of the codebase. PI range markers are already removed by
2022
2270
  _isUnsafeNode. */
2023
- if (SAFE_FOR_XML && lcName === 'patchsrc') {
2024
- return false;
2025
- }
2026
-
2027
- if (
2028
- SAFE_FOR_XML &&
2029
- lcName === 'for' &&
2030
- lcTag !== 'label' &&
2031
- lcTag !== 'output'
2032
- ) {
2271
+ if (_isPatchLinkageAttribute(lcName, lcTag)) {
2033
2272
  return false;
2034
2273
  }
2035
2274
 
@@ -2052,73 +2291,76 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2052
2291
  XML-compatible (https://html.spec.whatwg.org/multipage/infrastructure.html#xml-compatible and http://www.w3.org/TR/xml/#d0e804)
2053
2292
  We don't need to check the value; it's always URI safe. */
2054
2293
  if (ALLOW_DATA_ATTR && regExpTest(DATA_ATTR, lcName)) {
2055
- // This attribute is safe
2056
- } else if (ALLOW_ARIA_ATTR && regExpTest(ARIA_ATTR, lcName)) {
2057
- // This attribute is safe
2058
- /* Otherwise, check the name is permitted */
2059
- } else if (!nameIsPermitted) {
2060
- if (
2061
- // First condition does a very basic check if a) it's basically a valid custom element tagname AND
2062
- // b) if the tagName passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck
2063
- // and c) if the attribute name passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.attributeNameCheck
2294
+ return true;
2295
+ }
2296
+
2297
+ /* Allow valid aria-* attributes, the value is always URI safe */
2298
+ if (ALLOW_ARIA_ATTR && regExpTest(ARIA_ATTR, lcName)) {
2299
+ return true;
2300
+ }
2301
+
2302
+ /* A name outside the allowlist is acceptable on custom-element terms
2303
+ only. The value checks below are intentionally skipped in that case:
2304
+ if the user supplied a tagNameCheck we also allow derived custom
2305
+ elements using the same test, and attributes passing the configured
2306
+ attributeNameCheck are allowed as custom elements define these at
2307
+ their own discretion. */
2308
+ if (!nameIsPermitted) {
2309
+ return (
2310
+ // Condition a) covers a basically valid custom element tag name whose
2311
+ // tag passes the configured tagNameCheck and whose attribute name
2312
+ // passes the configured attributeNameCheck ...
2064
2313
  (_isBasicCustomElement(lcTag) &&
2065
- ((CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp &&
2066
- regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, lcTag)) ||
2067
- (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function &&
2068
- CUSTOM_ELEMENT_HANDLING.tagNameCheck(lcTag))) &&
2069
- ((CUSTOM_ELEMENT_HANDLING.attributeNameCheck instanceof RegExp &&
2070
- regExpTest(CUSTOM_ELEMENT_HANDLING.attributeNameCheck, lcName)) ||
2071
- (CUSTOM_ELEMENT_HANDLING.attributeNameCheck instanceof Function &&
2072
- CUSTOM_ELEMENT_HANDLING.attributeNameCheck(lcName, lcTag)))) ||
2073
- // Alternative, second condition checks if it's an `is`-attribute, AND
2074
- // the value passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck
2314
+ _matchesNameCheck(CUSTOM_ELEMENT_HANDLING.tagNameCheck, lcTag) &&
2315
+ _matchesNameCheck(
2316
+ CUSTOM_ELEMENT_HANDLING.attributeNameCheck,
2317
+ lcName,
2318
+ lcTag
2319
+ )) ||
2320
+ // Condition b) covers an `is` attribute whose value passes the
2321
+ // configured tagNameCheck while customized built-in elements are
2322
+ // allowed.
2075
2323
  (lcName === 'is' &&
2076
2324
  CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements &&
2077
- ((CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp &&
2078
- regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, value)) ||
2079
- (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function &&
2080
- CUSTOM_ELEMENT_HANDLING.tagNameCheck(value))))
2081
- ) {
2082
- // If user has supplied a regexp or function in CUSTOM_ELEMENT_HANDLING.tagNameCheck, we need to also allow derived custom elements using the same tagName test.
2083
- // Additionally, we need to allow attributes passing the CUSTOM_ELEMENT_HANDLING.attributeNameCheck user has configured, as custom elements can define these at their own discretion.
2084
- } else {
2085
- return false;
2086
- }
2087
- /* Check value is safe. First, is attr inert? If so, is safe */
2088
- } else if (URI_SAFE_ATTRIBUTES[lcName]) {
2089
- // This attribute is safe
2090
- /* Check no script, data or unknown possibly unsafe URI
2325
+ _matchesNameCheck(CUSTOM_ELEMENT_HANDLING.tagNameCheck, value))
2326
+ );
2327
+ }
2328
+
2329
+ /* Check value is safe. First, is attr inert? If so, is safe */
2330
+ if (URI_SAFE_ATTRIBUTES[lcName]) {
2331
+ return true;
2332
+ }
2333
+
2334
+ /* Check no script, data or unknown possibly unsafe URI
2091
2335
  unless we know URI values are safe for that attribute */
2092
- } else if (
2093
- regExpTest(IS_ALLOWED_URI, stringReplace(value, ATTR_WHITESPACE, ''))
2094
- ) {
2095
- // This attribute is safe
2096
- /* Keep image data URIs alive if src/xlink:href is allowed */
2097
- /* Further prevent gadget XSS for dynamically built script tags */
2098
- } else if (
2336
+ if (regExpTest(IS_ALLOWED_URI, stringReplace(value, ATTR_WHITESPACE, ''))) {
2337
+ return true;
2338
+ }
2339
+
2340
+ /* Keep image data URIs alive if src/xlink:href is allowed */
2341
+ /* Further prevent gadget XSS for dynamically built script tags */
2342
+ if (
2099
2343
  (lcName === 'src' || lcName === 'xlink:href' || lcName === 'href') &&
2100
2344
  lcTag !== 'script' &&
2101
2345
  stringIndexOf(value, 'data:') === 0 &&
2102
2346
  DATA_URI_TAGS[lcTag]
2103
2347
  ) {
2104
- // This attribute is safe
2105
- /* Allow unknown protocols: This provides support for links that
2348
+ return true;
2349
+ }
2350
+
2351
+ /* Allow unknown protocols: This provides support for links that
2106
2352
  are handled by protocol handlers which may be unknown ahead of
2107
2353
  time, e.g. fb:, spotify: */
2108
- } else if (
2354
+ if (
2109
2355
  ALLOW_UNKNOWN_PROTOCOLS &&
2110
2356
  !regExpTest(IS_SCRIPT_OR_DATA, stringReplace(value, ATTR_WHITESPACE, ''))
2111
2357
  ) {
2112
- // This attribute is safe
2113
- /* Check for binary attributes */
2114
- } else if (value) {
2115
- return false;
2116
- } else {
2117
- // Binary attributes are safe at this point
2118
- /* Anything else, presume unsafe, do not add it back */
2358
+ return true;
2119
2359
  }
2120
2360
 
2121
- return true;
2361
+ /* Only an empty (binary) value remains safe at this point;
2362
+ anything else is presumed unsafe, do not add it back */
2363
+ return !value;
2122
2364
  };
2123
2365
 
2124
2366
  /* Names the HTML spec reserves from valid-custom-element-name; these must
@@ -2250,6 +2492,15 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2250
2492
  return;
2251
2493
  }
2252
2494
 
2495
+ /* Same lazy-install guard as uponSanitizeElement (see there): fork the
2496
+ attribute allowlist off its shared binding before a hook can see it. */
2497
+ ALLOWED_ATTR = _forkSharedAllowlist(
2498
+ hooks.uponSanitizeAttribute,
2499
+ ALLOWED_ATTR,
2500
+ DEFAULT_ALLOWED_ATTR,
2501
+ SET_CONFIG_ALLOWED_ATTR
2502
+ );
2503
+
2253
2504
  const hookEvent = {
2254
2505
  attrName: '',
2255
2506
  attrValue: '',
@@ -2286,7 +2537,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2286
2537
  stringIndexOf(value, SANITIZE_NAMED_PROPS_PREFIX) !== 0
2287
2538
  ) {
2288
2539
  // Remove the attribute with this value
2289
- _removeAttribute(name, currentNode);
2540
+ _removeAttribute(name, currentNode, attr);
2290
2541
  // Prefix the value and later re-create the attribute with the sanitized value
2291
2542
  value = SANITIZE_NAMED_PROPS_PREFIX + value;
2292
2543
  }
@@ -2301,13 +2552,13 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2301
2552
  value
2302
2553
  )
2303
2554
  ) {
2304
- _removeAttribute(name, currentNode);
2555
+ _removeAttribute(name, currentNode, attr);
2305
2556
  continue;
2306
2557
  }
2307
2558
 
2308
2559
  /* Make sure we cannot easily use animated hrefs, even if animations are allowed */
2309
2560
  if (lcName === 'attributename' && stringMatch(value, 'href')) {
2310
- _removeAttribute(name, currentNode);
2561
+ _removeAttribute(name, currentNode, attr);
2311
2562
  continue;
2312
2563
  }
2313
2564
 
@@ -2318,7 +2569,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2318
2569
 
2319
2570
  /* Did the hooks approve of the attribute? */
2320
2571
  if (!hookEvent.keepAttr) {
2321
- _removeAttribute(name, currentNode);
2572
+ _removeAttribute(name, currentNode, attr);
2322
2573
  continue;
2323
2574
  }
2324
2575
 
@@ -2327,7 +2578,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2327
2578
  !ALLOW_SELF_CLOSE_IN_ATTR &&
2328
2579
  regExpTest(EXPRESSIONS.SELF_CLOSING_TAG, value)
2329
2580
  ) {
2330
- _removeAttribute(name, currentNode);
2581
+ _removeAttribute(name, currentNode, attr);
2331
2582
  continue;
2332
2583
  }
2333
2584
 
@@ -2338,7 +2589,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2338
2589
 
2339
2590
  /* Is `value` valid for this attribute? */
2340
2591
  if (!_isValidAttribute(lcTag, lcName, value)) {
2341
- _removeAttribute(name, currentNode);
2592
+ _removeAttribute(name, currentNode, attr);
2342
2593
  continue;
2343
2594
  }
2344
2595
 
@@ -2395,10 +2646,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2395
2646
  Walk it explicitly. The nodeType guard avoids reading
2396
2647
  shadowRoot off text / comment / CDATA / PI nodes that the
2397
2648
  iterator also surfaces. */
2398
- const shadowNodeType = getNodeType
2399
- ? getNodeType(shadowNode)
2400
- : shadowNode.nodeType;
2401
- if (shadowNodeType === NODE_TYPE.element) {
2649
+ if (_readNodeType(shadowNode) === NODE_TYPE.element) {
2402
2650
  const innerSr = getShadowRoot(shadowNode);
2403
2651
  if (_isDocumentFragment(innerSr)) {
2404
2652
  _sanitizeAttachedShadowRoots(innerSr);
@@ -2460,7 +2708,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2460
2708
  }
2461
2709
 
2462
2710
  const node = item.node;
2463
- const nodeType = getNodeType ? getNodeType(node) : (node as any).nodeType;
2711
+ const nodeType = _readNodeType(node);
2464
2712
  const isElement = nodeType === NODE_TYPE.element;
2465
2713
 
2466
2714
  /* (pushed last → processed first) Children, snapshotted in reverse so
@@ -2589,9 +2837,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2589
2837
  child named "nodeName" on the form root would otherwise shadow
2590
2838
  the property and let this check skip the root-allowlist
2591
2839
  validation entirely. */
2592
- const nn = getNodeName
2593
- ? getNodeName(dirty as Node)
2594
- : (dirty as Node).nodeName;
2840
+ const nn = _readNodeName(dirty as Node);
2595
2841
  if (typeof nn === 'string') {
2596
2842
  const tagName = transformCaseFunc(nn);
2597
2843
  if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) {
@@ -2692,18 +2938,21 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2692
2938
 
2693
2939
  /* Get node iterator */
2694
2940
  const walkRoot: Node = inPlace ? (dirty as Node) : body;
2695
- const nodeIterator = _createNodeIterator(walkRoot);
2696
2941
 
2697
2942
  /* Now start iterating over the created document.
2698
2943
  The walk runs inside an exception barrier (campaign-3 F2): a re-entrant
2699
2944
  engine/custom-element mutation can detach a node mid-walk so
2700
2945
  `_forceRemove`'s parentless guard throws, aborting the loop. Without the
2701
2946
  barrier the caller's in-place tree would be left half-sanitized with the
2702
- unvisited tail still armed. On any throw we fail closed — strip the
2703
- in-place root bare then rethrow so the existing throw contract is
2704
- preserved. (String/DOM-copy paths never return the partial body, so the
2705
- propagating throw is already fail-closed there.) */
2947
+ unvisited tail still armed. _createNodeIterator itself is inside the
2948
+ barrier too: constructing the iterator dereferences the root's document,
2949
+ and any failure there (e.g. an exotic/clobbered root) must still fail
2950
+ closed rather than skip the neutralize. On any throw we fail closed -
2951
+ strip the in-place root bare - then rethrow so the existing throw
2952
+ contract is preserved. (String/DOM-copy paths never return the partial
2953
+ body, so the propagating throw is already fail-closed there.) */
2706
2954
  try {
2955
+ const nodeIterator = _createNodeIterator(walkRoot);
2707
2956
  while ((currentNode = nodeIterator.nextNode())) {
2708
2957
  /* Sanitize tags and elements */
2709
2958
  _sanitizeElements(currentNode, walkRoot);