dompurify 3.4.13 → 3.4.15

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
 
@@ -204,6 +268,15 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
204
268
 
205
269
  const cloneNode = lookupGetter(ElementPrototype, 'cloneNode');
206
270
  const remove = lookupGetter(ElementPrototype, 'remove');
271
+ // Clobber-safe Attr-node removal. On an HTMLFormElement a descendant named
272
+ // "removeAttributeNode" shadows the prototype method via
273
+ // [LegacyOverrideBuiltIns], so element.removeAttributeNode(attr) throws.
274
+ // Calling the cached Element.prototype method with the element as the
275
+ // receiver removes the exact live Attr node regardless of the shadowing.
276
+ const removeAttributeNode = lookupGetter(
277
+ ElementPrototype,
278
+ 'removeAttributeNode'
279
+ );
207
280
  const getNextSibling = lookupGetter(ElementPrototype, 'nextSibling');
208
281
  const getChildNodes = lookupGetter(ElementPrototype, 'childNodes');
209
282
  const getParentNode = lookupGetter(ElementPrototype, 'parentNode');
@@ -218,6 +291,18 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
218
291
  ? lookupGetter(Node.prototype, 'ownerDocument')
219
292
  : null;
220
293
 
294
+ /* Clobber-safe nodeType / nodeName reads through the cached Node.prototype
295
+ getters, with a direct-property fallback for environments that lack
296
+ Node.prototype. Sites that need a different fallback (e.g. _isClobbered
297
+ returns early on a null name) intentionally keep their own reads. */
298
+ const _readNodeType = function (node: Node): number {
299
+ return getNodeType ? getNodeType(node) : (node as any).nodeType;
300
+ };
301
+
302
+ const _readNodeName = function (node: Node): string {
303
+ return getNodeName ? getNodeName(node) : (node as any).nodeName;
304
+ };
305
+
221
306
  // As per issue #47, the web-components registry is inherited by a
222
307
  // new document created via createHTMLDocument. As per the spec
223
308
  // (http://w3c.github.io/webcomponents/spec/custom/#creating-and-passing-registries)
@@ -728,26 +813,23 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
728
813
  NAMESPACE =
729
814
  typeof cfg.NAMESPACE === 'string' ? cfg.NAMESPACE : HTML_NAMESPACE; // Default HTML namespace
730
815
 
731
- MATHML_TEXT_INTEGRATION_POINTS =
732
- objectHasOwnProperty(cfg, 'MATHML_TEXT_INTEGRATION_POINTS') &&
733
- cfg.MATHML_TEXT_INTEGRATION_POINTS &&
734
- typeof cfg.MATHML_TEXT_INTEGRATION_POINTS === 'object'
735
- ? clone(cfg.MATHML_TEXT_INTEGRATION_POINTS)
736
- : addToSet({}, DEFAULT_MATHML_TEXT_INTEGRATION_POINTS); // Default built-in map
737
-
738
- HTML_INTEGRATION_POINTS =
739
- objectHasOwnProperty(cfg, 'HTML_INTEGRATION_POINTS') &&
740
- cfg.HTML_INTEGRATION_POINTS &&
741
- typeof cfg.HTML_INTEGRATION_POINTS === 'object'
742
- ? clone(cfg.HTML_INTEGRATION_POINTS)
743
- : addToSet({}, DEFAULT_HTML_INTEGRATION_POINTS); // Default built-in map
744
-
745
- const customElementHandling =
746
- objectHasOwnProperty(cfg, 'CUSTOM_ELEMENT_HANDLING') &&
747
- cfg.CUSTOM_ELEMENT_HANDLING &&
748
- typeof cfg.CUSTOM_ELEMENT_HANDLING === 'object'
749
- ? clone(cfg.CUSTOM_ELEMENT_HANDLING)
750
- : create(null);
816
+ MATHML_TEXT_INTEGRATION_POINTS = _resolveObjectOption(
817
+ cfg,
818
+ 'MATHML_TEXT_INTEGRATION_POINTS',
819
+ () => addToSet({}, DEFAULT_MATHML_TEXT_INTEGRATION_POINTS) // Default built-in map
820
+ );
821
+
822
+ HTML_INTEGRATION_POINTS = _resolveObjectOption(
823
+ cfg,
824
+ 'HTML_INTEGRATION_POINTS',
825
+ () => addToSet({}, DEFAULT_HTML_INTEGRATION_POINTS) // Default built-in map
826
+ );
827
+
828
+ const customElementHandling = _resolveObjectOption(
829
+ cfg,
830
+ 'CUSTOM_ELEMENT_HANDLING',
831
+ () => create(null)
832
+ );
751
833
 
752
834
  CUSTOM_ELEMENT_HANDLING = create(null);
753
835
 
@@ -845,24 +927,6 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
845
927
  }
846
928
  }
847
929
 
848
- if (
849
- objectHasOwnProperty(cfg, 'ADD_URI_SAFE_ATTR') &&
850
- arrayIsArray(cfg.ADD_URI_SAFE_ATTR)
851
- ) {
852
- addToSet(URI_SAFE_ATTRIBUTES, cfg.ADD_URI_SAFE_ATTR, transformCaseFunc);
853
- }
854
-
855
- if (
856
- objectHasOwnProperty(cfg, 'FORBID_CONTENTS') &&
857
- arrayIsArray(cfg.FORBID_CONTENTS)
858
- ) {
859
- if (FORBID_CONTENTS === DEFAULT_FORBID_CONTENTS) {
860
- FORBID_CONTENTS = clone(FORBID_CONTENTS);
861
- }
862
-
863
- addToSet(FORBID_CONTENTS, cfg.FORBID_CONTENTS, transformCaseFunc);
864
- }
865
-
866
930
  if (
867
931
  objectHasOwnProperty(cfg, 'ADD_FORBID_CONTENTS') &&
868
932
  arrayIsArray(cfg.ADD_FORBID_CONTENTS)
@@ -1175,6 +1239,37 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1175
1239
  }
1176
1240
  };
1177
1241
 
1242
+ /**
1243
+ * _stripAttributeNode
1244
+ *
1245
+ * Remove a single Attr node case/namespace-exactly on an attribute-teardown
1246
+ * path. Name-based removeAttribute() ASCII-lowercases its lookup key for an
1247
+ * HTML element in an HTML document and so silently misses a case-preserved
1248
+ * handler (e.g. `ONERROR` off an XML/XHTML import) - the same defect
1249
+ * _removeAttribute() was fixed for, which a name-based call would reintroduce
1250
+ * on these IN_PLACE teardown paths. Unlike _removeAttribute this does not
1251
+ * record into DOMPurify.removed: the neutralize passes intentionally do not
1252
+ * book-keep. A clobbered/detached node falls back to best-effort name-based
1253
+ * removal.
1254
+ *
1255
+ * @param element the element to strip the attribute from
1256
+ * @param attribute the Attr node to remove
1257
+ * @param name the attribute's name, for the fallback path
1258
+ */
1259
+ const _stripAttributeNode = function (
1260
+ element: Element,
1261
+ attribute: Attr,
1262
+ name: string
1263
+ ): void {
1264
+ try {
1265
+ removeAttributeNode(element, attribute);
1266
+ } catch (_) {
1267
+ try {
1268
+ element.removeAttribute(name);
1269
+ } catch (_) {}
1270
+ }
1271
+ };
1272
+
1178
1273
  /**
1179
1274
  * _neutralizeRoot
1180
1275
  *
@@ -1221,11 +1316,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1221
1316
  const attribute = attributes[i];
1222
1317
  const name = attribute && attribute.name;
1223
1318
  if (typeof name === 'string') {
1224
- try {
1225
- (root as Element).removeAttribute(name);
1226
- } catch (_) {
1227
- /* Clobbered removeAttribute — ignore (fail-closed best effort) */
1228
- }
1319
+ _stripAttributeNode(root as Element, attribute, name);
1229
1320
  }
1230
1321
  }
1231
1322
  }
@@ -1234,24 +1325,58 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1234
1325
  /**
1235
1326
  * _removeAttribute
1236
1327
  *
1328
+ * Name-based getAttributeNode()/removeAttribute() ASCII-lowercase their
1329
+ * lookup key for HTML elements in an HTML document, so they silently miss an
1330
+ * attribute whose stored qualified name still contains uppercase ASCII
1331
+ * letters. That happens when the node came from a case-preserving source
1332
+ * (an XML/XHTML document imported via importNode(), or createAttributeNS()),
1333
+ * where e.g. `ONERROR` survives the walk: the policy check lowercases to
1334
+ * `onerror` and rejects it, but `removeAttribute('ONERROR')` looks up
1335
+ * `onerror` and finds nothing. Remove the exact Attr node instead, which is
1336
+ * case- and namespace-exact, and fall back to name-based removal only when
1337
+ * the caller could not supply the node.
1338
+ *
1237
1339
  * @param name an Attribute name
1238
1340
  * @param element a DOM node
1341
+ * @param attr the exact Attr node to remove, when the caller has it
1239
1342
  */
1240
- const _removeAttribute = function (name: string, element: Element): void {
1343
+ const _removeAttribute = function (
1344
+ name: string,
1345
+ element: Element,
1346
+ attr?: Attr | null
1347
+ ): void {
1348
+ if (!attr) {
1349
+ try {
1350
+ attr = element.getAttributeNode(name);
1351
+ } catch (_) {
1352
+ attr = null;
1353
+ }
1354
+ }
1355
+
1356
+ arrayPush(DOMPurify.removed, {
1357
+ attribute: attr || null,
1358
+ from: element,
1359
+ });
1360
+
1241
1361
  try {
1242
- arrayPush(DOMPurify.removed, {
1243
- attribute: element.getAttributeNode(name),
1244
- from: element,
1245
- });
1362
+ if (attr) {
1363
+ removeAttributeNode(element, attr);
1364
+ } else {
1365
+ element.removeAttribute(name);
1366
+ }
1246
1367
  } catch (_) {
1247
- arrayPush(DOMPurify.removed, {
1248
- attribute: null,
1249
- from: element,
1250
- });
1368
+ /* Clobbered or already-detached node - best-effort fall back to a
1369
+ name-based removal so the "is" handling below still runs. Note this
1370
+ fallback ASCII-lowercases its lookup key in an HTML document and so
1371
+ cannot reach a case-preserved attribute name; the cached
1372
+ removeAttributeNode() above is what removes those, and a clobbered
1373
+ form is already removed wholesale by _isClobbered() before reaching
1374
+ here. */
1375
+ try {
1376
+ element.removeAttribute(name);
1377
+ } catch (_) {}
1251
1378
  }
1252
1379
 
1253
- element.removeAttribute(name);
1254
-
1255
1380
  // We void attribute values for unremovable "is" attributes
1256
1381
  if (name === 'is') {
1257
1382
  if (RETURN_DOM || RETURN_DOM_FRAGMENT) {
@@ -1289,11 +1414,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1289
1414
  continue;
1290
1415
  }
1291
1416
 
1292
- try {
1293
- element.removeAttribute(name);
1294
- } catch (_) {
1295
- /* Clobbered removeAttribute on a doomed node — ignore */
1296
- }
1417
+ _stripAttributeNode(element, attribute, name);
1297
1418
  }
1298
1419
  };
1299
1420
 
@@ -1324,7 +1445,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1324
1445
 
1325
1446
  while (stack.length > 0) {
1326
1447
  const node = stack.pop();
1327
- const nodeType = getNodeType ? getNodeType(node) : (node as any).nodeType;
1448
+ const nodeType = _readNodeType(node);
1328
1449
 
1329
1450
  if (nodeType === NODE_TYPE.element) {
1330
1451
  _stripDisallowedAttributes(node as Element);
@@ -1374,6 +1495,32 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1374
1495
  *
1375
1496
  * @param root the in-place root to sweep
1376
1497
  */
1498
+ /**
1499
+ * Central policy for declarative-partial-updates patch-linkage attributes,
1500
+ * shared by the _neutralizePatchLinkage pre-pass and _isValidAttribute so
1501
+ * the two sites cannot drift: `patchsrc` always links, `for` links
1502
+ * everywhere except on <label>/<output>, and the whole policy is gated on
1503
+ * SAFE_FOR_XML (see the rationale block in _isValidAttribute).
1504
+ *
1505
+ * @param lcName the transformCaseFunc'd attribute name
1506
+ * @param lcTag the transformCaseFunc'd tag name of the carrying element
1507
+ * @return true if the attribute is patch linkage and must be dropped
1508
+ */
1509
+ const _isPatchLinkageAttribute = function (
1510
+ lcName: string,
1511
+ lcTag: string
1512
+ ): boolean {
1513
+ if (!SAFE_FOR_XML) {
1514
+ return false;
1515
+ }
1516
+
1517
+ if (lcName === 'patchsrc') {
1518
+ return true;
1519
+ }
1520
+
1521
+ return lcName === 'for' && lcTag !== 'label' && lcTag !== 'output';
1522
+ };
1523
+
1377
1524
  const _neutralizePatchLinkage = function (root: Node): void {
1378
1525
  if (!SAFE_FOR_XML) {
1379
1526
  return;
@@ -1382,7 +1529,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1382
1529
  const stack: Node[] = [root];
1383
1530
  while (stack.length > 0) {
1384
1531
  const node = stack.pop();
1385
- const nodeType = getNodeType ? getNodeType(node) : (node as any).nodeType;
1532
+ const nodeType = _readNodeType(node);
1386
1533
 
1387
1534
  /* Remove range markers (the target side of a patch linkage): every
1388
1535
  processing instruction, and any markup-bearing comment. */
@@ -1403,9 +1550,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1403
1550
  /* Strip patch-source attributes (the source side) off elements. */
1404
1551
  if (nodeType === NODE_TYPE.element) {
1405
1552
  const element = node as Element;
1406
- const lcTag = transformCaseFunc(
1407
- getNodeName ? getNodeName(node) : (node as any).nodeName
1408
- );
1553
+ const lcTag = transformCaseFunc(_readNodeName(node));
1409
1554
  try {
1410
1555
  if (element.hasAttribute && element.hasAttribute('patchsrc')) {
1411
1556
  element.removeAttribute('patchsrc');
@@ -1414,8 +1559,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1414
1559
  if (
1415
1560
  element.hasAttribute &&
1416
1561
  element.hasAttribute('for') &&
1417
- lcTag !== 'label' &&
1418
- lcTag !== 'output'
1562
+ _isPatchLinkageAttribute('for', lcTag)
1419
1563
  ) {
1420
1564
  element.removeAttribute('for');
1421
1565
  }
@@ -1642,6 +1786,17 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1642
1786
  // canonical NamedNodeMap.
1643
1787
  element.attributes !== getAttributes(element) ||
1644
1788
  typeof element.removeAttribute !== 'function' ||
1789
+ // A form descendant named "removeAttributeNode" or "getAttributeNode"
1790
+ // shadows these Attr-node methods via [LegacyOverrideBuiltIns].
1791
+ // _removeAttribute() / _stripAttributeNode() reach for
1792
+ // element.removeAttributeNode(attr) first; when it is shadowed the call
1793
+ // throws and the name-based fallback element.removeAttribute(name)
1794
+ // ASCII-lowercases its lookup key in an HTML document, silently missing
1795
+ // a case-preserved event-handler attribute (e.g. an ONANIMATIONSTART
1796
+ // that reached the sanitizer through an XML/XHTML parse). Flag the form
1797
+ // so it is removed wholesale, exactly as for the other shadowed methods.
1798
+ typeof element.removeAttributeNode !== 'function' ||
1799
+ typeof element.getAttributeNode !== 'function' ||
1645
1800
  typeof element.setAttribute !== 'function' ||
1646
1801
  typeof element.namespaceURI !== 'string' ||
1647
1802
  typeof element.insertBefore !== 'function' ||
@@ -1751,12 +1906,22 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1751
1906
  return true;
1752
1907
  }
1753
1908
 
1754
- /* Remove risky CSS construction leading to mXSS */
1909
+ /* Remove rawtext/literal-text elements whose literal serialization
1910
+ re-opens markup on an HTML reparse - shapes (a) and (b) documented at
1911
+ LITERAL_TEXT_ELEMENTS. Both are invisible to rule 1 above (which
1912
+ self-disables once there is an element child, and whose second probe
1913
+ reads the innerHTML an XML/XHTML document serializes escaped), which
1914
+ is why both probes here read textContent instead. Previously only
1915
+ `style`-with-element-child was covered; every element in
1916
+ LITERAL_TEXT_ELEMENTS shares this literal serialization and is
1917
+ equally affected. */
1755
1918
  if (
1756
1919
  SAFE_FOR_XML &&
1757
1920
  currentNode.namespaceURI === HTML_NAMESPACE &&
1758
- tagName === 'style' &&
1759
- _isNode(currentNode.firstElementChild)
1921
+ LITERAL_TEXT_ELEMENTS[tagName] &&
1922
+ (_isNode(currentNode.firstElementChild) ||
1923
+ (typeof currentNode.textContent === 'string' &&
1924
+ regExpTest(LITERAL_TEXT_CLOSE[tagName], currentNode.textContent)))
1760
1925
  ) {
1761
1926
  return true;
1762
1927
  }
@@ -1778,6 +1943,34 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1778
1943
  return false;
1779
1944
  };
1780
1945
 
1946
+ /**
1947
+ * Evaluate a CUSTOM_ELEMENT_HANDLING check (a RegExp or a predicate
1948
+ * function, per the validation in _parseConfig) against a name.
1949
+ * Additional arguments are forwarded to predicate functions - the
1950
+ * attributeNameCheck predicate receives the tag name as its second
1951
+ * argument. A null/absent check never matches.
1952
+ *
1953
+ * @param check the configured tagNameCheck / attributeNameCheck value
1954
+ * @param name the name to test
1955
+ * @param args extra arguments forwarded to a predicate function
1956
+ * @return true if the check matches the name
1957
+ */
1958
+ const _matchesNameCheck = function (
1959
+ check: unknown,
1960
+ name: string,
1961
+ ...args: unknown[]
1962
+ ): boolean {
1963
+ if (check instanceof RegExp) {
1964
+ return regExpTest(check, name);
1965
+ }
1966
+
1967
+ if (check instanceof Function) {
1968
+ return Boolean(check(name, ...args));
1969
+ }
1970
+
1971
+ return false;
1972
+ };
1973
+
1781
1974
  /**
1782
1975
  * Handle a node whose tag is forbidden or not allowlisted: keep
1783
1976
  * allowed custom elements (false return exits _sanitizeElements
@@ -1801,20 +1994,12 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1801
1994
  root: Node
1802
1995
  ): boolean {
1803
1996
  /* Check if we have a custom element to handle */
1804
- if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName)) {
1805
- if (
1806
- CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp &&
1807
- regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)
1808
- ) {
1809
- return false;
1810
- }
1811
-
1812
- if (
1813
- CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function &&
1814
- CUSTOM_ELEMENT_HANDLING.tagNameCheck(tagName)
1815
- ) {
1816
- return false;
1817
- }
1997
+ if (
1998
+ !FORBID_TAGS[tagName] &&
1999
+ _isBasicCustomElement(tagName) &&
2000
+ _matchesNameCheck(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)
2001
+ ) {
2002
+ return false;
1818
2003
  }
1819
2004
 
1820
2005
  /* Keep content except for bad-listed elements.
@@ -1893,6 +2078,52 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1893
2078
  return set === defaultSet || set === setConfigSet ? clone(set) : set;
1894
2079
  };
1895
2080
 
2081
+ /**
2082
+ * Shared guard for a node that a hook has detached from the walk tree,
2083
+ * used after each element-hook site in _sanitizeElements. Detaching is a
2084
+ * long-standing user pattern (issue #469; draw.io-style foreignObject
2085
+ * filtering). Per the cached, unclobberable parentNode getter the node is
2086
+ * genuinely out of the tree, so it can reach neither the serialized
2087
+ * output nor an IN_PLACE live tree; treat it as removed and stop
2088
+ * processing it. Without this guard, the unsafe-node / namespace checks
2089
+ * would call _forceRemove on a parentless node and hit the REPORT-3
2090
+ * fail-closed throw — which exists for nodes DOMPurify wants gone but
2091
+ * *cannot* detach (clobbered / parentless roots), the opposite of a node
2092
+ * that is already safely gone. The walk root is exempt: a detached
2093
+ * IN_PLACE root is legitimate input and must still be fully sanitized,
2094
+ * and a kill-decision on it must keep hitting the REPORT-3 throw.
2095
+ *
2096
+ * Nodes detached by hooks stay the hook's responsibility for placement:
2097
+ * they are not recorded in DOMPurify.removed, so the post-walk IN_PLACE
2098
+ * pass (which iterates DOMPurify.removed) does not reach them. But a
2099
+ * hook-detached subtree can still hold a queued resource-event handler -
2100
+ * e.g. an <img onload> that began loading when the caller built the live
2101
+ * tree - which fires in page scope after sanitize returns even though the
2102
+ * handler never reached the returned tree. That is the audit-5 F1 hazard,
2103
+ * and the documented node.remove() hook pattern walks straight into it.
2104
+ * So on the IN_PLACE path we neutralize the detached subtree inline,
2105
+ * stripping its non-allow-listed attributes before returning, exactly as
2106
+ * the post-walk pass does for _forceRemove'd subtrees.
2107
+ *
2108
+ * @param currentNode the node a hook may have detached
2109
+ * @param root the current walk root
2110
+ * @return true if the node is detached and now handled, false otherwise
2111
+ */
2112
+ const _handleHookDetachedNode = function (
2113
+ currentNode: Node,
2114
+ root: Node
2115
+ ): boolean {
2116
+ if (currentNode === root || getParentNode(currentNode) !== null) {
2117
+ return false;
2118
+ }
2119
+
2120
+ if (IN_PLACE) {
2121
+ _neutralizeSubtree(currentNode);
2122
+ }
2123
+
2124
+ return true;
2125
+ };
2126
+
1896
2127
  /**
1897
2128
  * _sanitizeElements
1898
2129
  *
@@ -1902,20 +2133,13 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1902
2133
  * @param currentNode to check for permission to exist
1903
2134
  * @return true if node was killed, false if left alive
1904
2135
  */
1905
- // eslint-disable-next-line complexity
1906
2136
  const _sanitizeElements = function (currentNode: any, root: Node): boolean {
1907
2137
  /* Execute a hook if present */
1908
2138
  _executeHooks(hooks.beforeSanitizeElements, currentNode, null);
1909
2139
 
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
-
2140
+ /* A hook may have detached the node - treat it as removed (see
2141
+ _handleHookDetachedNode for the full rationale). */
2142
+ if (_handleHookDetachedNode(currentNode, root)) {
1919
2143
  return true;
1920
2144
  }
1921
2145
 
@@ -1926,9 +2150,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1926
2150
  }
1927
2151
 
1928
2152
  /* Now let's check the element's type and name */
1929
- const tagName = transformCaseFunc(
1930
- getNodeName ? getNodeName(currentNode) : currentNode.nodeName
1931
- );
2153
+ const tagName = transformCaseFunc(_readNodeName(currentNode));
1932
2154
 
1933
2155
  /* Close the pre-walk clone-guard's timing gap: an uponSanitizeElement
1934
2156
  hook may have been installed after that guard sampled the hook arrays
@@ -1948,35 +2170,9 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1948
2170
  allowedTags: ALLOWED_TAGS,
1949
2171
  });
1950
2172
 
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
-
2173
+ /* The uponSanitizeElement hook may have detached the node, exactly as
2174
+ above (see _handleHookDetachedNode for the full rationale). */
2175
+ if (_handleHookDetachedNode(currentNode, root)) {
1980
2176
  return true;
1981
2177
  }
1982
2178
 
@@ -2021,7 +2217,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2021
2217
  bound and short-circuits to false for any node minted in a different
2022
2218
  realm — letting a foreign-realm element with a forbidden namespace
2023
2219
  slip past the namespace check entirely. */
2024
- const nt = getNodeType ? getNodeType(currentNode) : currentNode.nodeType;
2220
+ const nt = _readNodeType(currentNode);
2025
2221
  if (nt === NODE_TYPE.element && !_checkValidNamespace(currentNode)) {
2026
2222
  _forceRemove(currentNode);
2027
2223
  return true;
@@ -2097,16 +2293,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2097
2293
  other structural-threat checks and stays overridable, consistent with
2098
2294
  the rest of the codebase. PI range markers are already removed by
2099
2295
  _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
- ) {
2296
+ if (_isPatchLinkageAttribute(lcName, lcTag)) {
2110
2297
  return false;
2111
2298
  }
2112
2299
 
@@ -2129,73 +2316,76 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2129
2316
  XML-compatible (https://html.spec.whatwg.org/multipage/infrastructure.html#xml-compatible and http://www.w3.org/TR/xml/#d0e804)
2130
2317
  We don't need to check the value; it's always URI safe. */
2131
2318
  if (ALLOW_DATA_ATTR && regExpTest(DATA_ATTR, lcName)) {
2132
- // This attribute is safe
2133
- } else if (ALLOW_ARIA_ATTR && regExpTest(ARIA_ATTR, lcName)) {
2134
- // This attribute is safe
2135
- /* Otherwise, check the name is permitted */
2136
- } else if (!nameIsPermitted) {
2137
- if (
2138
- // First condition does a very basic check if a) it's basically a valid custom element tagname AND
2139
- // b) if the tagName passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck
2140
- // and c) if the attribute name passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.attributeNameCheck
2319
+ return true;
2320
+ }
2321
+
2322
+ /* Allow valid aria-* attributes, the value is always URI safe */
2323
+ if (ALLOW_ARIA_ATTR && regExpTest(ARIA_ATTR, lcName)) {
2324
+ return true;
2325
+ }
2326
+
2327
+ /* A name outside the allowlist is acceptable on custom-element terms
2328
+ only. The value checks below are intentionally skipped in that case:
2329
+ if the user supplied a tagNameCheck we also allow derived custom
2330
+ elements using the same test, and attributes passing the configured
2331
+ attributeNameCheck are allowed as custom elements define these at
2332
+ their own discretion. */
2333
+ if (!nameIsPermitted) {
2334
+ return (
2335
+ // Condition a) covers a basically valid custom element tag name whose
2336
+ // tag passes the configured tagNameCheck and whose attribute name
2337
+ // passes the configured attributeNameCheck ...
2141
2338
  (_isBasicCustomElement(lcTag) &&
2142
- ((CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp &&
2143
- regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, lcTag)) ||
2144
- (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function &&
2145
- CUSTOM_ELEMENT_HANDLING.tagNameCheck(lcTag))) &&
2146
- ((CUSTOM_ELEMENT_HANDLING.attributeNameCheck instanceof RegExp &&
2147
- regExpTest(CUSTOM_ELEMENT_HANDLING.attributeNameCheck, lcName)) ||
2148
- (CUSTOM_ELEMENT_HANDLING.attributeNameCheck instanceof Function &&
2149
- CUSTOM_ELEMENT_HANDLING.attributeNameCheck(lcName, lcTag)))) ||
2150
- // Alternative, second condition checks if it's an `is`-attribute, AND
2151
- // the value passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck
2339
+ _matchesNameCheck(CUSTOM_ELEMENT_HANDLING.tagNameCheck, lcTag) &&
2340
+ _matchesNameCheck(
2341
+ CUSTOM_ELEMENT_HANDLING.attributeNameCheck,
2342
+ lcName,
2343
+ lcTag
2344
+ )) ||
2345
+ // Condition b) covers an `is` attribute whose value passes the
2346
+ // configured tagNameCheck while customized built-in elements are
2347
+ // allowed.
2152
2348
  (lcName === 'is' &&
2153
2349
  CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements &&
2154
- ((CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp &&
2155
- regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, value)) ||
2156
- (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function &&
2157
- CUSTOM_ELEMENT_HANDLING.tagNameCheck(value))))
2158
- ) {
2159
- // 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.
2160
- // 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.
2161
- } else {
2162
- return false;
2163
- }
2164
- /* Check value is safe. First, is attr inert? If so, is safe */
2165
- } else if (URI_SAFE_ATTRIBUTES[lcName]) {
2166
- // This attribute is safe
2167
- /* Check no script, data or unknown possibly unsafe URI
2350
+ _matchesNameCheck(CUSTOM_ELEMENT_HANDLING.tagNameCheck, value))
2351
+ );
2352
+ }
2353
+
2354
+ /* Check value is safe. First, is attr inert? If so, is safe */
2355
+ if (URI_SAFE_ATTRIBUTES[lcName]) {
2356
+ return true;
2357
+ }
2358
+
2359
+ /* Check no script, data or unknown possibly unsafe URI
2168
2360
  unless we know URI values are safe for that attribute */
2169
- } else if (
2170
- regExpTest(IS_ALLOWED_URI, stringReplace(value, ATTR_WHITESPACE, ''))
2171
- ) {
2172
- // This attribute is safe
2173
- /* Keep image data URIs alive if src/xlink:href is allowed */
2174
- /* Further prevent gadget XSS for dynamically built script tags */
2175
- } else if (
2361
+ if (regExpTest(IS_ALLOWED_URI, stringReplace(value, ATTR_WHITESPACE, ''))) {
2362
+ return true;
2363
+ }
2364
+
2365
+ /* Keep image data URIs alive if src/xlink:href is allowed */
2366
+ /* Further prevent gadget XSS for dynamically built script tags */
2367
+ if (
2176
2368
  (lcName === 'src' || lcName === 'xlink:href' || lcName === 'href') &&
2177
2369
  lcTag !== 'script' &&
2178
2370
  stringIndexOf(value, 'data:') === 0 &&
2179
2371
  DATA_URI_TAGS[lcTag]
2180
2372
  ) {
2181
- // This attribute is safe
2182
- /* Allow unknown protocols: This provides support for links that
2373
+ return true;
2374
+ }
2375
+
2376
+ /* Allow unknown protocols: This provides support for links that
2183
2377
  are handled by protocol handlers which may be unknown ahead of
2184
2378
  time, e.g. fb:, spotify: */
2185
- } else if (
2379
+ if (
2186
2380
  ALLOW_UNKNOWN_PROTOCOLS &&
2187
2381
  !regExpTest(IS_SCRIPT_OR_DATA, stringReplace(value, ATTR_WHITESPACE, ''))
2188
2382
  ) {
2189
- // This attribute is safe
2190
- /* Check for binary attributes */
2191
- } else if (value) {
2192
- return false;
2193
- } else {
2194
- // Binary attributes are safe at this point
2195
- /* Anything else, presume unsafe, do not add it back */
2383
+ return true;
2196
2384
  }
2197
2385
 
2198
- return true;
2386
+ /* Only an empty (binary) value remains safe at this point;
2387
+ anything else is presumed unsafe, do not add it back */
2388
+ return !value;
2199
2389
  };
2200
2390
 
2201
2391
  /* Names the HTML spec reserves from valid-custom-element-name; these must
@@ -2272,22 +2462,32 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2272
2462
  /**
2273
2463
  * Write a modified attribute value back onto the element. On
2274
2464
  * success, re-probe for clobbering introduced by the new value and
2275
- * remove the element when found; otherwise pop the removal entry
2276
- * recorded by the earlier _removeAttribute (long-standing pairing
2277
- * with the SANITIZE_NAMED_PROPS path - do not "fix" casually). On
2465
+ * remove the element when found; otherwise, when this writeback is the
2466
+ * recreate half of the SANITIZE_NAMED_PROPS remove-and-recreate, pop the
2467
+ * removal entry that path recorded so it does not show as removed. On
2278
2468
  * failure, remove the attribute instead.
2279
2469
  *
2470
+ * Returns true only on a clean write (the value was set and the new value
2471
+ * introduced no clobbering). The caller uses that, together with its own
2472
+ * knowledge of whether this attribute pushed a DOMPurify.removed record, to
2473
+ * decide whether to pop that record. The pop must happen ONLY for the
2474
+ * named-prop remove-and-recreate; popping on any other value change (trim,
2475
+ * template scrubbing, Trusted Types) would consume an unrelated _forceRemove
2476
+ * subtree-cleanup record and let that detached subtree keep a live event
2477
+ * handler through the IN_PLACE neutralization pass (SO-001).
2478
+ *
2280
2479
  * @param currentNode the element carrying the attribute
2281
2480
  * @param name the attribute name as present on the element
2282
2481
  * @param namespaceURI the attribute's namespace, if any
2283
2482
  * @param value the new attribute value
2483
+ * @return true if the value was written without introducing clobbering
2284
2484
  */
2285
2485
  const _setAttributeValue = function (
2286
2486
  currentNode: Element,
2287
2487
  name: string,
2288
2488
  namespaceURI: string | null,
2289
2489
  value: string
2290
- ): void {
2490
+ ): boolean {
2291
2491
  try {
2292
2492
  if (namespaceURI) {
2293
2493
  currentNode.setAttributeNS(namespaceURI, name, value);
@@ -2298,11 +2498,13 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2298
2498
 
2299
2499
  if (_isClobbered(currentNode)) {
2300
2500
  _forceRemove(currentNode);
2301
- } else {
2302
- arrayPop(DOMPurify.removed);
2501
+ return false;
2303
2502
  }
2503
+
2504
+ return true;
2304
2505
  } catch (_) {
2305
2506
  _removeAttribute(name, currentNode);
2507
+ return false;
2306
2508
  }
2307
2509
  };
2308
2510
 
@@ -2316,6 +2518,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2316
2518
  *
2317
2519
  * @param currentNode to sanitize
2318
2520
  */
2521
+ // eslint-disable-next-line complexity
2319
2522
  const _sanitizeAttributes = function (currentNode: Element): void {
2320
2523
  /* Execute a hook if present */
2321
2524
  _executeHooks(hooks.beforeSanitizeAttributes, currentNode, null);
@@ -2355,6 +2558,16 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2355
2558
  const initValue = attrValue;
2356
2559
  let value = name === 'value' ? initValue : stringTrim(initValue);
2357
2560
 
2561
+ /* Tracks whether this specific attribute pushed a DOMPurify.removed
2562
+ record that a later _setAttributeValue() writeback is expected to pop
2563
+ back off (the SANITIZE_NAMED_PROPS remove-and-recreate below is the
2564
+ only path that does). A value that merely changed via trim, template
2565
+ scrubbing, or Trusted Types pushes no such record, so its writeback
2566
+ must NOT pop — otherwise it consumes an unrelated _forceRemove subtree
2567
+ record and that detached subtree escapes the IN_PLACE handler-
2568
+ neutralization pass (SO-001). */
2569
+ let recreatedNamedProp = false;
2570
+
2358
2571
  /* Execute a hook if present */
2359
2572
  hookEvent.attrName = lcName;
2360
2573
  hookEvent.attrValue = value;
@@ -2372,9 +2585,12 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2372
2585
  stringIndexOf(value, SANITIZE_NAMED_PROPS_PREFIX) !== 0
2373
2586
  ) {
2374
2587
  // Remove the attribute with this value
2375
- _removeAttribute(name, currentNode);
2588
+ _removeAttribute(name, currentNode, attr);
2376
2589
  // Prefix the value and later re-create the attribute with the sanitized value
2377
2590
  value = SANITIZE_NAMED_PROPS_PREFIX + value;
2591
+ // This attribute owns the record just pushed; its recreate writeback
2592
+ // is the one allowed to pop it back off.
2593
+ recreatedNamedProp = true;
2378
2594
  }
2379
2595
  // Else: already prefixed, leave the attribute alone — the prefix is
2380
2596
  // itself the clobbering protection, and re-applying it is incorrect.
@@ -2387,13 +2603,13 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2387
2603
  value
2388
2604
  )
2389
2605
  ) {
2390
- _removeAttribute(name, currentNode);
2606
+ _removeAttribute(name, currentNode, attr);
2391
2607
  continue;
2392
2608
  }
2393
2609
 
2394
2610
  /* Make sure we cannot easily use animated hrefs, even if animations are allowed */
2395
2611
  if (lcName === 'attributename' && stringMatch(value, 'href')) {
2396
- _removeAttribute(name, currentNode);
2612
+ _removeAttribute(name, currentNode, attr);
2397
2613
  continue;
2398
2614
  }
2399
2615
 
@@ -2404,7 +2620,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2404
2620
 
2405
2621
  /* Did the hooks approve of the attribute? */
2406
2622
  if (!hookEvent.keepAttr) {
2407
- _removeAttribute(name, currentNode);
2623
+ _removeAttribute(name, currentNode, attr);
2408
2624
  continue;
2409
2625
  }
2410
2626
 
@@ -2413,7 +2629,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2413
2629
  !ALLOW_SELF_CLOSE_IN_ATTR &&
2414
2630
  regExpTest(EXPRESSIONS.SELF_CLOSING_TAG, value)
2415
2631
  ) {
2416
- _removeAttribute(name, currentNode);
2632
+ _removeAttribute(name, currentNode, attr);
2417
2633
  continue;
2418
2634
  }
2419
2635
 
@@ -2424,7 +2640,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2424
2640
 
2425
2641
  /* Is `value` valid for this attribute? */
2426
2642
  if (!_isValidAttribute(lcTag, lcName, value)) {
2427
- _removeAttribute(name, currentNode);
2643
+ _removeAttribute(name, currentNode, attr);
2428
2644
  continue;
2429
2645
  }
2430
2646
 
@@ -2433,7 +2649,20 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2433
2649
 
2434
2650
  /* Handle invalid data-* attribute set by try-catching it */
2435
2651
  if (value !== initValue) {
2436
- _setAttributeValue(currentNode, name, namespaceURI, value);
2652
+ const cleanWrite = _setAttributeValue(
2653
+ currentNode,
2654
+ name,
2655
+ namespaceURI,
2656
+ value
2657
+ );
2658
+
2659
+ /* Only the named-prop remove-and-recreate owns a DOMPurify.removed
2660
+ record to pop; a plain trim/template/Trusted-Types writeback owns
2661
+ none, so popping here would drop an unrelated subtree's cleanup
2662
+ record (SO-001). */
2663
+ if (cleanWrite && recreatedNamedProp) {
2664
+ arrayPop(DOMPurify.removed);
2665
+ }
2437
2666
  }
2438
2667
  }
2439
2668
 
@@ -2481,10 +2710,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2481
2710
  Walk it explicitly. The nodeType guard avoids reading
2482
2711
  shadowRoot off text / comment / CDATA / PI nodes that the
2483
2712
  iterator also surfaces. */
2484
- const shadowNodeType = getNodeType
2485
- ? getNodeType(shadowNode)
2486
- : shadowNode.nodeType;
2487
- if (shadowNodeType === NODE_TYPE.element) {
2713
+ if (_readNodeType(shadowNode) === NODE_TYPE.element) {
2488
2714
  const innerSr = getShadowRoot(shadowNode);
2489
2715
  if (_isDocumentFragment(innerSr)) {
2490
2716
  _sanitizeAttachedShadowRoots(innerSr);
@@ -2546,7 +2772,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2546
2772
  }
2547
2773
 
2548
2774
  const node = item.node;
2549
- const nodeType = getNodeType ? getNodeType(node) : (node as any).nodeType;
2775
+ const nodeType = _readNodeType(node);
2550
2776
  const isElement = nodeType === NODE_TYPE.element;
2551
2777
 
2552
2778
  /* (pushed last → processed first) Children, snapshotted in reverse so
@@ -2675,9 +2901,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2675
2901
  child named "nodeName" on the form root would otherwise shadow
2676
2902
  the property and let this check skip the root-allowlist
2677
2903
  validation entirely. */
2678
- const nn = getNodeName
2679
- ? getNodeName(dirty as Node)
2680
- : (dirty as Node).nodeName;
2904
+ const nn = _readNodeName(dirty as Node);
2681
2905
  if (typeof nn === 'string') {
2682
2906
  const tagName = transformCaseFunc(nn);
2683
2907
  if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) {
@@ -2746,8 +2970,16 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2746
2970
  them before the main iterator runs, since the iterator does not
2747
2971
  descend into shadow trees. The walk routes every read through a
2748
2972
  cached prototype getter so clobbering descendants on a form root
2749
- cannot hide a shadow host from this pass. */
2750
- _sanitizeAttachedShadowRoots(importedNode);
2973
+ cannot hide a shadow host from this pass.
2974
+
2975
+ Traverse from `body`, not `importedNode`: when importedNode is a
2976
+ DocumentFragment, the appendChild() above moves its children into
2977
+ `body` and leaves the fragment empty, so walking importedNode would
2978
+ scan nothing and miss every attached shadow host now under `body`
2979
+ (SO-003). In the BODY/HTML branches `body === importedNode`, so this
2980
+ is equivalent there; in the element branch `body` contains the
2981
+ appended element and its descendants. `body` covers all three. */
2982
+ _sanitizeAttachedShadowRoots(body);
2751
2983
  } else {
2752
2984
  /* Exit directly if we have nothing to do */
2753
2985
  if (