dompurify 3.4.16 → 3.4.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/purify.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  arrayLastIndexOf,
15
15
  arrayPop,
16
16
  arrayPush,
17
+ arraySlice,
17
18
  arraySplice,
18
19
  stringMatch,
19
20
  stringReplace,
@@ -22,8 +23,12 @@ import {
22
23
  stringIndexOf,
23
24
  stringTrim,
24
25
  regExpTest,
26
+ setAdd,
27
+ setCreate,
28
+ setHas,
25
29
  isRegex,
26
30
  typeErrorCreate,
31
+ lookupAccessor,
27
32
  lookupGetter,
28
33
  create,
29
34
  objectHasOwnProperty,
@@ -75,10 +80,11 @@ const NODE_TYPE = {
75
80
  `<style>...</style><img onerror=x>` built as a node, which the literal
76
81
  serializer emits verbatim for the HTML parser to re-open.
77
82
  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.
83
+ (b), and any other markup in the text, by the ELEMENT_MARKUP_PROBE branch next
84
+ to it. Both read textContent (the raw-serialized form for these elements)
85
+ rather than innerHTML, because an XML/XHTML working document serializes
86
+ innerHTML with `<` escaped, which silently blinds the innerHTML-based probes
87
+ (rule 1's second probe and FALLBACK_TAG_CLOSE) there.
82
88
  `script` is never allow-listed, but is kept here so the guard matches the
83
89
  serializer's own literal-text list exactly. */
84
90
  const LITERAL_TEXT_ELEMENT_NAMES = [
@@ -93,20 +99,6 @@ const LITERAL_TEXT_ELEMENT_NAMES = [
93
99
  ];
94
100
  const LITERAL_TEXT_ELEMENTS = freeze(addToSet({}, LITERAL_TEXT_ELEMENT_NAMES));
95
101
 
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
-
110
102
  const getGlobal = function (): WindowLike {
111
103
  return typeof window === 'undefined' ? null : window;
112
104
  };
@@ -303,6 +295,17 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
303
295
  return getNodeName ? getNodeName(node) : (node as any).nodeName;
304
296
  };
305
297
 
298
+ /* Element namespace through the cached Element.prototype getter, so a live
299
+ node's own-property override (Object.defineProperty) cannot steer
300
+ namespace decisions; direct read where the getter does not exist. Only
301
+ call this on Elements - the getter throws on other node types. */
302
+ const getNamespaceURI = lookupAccessor(ElementPrototype, 'namespaceURI');
303
+ const _readNamespaceURI = function (element: Element): string | null {
304
+ return getNamespaceURI
305
+ ? getNamespaceURI(element)
306
+ : (element as any).namespaceURI;
307
+ };
308
+
306
309
  // As per issue #47, the web-components registry is inherited by a
307
310
  // new document created via createHTMLDocument. As per the spec
308
311
  // (http://w3c.github.io/webcomponents/spec/custom/#creating-and-passing-registries)
@@ -576,6 +579,9 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
576
579
  * of importing it into a new Document and returning a sanitized copy */
577
580
  let IN_PLACE = false;
578
581
 
582
+ /* IN_PLACE: nodes processed during the current call */
583
+ let IN_PLACE_VISITED: Set<unknown> | null = null;
584
+
579
585
  /* Allow usage of profiles like html, svg and mathMl */
580
586
  let USE_PROFILES: UseProfilesConfig | false = {};
581
587
 
@@ -1152,40 +1158,50 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1152
1158
  * return. Return true otherwise.
1153
1159
  */
1154
1160
  const _checkValidNamespace = function (element: Element): boolean {
1155
- let parent = getParentNode(element);
1156
-
1157
- // In JSDOM, if we're inside shadow DOM, then parentNode
1158
- // can be null. We just simulate parent in this case.
1159
- if (!parent || !parent.tagName) {
1160
- parent = {
1161
- namespaceURI: NAMESPACE,
1162
- tagName: 'template',
1163
- };
1161
+ /* Read names and namespaces through the cached prototype getters, never
1162
+ the instance properties. A live IN_PLACE node can carry own-property
1163
+ overrides of tagName / namespaceURI (Object.defineProperty) that would
1164
+ otherwise steer the classification below: a namespace-confused node
1165
+ posing as a plain HTML element, or a parent posing as an integration
1166
+ point. Same root cause as GHSA-x4vx-rjvf-j5p4 (nodeName); for elements
1167
+ nodeName is the tagName. */
1168
+ const elementNamespace = _readNamespaceURI(element);
1169
+ const tagName = stringToLowerCase(_readNodeName(element));
1170
+
1171
+ // A missing parent (JSDOM inside shadow DOM) or a non-element parent
1172
+ // (DocumentFragment, ShadowRoot, Document) is simulated as a <template>
1173
+ // in the configured namespace, as before.
1174
+ const parentNode = getParentNode(element);
1175
+ let parent: { namespaceURI: string | null };
1176
+ let parentTagName: string;
1177
+ if (parentNode && _readNodeType(parentNode) === NODE_TYPE.element) {
1178
+ parent = { namespaceURI: _readNamespaceURI(parentNode) };
1179
+ parentTagName = stringToLowerCase(_readNodeName(parentNode));
1180
+ } else {
1181
+ parent = { namespaceURI: NAMESPACE };
1182
+ parentTagName = 'template';
1164
1183
  }
1165
1184
 
1166
- const tagName = stringToLowerCase(element.tagName);
1167
- const parentTagName = stringToLowerCase(parent.tagName);
1168
-
1169
- if (!ALLOWED_NAMESPACES[element.namespaceURI]) {
1185
+ if (!ALLOWED_NAMESPACES[elementNamespace]) {
1170
1186
  return false;
1171
1187
  }
1172
1188
 
1173
- if (element.namespaceURI === SVG_NAMESPACE) {
1189
+ if (elementNamespace === SVG_NAMESPACE) {
1174
1190
  return _checkSvgNamespace(tagName, parent, parentTagName);
1175
1191
  }
1176
1192
 
1177
- if (element.namespaceURI === MATHML_NAMESPACE) {
1193
+ if (elementNamespace === MATHML_NAMESPACE) {
1178
1194
  return _checkMathMlNamespace(tagName, parent, parentTagName);
1179
1195
  }
1180
1196
 
1181
- if (element.namespaceURI === HTML_NAMESPACE) {
1197
+ if (elementNamespace === HTML_NAMESPACE) {
1182
1198
  return _checkHtmlNamespace(tagName, parent, parentTagName);
1183
1199
  }
1184
1200
 
1185
1201
  // For XHTML and XML documents that support custom namespaces
1186
1202
  if (
1187
1203
  PARSER_MEDIA_TYPE === 'application/xhtml+xml' &&
1188
- ALLOWED_NAMESPACES[element.namespaceURI]
1204
+ ALLOWED_NAMESPACES[elementNamespace]
1189
1205
  ) {
1190
1206
  return true;
1191
1207
  }
@@ -1271,46 +1287,29 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1271
1287
  };
1272
1288
 
1273
1289
  /**
1274
- * _neutralizeRoot
1275
- *
1276
- * Fail-closed teardown of an in-place root after the sanitize walk aborts
1277
- * (campaign-3 F2). An internal throw mid-walk — e.g. a page-registered
1278
- * custom element's reaction detaches a node so `_forceRemove`'s deliberate
1279
- * parentless guard throws, or any other re-entrant engine mutation — would
1280
- * otherwise leave the caller's *live* tree half-sanitized, with everything
1281
- * after the abort point still carrying its handlers. There is no safe way
1282
- * to resume the walk (the tree mutated under us), so we strip the root bare:
1283
- * remove every child and every attribute, then let the caller's catch see
1284
- * the original error. Clobber-safe (cached `remove`/`childNodes`/`attributes`
1285
- * getters; the root was already clobber-pre-flighted at the IN_PLACE entry).
1290
+ * Fail-closed teardown of an in-place root: strip every disallowed attribute
1291
+ * in the subtree first, so nothing armed is left behind, then remove all
1292
+ * children and the root's own attributes.
1286
1293
  *
1287
1294
  * @param root the in-place root to empty
1288
1295
  */
1289
1296
  const _neutralizeRoot = function (root: Node): void {
1290
- /* Strip every disallowed attribute (on* handlers included) off the whole
1291
- subtree BEFORE detaching anything. Detaching first would hand back
1292
- handler-bearing originals (e.g. an already-loading `<img onerror>`)
1293
- whose queued resource event still fires in page scope after we throw.
1294
- Clobber-safe reads; a doomed clobbered node's own attributes are
1295
- irrelevant while its non-clobbered descendants are reached and scrubbed. */
1296
1297
  _neutralizeSubtree(root);
1297
1298
 
1298
1299
  const childNodes = getChildNodes(root);
1299
1300
  if (childNodes) {
1300
- const snapshot: Node[] = [];
1301
- arrayForEach(childNodes, (child) => {
1302
- arrayPush(snapshot, child);
1303
- });
1304
- arrayForEach(snapshot, (child) => {
1301
+ arrayForEach(arraySlice(childNodes), (child: Node) => {
1305
1302
  try {
1306
1303
  remove(child);
1307
1304
  } catch (_) {
1308
- /* Best-effort teardown; a still-attached child is handled below */
1305
+ /* Best effort */
1309
1306
  }
1310
1307
  });
1311
1308
  }
1312
1309
 
1313
- const attributes = getAttributes(root);
1310
+ // Only elements have attributes; the root may be a rejected fragment
1311
+ const attributes =
1312
+ _readNodeType(root) === NODE_TYPE.element ? getAttributes(root) : null;
1314
1313
  if (attributes) {
1315
1314
  for (let i = attributes.length - 1; i >= 0; --i) {
1316
1315
  const attribute = attributes[i];
@@ -1323,18 +1322,9 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1323
1322
  };
1324
1323
 
1325
1324
  /**
1326
- * _removeAttribute
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.
1325
+ * Removes an attribute, preferably as its exact Attr node: name-based
1326
+ * removal lowercases the name in HTML documents and misses case-preserved
1327
+ * names, e.g. from imported XHTML.
1338
1328
  *
1339
1329
  * @param name an Attribute name
1340
1330
  * @param element a DOM node
@@ -1365,13 +1355,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1365
1355
  element.removeAttribute(name);
1366
1356
  }
1367
1357
  } catch (_) {
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. */
1358
+ /* Clobbered or detached node: fall back to name-based removal */
1375
1359
  try {
1376
1360
  element.removeAttribute(name);
1377
1361
  } catch (_) {}
@@ -1392,12 +1376,8 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1392
1376
  };
1393
1377
 
1394
1378
  /**
1395
- * _stripDisallowedAttributes
1396
- *
1397
- * Removes every attribute the active configuration does not allow from a
1398
- * single element, using the same allowlist as the main attribute pass (so
1399
- * `on*` handlers go, but no `/^on/` blocklist is introduced). Used only to
1400
- * neutralise nodes that are being discarded from an in-place tree.
1379
+ * Removes every attribute the configuration does not allow. Used to
1380
+ * neutralize nodes discarded from an in-place tree.
1401
1381
  *
1402
1382
  * @param element the element to strip
1403
1383
  */
@@ -1419,36 +1399,33 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1419
1399
  };
1420
1400
 
1421
1401
  /**
1422
- * _neutralizeSubtree
1423
- *
1424
- * Completes the audit-5 F1 fix across every removal path. The KEEP_CONTENT
1425
- * move-hoist neutralises only disallowed-tag removals; clobber, mXSS-canary,
1426
- * namespace, comment, processing-instruction and KEEP_CONTENT:false removals
1427
- * all drop their subtree wholesale via `_forceRemove`. On the IN_PLACE path
1428
- * those dropped nodes are detached from the caller's LIVE tree but a
1429
- * handler-bearing original among them (an `<img onerror>`/`<video>` that was
1430
- * loading) keeps its queued resource event, which fires in page scope after
1431
- * sanitize returns. This walks a removed subtree and strips every attribute
1432
- * the active configuration does not allow — so `on*` handlers are cancelled
1433
- * through the SAME allowlist that governs kept nodes, not a separate `/^on/`
1434
- * blocklist. Run synchronously before sanitize returns, i.e. before any
1435
- * queued event can fire. Hook-free by design: these nodes leave the output,
1436
- * so firing attribute hooks for them would be surprising. Clobber-safe reads;
1437
- * a doomed clobbered node may shadow `removeAttribute` (its own attributes are
1438
- * irrelevant — it is discarded — while its non-clobbered descendants, e.g.
1439
- * the `<img>`, are reached and scrubbed).
1402
+ * Iterative depth-first walk, so deep trees cannot overflow the call stack,
1403
+ * reading through the cached getters, so clobbering cannot hide children.
1404
+ * When `visit` returns false, the node's children are skipped. With
1405
+ * `intoShadow`, open shadow roots are walked after their host's children.
1440
1406
  *
1441
- * @param root the root of a removed subtree to neutralise
1407
+ * @param root where to start
1408
+ * @param visit called for every node
1409
+ * @param intoShadow whether to enter open shadow roots
1442
1410
  */
1443
- const _neutralizeSubtree = function (root: Node): void {
1411
+ const _walkTree = function (
1412
+ root: Node,
1413
+ visit: (node: Node) => boolean | void,
1414
+ intoShadow = false
1415
+ ): void {
1444
1416
  const stack: Node[] = [root];
1445
1417
 
1446
1418
  while (stack.length > 0) {
1447
1419
  const node = stack.pop();
1448
- const nodeType = _readNodeType(node);
1420
+ if (visit(node) === false) {
1421
+ continue;
1422
+ }
1449
1423
 
1450
- if (nodeType === NODE_TYPE.element) {
1451
- _stripDisallowedAttributes(node as Element);
1424
+ if (intoShadow && _readNodeType(node) === NODE_TYPE.element) {
1425
+ const shadowRoot = getShadowRoot(node);
1426
+ if (_isDocumentFragment(shadowRoot)) {
1427
+ stack.push(shadowRoot);
1428
+ }
1452
1429
  }
1453
1430
 
1454
1431
  const childNodes = getChildNodes(node);
@@ -1461,46 +1438,191 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1461
1438
  };
1462
1439
 
1463
1440
  /**
1464
- * _neutralizePatchLinkage
1441
+ * Strips disallowed attributes from every element of a discarded subtree,
1442
+ * so a handler that is already queued (a loading image) cannot fire.
1443
+ *
1444
+ * @param root the root of a removed subtree
1445
+ */
1446
+ const _neutralizeSubtree = function (root: Node): void {
1447
+ _walkTree(root, (node) => {
1448
+ if (_readNodeType(node) === NODE_TYPE.element) {
1449
+ _stripDisallowedAttributes(node as Element);
1450
+ }
1451
+ });
1452
+ };
1453
+
1454
+ /**
1455
+ * All nodes under `root`, root first, including open shadow roots. Template
1456
+ * content is not entered, it is inert.
1465
1457
  *
1466
- * IN_PLACE entry pre-pass (declarative-partial-updates / streaming
1467
- * hardening, https://github.com/WICG/declarative-partial-updates).
1458
+ * @param root the in-place root
1459
+ * @return the nodes
1460
+ */
1461
+ const _collectLiveNodes = function (root: Node): Node[] {
1462
+ const nodes: Node[] = [];
1463
+ _walkTree(
1464
+ root,
1465
+ (node) => {
1466
+ nodes.push(node);
1467
+ },
1468
+ true
1469
+ );
1470
+ return nodes;
1471
+ };
1472
+
1473
+ /**
1474
+ * Strips disallowed attributes from every element that was under the
1475
+ * in-place root when the call started and no longer is, whatever removed
1476
+ * or moved it.
1468
1477
  *
1469
- * The main walk strips patch linkage (`for`/`patchsrc`) and removes range
1470
- * markers (PIs / markup comments) node-by-node, in document order, AS it
1471
- * reaches each node. On a live in-place root that leaves a window: from the
1472
- * moment the root is connected until the walk arrives at a given node, that
1473
- * node's linkage is live. A patch applied on connection/stream can fire as
1474
- * a microtask during the walk and inject or teleport an unsanitized DOM
1475
- * range into a region the iterator has already passed and will not revisit,
1476
- * so the post-return "tree is sanitized" contract is violated. Sweep the
1477
- * whole tree once up front and sever every linkage before the walk begins,
1478
- * closing that window.
1478
+ * @param root the in-place root
1479
+ * @param before the root's nodes, collected before the walk
1480
+ */
1481
+ const _neutralizeEscapedElements = function (
1482
+ root: Node,
1483
+ before: Node[]
1484
+ ): void {
1485
+ const reachable = setCreate();
1486
+ arrayForEach(_collectLiveNodes(root), (node: Node) => {
1487
+ if (_readNodeType(node) === NODE_TYPE.element) {
1488
+ setAdd(reachable, node);
1489
+ }
1490
+ });
1491
+ arrayForEach(before, (node: Node) => {
1492
+ if (
1493
+ _readNodeType(node) === NODE_TYPE.element &&
1494
+ !setHas(reachable, node)
1495
+ ) {
1496
+ _stripDisallowedAttributes(node as Element);
1497
+ }
1498
+ });
1499
+ };
1500
+
1501
+ /**
1502
+ * Neutralizes everything that left the in-place tree during the call: what
1503
+ * DOMPurify removed, and what hooks or custom elements removed or moved out.
1479
1504
  *
1480
- * This CANNOT undo a patch that already fired before sanitize ran — that is
1481
- * the irreducible "do not IN_PLACE a live-connected attacker tree" caveat —
1482
- * but it closes everything from sanitize-start onward. Gated on SAFE_FOR_XML
1483
- * to group with the rest of the declarative-partial-updates handling and
1484
- * stay overridable, consistent with the codebase.
1505
+ * @param root the in-place root
1506
+ * @param before the root's nodes, collected before the walk
1507
+ * @return true if the root itself was removed
1508
+ */
1509
+ const _neutralizeDiscarded = function (root: Node, before: Node[]): boolean {
1510
+ let rootRemoved = false;
1511
+ arrayForEach(DOMPurify.removed, (entry) => {
1512
+ if (entry.element) {
1513
+ rootRemoved = rootRemoved || entry.element === root;
1514
+ _neutralizeSubtree(entry.element as Node);
1515
+ }
1516
+ });
1517
+ _neutralizeEscapedElements(root, before);
1518
+ return rootRemoved;
1519
+ };
1520
+
1521
+ const MAX_UNVISITED_ROUNDS = 8;
1522
+
1523
+ /**
1524
+ * The walk only moves forward, so a node moved to a position it already
1525
+ * passed is never visited. Give every original node that is still in the
1526
+ * tree but unvisited the normal treatment, hooks included. Nodes created
1527
+ * during the call are left alone. Fails closed if the tree keeps changing.
1485
1528
  *
1486
- * Clobber-safe traversal (cached childNodes getter); per-node try/catch so a
1487
- * clobbered root cannot defeat the sweep of its non-clobbered descendants.
1529
+ * @param root the in-place root
1530
+ * @param before the root's nodes, collected before the walk
1531
+ */
1532
+ const _sanitizeUnvisitedNodes = function (root: Node, before: Node[]): void {
1533
+ const visited = IN_PLACE_VISITED;
1534
+ /* Built lazily: usually every live node was visited */
1535
+ let original: Set<unknown> | null = null;
1536
+ const getOriginal = (): Set<unknown> => {
1537
+ if (!original) {
1538
+ original = setCreate();
1539
+ arrayForEach(before, (beforeNode: Node) => {
1540
+ setAdd(original, beforeNode);
1541
+ });
1542
+ }
1543
+
1544
+ return original;
1545
+ };
1546
+
1547
+ for (let round = 0; ; round++) {
1548
+ const live = _collectLiveNodes(root);
1549
+ const containers: Node[] = [root];
1550
+ let pending = false;
1551
+
1552
+ arrayForEach(live, (node: Node) => {
1553
+ if (_readNodeType(node) === NODE_TYPE.documentFragment) {
1554
+ /* An open shadow root under the root */
1555
+ arrayPush(containers, node);
1556
+ } else if (!pending && !setHas(visited, node)) {
1557
+ pending = setHas(getOriginal(), node);
1558
+ }
1559
+ });
1560
+
1561
+ if (!pending) {
1562
+ return;
1563
+ }
1564
+
1565
+ if (round >= MAX_UNVISITED_ROUNDS) {
1566
+ throw typeErrorCreate(
1567
+ 'the tree kept changing during in-place sanitization; ' +
1568
+ 'refusing to return it'
1569
+ );
1570
+ }
1571
+
1572
+ arrayForEach(containers, (container: Node, index: number) => {
1573
+ _sanitizeUnvisitedIn(container, index > 0, root, getOriginal());
1574
+ });
1575
+ }
1576
+ };
1577
+
1578
+ /**
1579
+ * One _sanitizeUnvisitedNodes pass over the root or an open shadow root.
1488
1580
  *
1489
- * NOTE (pending real-Chrome confirmation, see test/declarative-patch-probe
1490
- * .html Q1): this mirrors the existing policy of keeping `for` on
1491
- * <label>/<output>. If the shipping feature can drive a patch through a
1492
- * surviving `for`-on-label/output + `id` pair, this pre-pass and the
1493
- * attribute check at _isBasicCustomElement's caller must additionally drop
1494
- * that pair on the IN_PLACE path. Left as-is until the taxonomy is verified.
1581
+ * @param container the root or an open shadow root
1582
+ * @param inShadow whether container is a shadow root
1583
+ * @param root the in-place root
1584
+ * @param original the root's nodes collected before the walk, as a set
1585
+ */
1586
+ const _sanitizeUnvisitedIn = function (
1587
+ container: Node,
1588
+ inShadow: boolean,
1589
+ root: Node,
1590
+ original: Set<unknown>
1591
+ ): void {
1592
+ const iterator = _createNodeIterator(container);
1593
+ let node: Node | null;
1594
+ while ((node = iterator.nextNode())) {
1595
+ if (!setHas(original, node) || setHas(IN_PLACE_VISITED, node)) {
1596
+ continue;
1597
+ }
1598
+
1599
+ if (inShadow) {
1600
+ _executeHooks(hooks.uponSanitizeShadowNode, node, null);
1601
+ }
1602
+
1603
+ _sanitizeElements(node, root);
1604
+ _sanitizeAttributes(node as Element, root);
1605
+ if (_isDocumentFragment((node as HTMLTemplateElement).content)) {
1606
+ _sanitizeShadowDOM((node as HTMLTemplateElement).content);
1607
+ }
1608
+ }
1609
+ };
1610
+
1611
+ /**
1612
+ * Records that _sanitizeElements processed `node`. No-op outside IN_PLACE.
1495
1613
  *
1496
- * @param root the in-place root to sweep
1614
+ * @param node the node being processed
1497
1615
  */
1616
+ const _markVisited = function (node: Node): void {
1617
+ if (IN_PLACE_VISITED) {
1618
+ setAdd(IN_PLACE_VISITED, node);
1619
+ }
1620
+ };
1621
+
1498
1622
  /**
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).
1623
+ * Patch-linkage attributes (declarative partial updates): `patchsrc`
1624
+ * always, `for` everywhere except on <label> and <output>. Gated on
1625
+ * SAFE_FOR_XML. Shared by the IN_PLACE pre-pass and _isValidAttribute.
1504
1626
  *
1505
1627
  * @param lcName the transformCaseFunc'd attribute name
1506
1628
  * @param lcTag the transformCaseFunc'd tag name of the carrying element
@@ -1521,18 +1643,26 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1521
1643
  return lcName === 'for' && lcTag !== 'label' && lcTag !== 'output';
1522
1644
  };
1523
1645
 
1646
+ /**
1647
+ * IN_PLACE pre-pass: sever all patch linkage before the walk, so no patch
1648
+ * can fire mid-walk into a part of the tree the walker already passed.
1649
+ * Removes range markers (processing instructions, comments with markup)
1650
+ * and strips linkage attributes. It cannot undo a patch that already fired.
1651
+ *
1652
+ * TODO: if the shipped feature can patch through `for` on <label>/<output>,
1653
+ * drop that pair here too (see test/declarative-patch-probe.html).
1654
+ *
1655
+ * @param root the in-place root to sweep
1656
+ */
1524
1657
  const _neutralizePatchLinkage = function (root: Node): void {
1525
1658
  if (!SAFE_FOR_XML) {
1526
1659
  return;
1527
1660
  }
1528
1661
 
1529
- const stack: Node[] = [root];
1530
- while (stack.length > 0) {
1531
- const node = stack.pop();
1662
+ _walkTree(root, (node) => {
1532
1663
  const nodeType = _readNodeType(node);
1533
1664
 
1534
- /* Remove range markers (the target side of a patch linkage): every
1535
- processing instruction, and any markup-bearing comment. */
1665
+ // Range markers: processing instructions, comments with markup
1536
1666
  if (
1537
1667
  nodeType === NODE_TYPE.processingInstruction ||
1538
1668
  (nodeType === NODE_TYPE.comment &&
@@ -1541,13 +1671,13 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1541
1671
  try {
1542
1672
  remove(node);
1543
1673
  } catch (_) {
1544
- /* Best-effort */
1674
+ /* Best effort */
1545
1675
  }
1546
1676
 
1547
- continue;
1677
+ return false;
1548
1678
  }
1549
1679
 
1550
- /* Strip patch-source attributes (the source side) off elements. */
1680
+ // Linkage attributes
1551
1681
  if (nodeType === NODE_TYPE.element) {
1552
1682
  const element = node as Element;
1553
1683
  const lcTag = transformCaseFunc(_readNodeName(node));
@@ -1564,17 +1694,12 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1564
1694
  element.removeAttribute('for');
1565
1695
  }
1566
1696
  } catch (_) {
1567
- /* Clobbered removeAttribute/hasAttribute on a doomed node — ignore */
1697
+ /* Clobbered methods on a doomed node */
1568
1698
  }
1569
1699
  }
1570
1700
 
1571
- const childNodes = getChildNodes(node);
1572
- if (childNodes) {
1573
- for (let i = childNodes.length - 1; i >= 0; --i) {
1574
- stack.push(childNodes[i]);
1575
- }
1576
- }
1577
- }
1701
+ return true;
1702
+ });
1578
1703
  };
1579
1704
 
1580
1705
  /**
@@ -1715,6 +1840,36 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1715
1840
  * @param node The root element whose character data should be scrubbed.
1716
1841
  */
1717
1842
  const _scrubTemplateExpressions = function (node: Element): void {
1843
+ _scrubOwnCharacterData(node);
1844
+
1845
+ // NodeIterator does not descend into <template>.content per the DOM spec,
1846
+ // so we must explicitly recurse into each template's content fragment,
1847
+ // mirroring the approach used by _sanitizeShadowDOM.
1848
+ const templates = node.querySelectorAll?.('template');
1849
+ if (templates) {
1850
+ arrayForEach(templates, (tmpl: HTMLTemplateElement) => {
1851
+ if (_isDocumentFragment(tmpl.content)) {
1852
+ _scrubTemplateExpressions(tmpl.content as unknown as Element);
1853
+ }
1854
+ });
1855
+ }
1856
+ };
1857
+
1858
+ /**
1859
+ * _scrubOwnCharacterData
1860
+ *
1861
+ * Non-recursive core of _scrubTemplateExpressions: normalize `node` and
1862
+ * strip template expressions from its own character data only. Does not
1863
+ * enter <template>.content or attached shadow roots (NodeIterator never
1864
+ * does). Used by _sanitizeShadowDOM, where every nested template content
1865
+ * and shadow root is already walked - and therefore scrubbed - by its own
1866
+ * recursive _sanitizeShadowDOM call; recursing here as well would rescrub
1867
+ * each nested fragment once per ancestor, which is quadratic in nesting
1868
+ * depth.
1869
+ *
1870
+ * @param node The root whose own character data should be scrubbed.
1871
+ */
1872
+ const _scrubOwnCharacterData = function (node: Element): void {
1718
1873
  node.normalize();
1719
1874
  /* Clobber-safe ownerDocument read, same reasoning as _createNodeIterator:
1720
1875
  under SAFE_FOR_TEMPLATES this runs on the live IN_PLACE root, which may
@@ -1736,18 +1891,6 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1736
1891
  currentNode.data = _stripTemplateExpressions(currentNode.data);
1737
1892
  currentNode = walker.nextNode() as CharacterData | null;
1738
1893
  }
1739
-
1740
- // NodeIterator does not descend into <template>.content per the DOM spec,
1741
- // so we must explicitly recurse into each template's content fragment,
1742
- // mirroring the approach used by _sanitizeShadowDOM.
1743
- const templates = node.querySelectorAll?.('template');
1744
- if (templates) {
1745
- arrayForEach(templates, (tmpl: HTMLTemplateElement) => {
1746
- if (_isDocumentFragment(tmpl.content)) {
1747
- _scrubTemplateExpressions(tmpl.content as unknown as Element);
1748
- }
1749
- });
1750
- }
1751
1894
  };
1752
1895
 
1753
1896
  /**
@@ -1914,14 +2057,31 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1914
2057
  is why both probes here read textContent instead. Previously only
1915
2058
  `style`-with-element-child was covered; every element in
1916
2059
  LITERAL_TEXT_ELEMENTS shares this literal serialization and is
1917
- equally affected. */
2060
+ equally affected.
2061
+
2062
+ The text probe is rule 1's ELEMENT_MARKUP_PROBE, not just the element's
2063
+ own end tag. For these elements textContent is exactly what an HTML
2064
+ serializer emits, so in an HTML working document this is the same
2065
+ decision rule 1 already makes there (markup-like text is removed). Only
2066
+ matching the own end tag let an XML/XHTML-sourced literal-text element
2067
+ keep markup such as `</style><img onerror>` that the same input loses in
2068
+ HTML mode, and that breaks out once the output is placed in another
2069
+ raw-text context.
2070
+
2071
+ Namespace through the cached getter (see _checkValidNamespace), and only
2072
+ for Elements: a processing instruction's nodeName is its target, which
2073
+ can collide with a name in LITERAL_TEXT_ELEMENTS. */
1918
2074
  if (
1919
2075
  SAFE_FOR_XML &&
1920
- currentNode.namespaceURI === HTML_NAMESPACE &&
1921
2076
  LITERAL_TEXT_ELEMENTS[tagName] &&
2077
+ _readNodeType(currentNode) === NODE_TYPE.element &&
2078
+ _readNamespaceURI(currentNode) === HTML_NAMESPACE &&
1922
2079
  (_isNode(currentNode.firstElementChild) ||
1923
2080
  (typeof currentNode.textContent === 'string' &&
1924
- regExpTest(LITERAL_TEXT_CLOSE[tagName], currentNode.textContent)))
2081
+ regExpTest(
2082
+ EXPRESSIONS.ELEMENT_MARKUP_PROBE,
2083
+ currentNode.textContent
2084
+ )))
1925
2085
  ) {
1926
2086
  return true;
1927
2087
  }
@@ -2010,7 +2170,16 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2010
2170
  path was dead in safe cases and a clobbering surface in unsafe
2011
2171
  ones. Falsy cached results stay falsy; the `if (childNodes &&
2012
2172
  parentNode)` check already gates correctly. */
2013
- if (KEEP_CONTENT && !FORBID_CONTENTS[tagName]) {
2173
+ if (
2174
+ KEEP_CONTENT &&
2175
+ !FORBID_CONTENTS[tagName] &&
2176
+ /* The default list is lowercase, but in application/xhtml+xml mode
2177
+ tagName keeps its case, so `foreignObject` would miss its
2178
+ `foreignobject` entry and have its content hoisted instead of
2179
+ dropped, unlike in HTML mode. Also consult the lowercased name; this
2180
+ can only drop more content, never keep more. */
2181
+ !FORBID_CONTENTS[stringToLowerCase(tagName)]
2182
+ ) {
2014
2183
  const parentNode = getParentNode(currentNode);
2015
2184
  const childNodes = getChildNodes(currentNode);
2016
2185
 
@@ -2079,35 +2248,13 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2079
2248
  };
2080
2249
 
2081
2250
  /**
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.
2251
+ * A hook may have detached the node it was called for. If so, treat it as
2252
+ * removed and stop processing it; the walk root is exempt. On the IN_PLACE
2253
+ * path the detached subtree is neutralized right away.
2107
2254
  *
2108
2255
  * @param currentNode the node a hook may have detached
2109
2256
  * @param root the current walk root
2110
- * @return true if the node is detached and now handled, false otherwise
2257
+ * @return true if the node was detached
2111
2258
  */
2112
2259
  const _handleHookDetachedNode = function (
2113
2260
  currentNode: Node,
@@ -2134,6 +2281,9 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2134
2281
  * @return true if node was killed, false if left alive
2135
2282
  */
2136
2283
  const _sanitizeElements = function (currentNode: any, root: Node): boolean {
2284
+ /* IN_PLACE: remember the node was processed (see _sanitizeUnvisitedNodes). */
2285
+ _markVisited(currentNode);
2286
+
2137
2287
  /* Execute a hook if present */
2138
2288
  _executeHooks(hooks.beforeSanitizeElements, currentNode, null);
2139
2289
 
@@ -2523,6 +2673,47 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2523
2673
  }
2524
2674
  };
2525
2675
 
2676
+ /**
2677
+ * _isUnsafeAttributeValue
2678
+ *
2679
+ * Value-based attribute checks that the attribute walk applies before any
2680
+ * hook can force-keep an attribute, shared with the public
2681
+ * isValidAttribute() so that it never approves a value sanitize() would
2682
+ * strip.
2683
+ *
2684
+ * @param name the attribute name as found on the node (case preserved)
2685
+ * @param value the attribute value
2686
+ * @returns true if the attribute must be removed because of its value
2687
+ */
2688
+ const _isUnsafeAttributeValue = function (
2689
+ name: string,
2690
+ value: string
2691
+ ): boolean {
2692
+ /* Work around a security issue with comments inside attributes, and
2693
+ raw-text closers that re-contextualize the value on a later reparse */
2694
+ if (
2695
+ SAFE_FOR_XML &&
2696
+ regExpTest(
2697
+ /((--!?|])>)|<\/(style|script|title|xmp|textarea|noscript|iframe|noembed|noframes)/i,
2698
+ value
2699
+ )
2700
+ ) {
2701
+ return true;
2702
+ }
2703
+
2704
+ /* Make sure we cannot easily use animated hrefs, even if animations are
2705
+ allowed. Compared case-insensitively on purpose: in
2706
+ application/xhtml+xml mode names keep their case, and the real SVG
2707
+ attribute is spelled attributeName, which 'attributename' would
2708
+ never match (GHSA-c6gr-qvv4-5v3f). In XHTML a lowercase
2709
+ `attributename` is not the SMIL attribute, so also stripping it
2710
+ there costs nothing. */
2711
+ return (
2712
+ stringToLowerCase(name) === 'attributename' &&
2713
+ stringMatch(value, 'href') !== null
2714
+ );
2715
+ };
2716
+
2526
2717
  /**
2527
2718
  * _sanitizeAttributes
2528
2719
  *
@@ -2534,7 +2725,6 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2534
2725
  * @param currentNode to sanitize
2535
2726
  * @param root the current walk root
2536
2727
  */
2537
- // eslint-disable-next-line complexity
2538
2728
  const _sanitizeAttributes = function (
2539
2729
  currentNode: Element,
2540
2730
  root: Node
@@ -2622,20 +2812,9 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2622
2812
  // Else: already prefixed, leave the attribute alone — the prefix is
2623
2813
  // itself the clobbering protection, and re-applying it is incorrect.
2624
2814
 
2625
- /* Work around a security issue with comments inside attributes */
2626
- if (
2627
- SAFE_FOR_XML &&
2628
- regExpTest(
2629
- /((--!?|])>)|<\/(style|script|title|xmp|textarea|noscript|iframe|noembed|noframes)/i,
2630
- value
2631
- )
2632
- ) {
2633
- _removeAttribute(name, currentNode, attr);
2634
- continue;
2635
- }
2636
-
2637
- /* Make sure we cannot easily use animated hrefs, even if animations are allowed */
2638
- if (lcName === 'attributename' && stringMatch(value, 'href')) {
2815
+ /* Value checks that apply regardless of hooks (comment / raw-text
2816
+ closers in the value, animated hrefs); see _isUnsafeAttributeValue. */
2817
+ if (_isUnsafeAttributeValue(name, value)) {
2639
2818
  _removeAttribute(name, currentNode, attr);
2640
2819
  continue;
2641
2820
  }
@@ -2751,53 +2930,50 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2751
2930
  }
2752
2931
  }
2753
2932
 
2933
+ /* Post-walk template-expression safety net, mirroring the light-DOM
2934
+ passes on `dirty` / `body`. The per-node strip above (via
2935
+ _sanitizeElements) only sees each text node in isolation, so an
2936
+ expression split across text siblings by a disallowed separator element
2937
+ (e.g. `$` <object> `{alert(1)}`) survives: each fragment matches no
2938
+ delimiter regex on its own, and once the separator is removed the
2939
+ fragments become adjacent and serialize as one complete expression.
2940
+ The light-DOM net catches exactly this, but NodeIterator does not
2941
+ descend into shadow trees and node.normalize() does not merge text
2942
+ across the shadow boundary, so the fragments were never re-examined as
2943
+ a merged unit. normalize() here (inside _scrubTemplateExpressions)
2944
+ merges them and re-strips. Deliberately the non-recursive variant: each
2945
+ nested shadow root and template content is walked by its own
2946
+ _sanitizeShadowDOM call above and scrubbed there, so every fragment is
2947
+ scrubbed exactly once (the recursive variant would make this quadratic
2948
+ in nesting depth). Gated on SAFE_FOR_TEMPLATES like the light-DOM
2949
+ passes, and placed before the afterSanitizeShadowDOM hook so that hook
2950
+ observes the finalized fragment. */
2951
+ if (SAFE_FOR_TEMPLATES) {
2952
+ _scrubOwnCharacterData(fragment as unknown as Element);
2953
+ }
2954
+
2754
2955
  /* Execute a hook if present */
2755
2956
  _executeHooks(hooks.afterSanitizeShadowDOM, fragment, null);
2756
2957
  };
2757
2958
 
2758
2959
  /**
2759
- * _sanitizeAttachedShadowRoots
2760
- *
2761
- * Walks `root` and feeds every attached shadow root we encounter into
2762
- * the existing _sanitizeShadowDOM pipeline. The default node iterator
2763
- * does not descend into shadow trees, so nodes inside an attached
2764
- * shadow root would otherwise be skipped entirely.
2765
- *
2766
- * Two real input paths put attached shadow roots in front of us:
2767
- * 1. IN_PLACE on a DOM node that already has shadow roots attached.
2768
- * 2. DOM-node input where importNode(dirty, true) deep-clones the
2769
- * shadow root because it was created with `clonable: true`.
2770
- *
2771
- * This pass runs once, up front, so the main iteration loop (and the
2772
- * existing _sanitizeShadowDOM template-content recursion) stay
2773
- * untouched — string-input paths are not affected.
2960
+ * Sanitizes every attached shadow root under `root`, since the node
2961
+ * iterator does not enter shadow trees. Needed for IN_PLACE input and for
2962
+ * DOM input whose clonable shadow roots were copied by importNode().
2774
2963
  *
2775
2964
  * @param root the subtree root to walk for attached shadow roots
2776
2965
  */
2777
2966
  const _sanitizeAttachedShadowRoots = function (root: Node): void {
2778
- /* Iterative (explicit stack) rather than per-child recursion. DOM APIs
2779
- impose no depth cap, so an attacker-shaped tree (JSON/CRDT/editor data
2780
- built straight into the DOM — the IN_PLACE surface) deeper than the JS
2781
- call-stack budget would otherwise overflow native recursion here and
2782
- throw at the IN_PLACE entry pre-pass, before a single node is
2783
- sanitized, leaving the caller's live tree untouched (fail-open). See
2784
- campaign-3 F4. A heap stack keeps depth off the call stack.
2785
-
2786
- Each work item is either a node to descend into, or a deferred
2787
- `_sanitizeShadowDOM` for an already-walked shadow root. The deferred
2788
- form preserves the original post-order discipline: a shadow root's
2789
- nested shadow roots are discovered before the outer shadow is
2790
- sanitized (which may remove hosts). Pushes are in reverse of the
2791
- desired processing order (LIFO): template content, then children, then
2792
- the shadow-sanitize, then the shadow walk — so the order matches the
2793
- previous recursion exactly. */
2967
+ /* Explicit stack, so deep trees cannot overflow the call stack. An item is
2968
+ a node to walk or a shadow root to sanitize. Items are processed in
2969
+ reverse push order: a shadow root is walked first (finding nested
2970
+ roots), then sanitized, then template content, then the children. */
2794
2971
  const stack: Array<{ node: Node | null; shadow: DocumentFragment | null }> =
2795
2972
  [{ node: root, shadow: null }];
2796
2973
 
2797
2974
  while (stack.length > 0) {
2798
2975
  const item = stack.pop();
2799
2976
 
2800
- /* Deferred shadow-DOM sanitisation: runs after its subtree was walked. */
2801
2977
  if (item.shadow) {
2802
2978
  _sanitizeShadowDOM(item.shadow);
2803
2979
  continue;
@@ -2807,9 +2983,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2807
2983
  const nodeType = _readNodeType(node);
2808
2984
  const isElement = nodeType === NODE_TYPE.element;
2809
2985
 
2810
- /* (pushed last → processed first) Children, snapshotted in reverse so
2811
- the first child is processed first. Snapshotting matters because a
2812
- hook may detach siblings mid-walk. */
2986
+ // Children, copied now since hooks may detach siblings mid-walk
2813
2987
  const childNodes = getChildNodes(node);
2814
2988
  if (childNodes) {
2815
2989
  for (let i = childNodes.length - 1; i >= 0; --i) {
@@ -2817,9 +2991,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2817
2991
  }
2818
2992
  }
2819
2993
 
2820
- /* (pushed before children → processed after them, matching the old
2821
- "template content last" order) When the node is a <template>,
2822
- descend into its content. */
2994
+ // Template content
2823
2995
  if (isElement) {
2824
2996
  const rootName = getNodeName ? getNodeName(node) : null;
2825
2997
  if (
@@ -2833,17 +3005,10 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2833
3005
  }
2834
3006
  }
2835
3007
 
2836
- /* Shadow root (processed first): walk its subtree, then sanitise it.
2837
- Realm-safe check (GHSA-hpcv-96wg-7vj8): nodeType-based detection
2838
- rather than `instanceof DocumentFragment`, which is realm-bound and
2839
- silently skipped foreign-realm shadow roots (e.g.
2840
- iframe.contentDocument attachShadow). */
3008
+ // Shadow root: walk it, then sanitize it (realm-safe nodeType check)
2841
3009
  if (isElement) {
2842
3010
  const sr = getShadowRoot(node);
2843
3011
  if (_isDocumentFragment(sr)) {
2844
- /* Push the deferred sanitise first so it pops after the shadow
2845
- walk we push next, i.e. nested shadow roots are discovered
2846
- before this one is sanitised. */
2847
3012
  stack.push({ node: null, shadow: sr }, { node: sr, shadow: null });
2848
3013
  }
2849
3014
  }
@@ -2913,73 +3078,40 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2913
3078
  /* Clean up removed elements */
2914
3079
  DOMPurify.removed = [];
2915
3080
 
2916
- /* Resolve IN_PLACE for this call without mutating persistent config.
2917
- Writing the IN_PLACE closure variable here leaks under setConfig(),
2918
- where _parseConfig is skipped on later calls: a single string call would
2919
- disable in-place mode for every subsequent node call, returning a
2920
- sanitized copy while leaving the caller's node — which in-place callers
2921
- keep using and whose return value they ignore — unsanitized. REPORT-2. */
3081
+ /* Resolved per call: setConfig() skips _parseConfig, so writing IN_PLACE
3082
+ here would leak into later calls (REPORT-2). */
2922
3083
  const inPlace = IN_PLACE && typeof dirty !== 'string' && _isNode(dirty);
3084
+ /* IN_PLACE: the caller's nodes before any hook or custom element can run,
3085
+ and the visit tracking of an outer call this one may be nested in */
3086
+ let inPlaceNodes: Node[] = [];
3087
+ const outerVisited = IN_PLACE_VISITED;
2923
3088
 
2924
3089
  if (inPlace) {
2925
- /* Declarative-partial-updates / streaming pre-pass: sever every patch
2926
- linkage across the live tree BEFORE the walk, so no patch can fire
2927
- mid-walk and inject into an already-processed region. Runs first, so
2928
- it also covers the forbidden/clobbered roots that throw below. */
2929
3090
  _neutralizePatchLinkage(dirty as Node);
2930
3091
 
2931
- /* Do some early pre-sanitization to avoid unsafe root nodes.
2932
- Read nodeName through the cached prototype getter — a clobbering
2933
- child named "nodeName" on the form root would otherwise shadow
2934
- the property and let this check skip the root-allowlist
2935
- validation entirely. */
3092
+ /* The root must be an allowed tag and not clobbered: a parentless root
3093
+ cannot be removed, so it would come back unsanitized. Its name is read
3094
+ through the cached getter, a clobbering child could shadow it. */
2936
3095
  const nn = _readNodeName(dirty as Node);
2937
3096
  if (typeof nn === 'string') {
2938
3097
  const tagName = transformCaseFunc(nn);
2939
3098
  if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) {
2940
- /* Fail closed on a live root: neutralize handlers/children before
2941
- throwing, exactly as the mid-walk abort path does. */
2942
3099
  _neutralizeRoot(dirty as Node);
2943
-
2944
3100
  throw typeErrorCreate(
2945
3101
  'root node is forbidden and cannot be sanitized in-place'
2946
3102
  );
2947
3103
  }
2948
3104
  }
2949
3105
 
2950
- /* Pre-flight the root through _isClobbered. The iterator-driven
2951
- removal path can not detach a parent-less root: _forceRemove
2952
- falls through to Element.prototype.remove(), which per spec
2953
- is a no-op on a node with no parent. A clobbered root would
2954
- then survive the main loop with its attributes uninspected,
2955
- because _sanitizeAttributes early-returns on _isClobbered. The
2956
- result would be an attacker-controlled form, complete with any
2957
- event-handler attributes the caller passed in, handed back to
2958
- the application unsanitized. Refuse to sanitize such a root
2959
- the same way we refuse a forbidden tag. GHSA-r47g-fvhr-h676. */
2960
3106
  if (_isClobbered(dirty as Element)) {
2961
- /* Fail closed on a live clobbered root before throwing.
2962
- _neutralizeRoot's reads are clobber-safe (cached getters); the
2963
- form's non-clobbered descendants, e.g. an armed <img>, are scrubbed. */
2964
3107
  _neutralizeRoot(dirty as Node);
2965
-
2966
3108
  throw typeErrorCreate(
2967
3109
  'root node is clobbered and cannot be sanitized in-place'
2968
3110
  );
2969
3111
  }
2970
3112
 
2971
- /* Sanitize attached shadow roots before the main iterator runs.
2972
- The iterator does not descend into shadow trees. Same fail-closed
2973
- barrier as the main walk (campaign-3 F2): a custom-element reaction
2974
- inside a shadow root could abort this pre-pass before the walk runs,
2975
- which would otherwise leave the entire live tree unsanitized. */
2976
- try {
2977
- _sanitizeAttachedShadowRoots(dirty as Node);
2978
- } catch (error) {
2979
- _neutralizeRoot(dirty as Node);
2980
-
2981
- throw error;
2982
- }
3113
+ inPlaceNodes = _collectLiveNodes(dirty as Node);
3114
+ IN_PLACE_VISITED = setCreate();
2983
3115
  } else if (_isNode(dirty)) {
2984
3116
  /* If dirty is a DOM element, append to an empty document to avoid
2985
3117
  elements being stripped by the parser */
@@ -3043,85 +3175,41 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
3043
3175
  /* Get node iterator */
3044
3176
  const walkRoot: Node = inPlace ? (dirty as Node) : body;
3045
3177
 
3046
- /* Now start iterating over the created document.
3047
- The walk runs inside an exception barrier (campaign-3 F2): a re-entrant
3048
- engine/custom-element mutation can detach a node mid-walk so
3049
- `_forceRemove`'s parentless guard throws, aborting the loop. Without the
3050
- barrier the caller's in-place tree would be left half-sanitized with the
3051
- unvisited tail still armed. _createNodeIterator itself is inside the
3052
- barrier too: constructing the iterator dereferences the root's document,
3053
- and any failure there (e.g. an exotic/clobbered root) must still fail
3054
- closed rather than skip the neutralize. On any throw we fail closed -
3055
- strip the in-place root bare - then rethrow so the existing throw
3056
- contract is preserved. (String/DOM-copy paths never return the partial
3057
- body, so the propagating throw is already fail-closed there.) */
3178
+ /* If anything throws, an in-place tree is stripped bare before the error
3179
+ propagates, never left half-sanitized. */
3058
3180
  try {
3181
+ if (inPlace) {
3182
+ _sanitizeAttachedShadowRoots(dirty as Node);
3183
+ }
3184
+
3059
3185
  const nodeIterator = _createNodeIterator(walkRoot);
3060
3186
  while ((currentNode = nodeIterator.nextNode())) {
3061
- /* Sanitize tags and elements */
3062
3187
  _sanitizeElements(currentNode, walkRoot);
3063
-
3064
- /* Check attributes next */
3065
3188
  _sanitizeAttributes(currentNode, walkRoot);
3066
3189
 
3067
- /* Shadow DOM detected, sanitize it.
3068
- Realm-safe check (GHSA-hpcv-96wg-7vj8): nodeType-based detection
3069
- instead of instanceof, so foreign-realm <template>.content is
3070
- walked correctly. */
3190
+ // Template content (realm-safe nodeType check)
3071
3191
  if (_isDocumentFragment(currentNode.content)) {
3072
3192
  _sanitizeShadowDOM(currentNode.content);
3073
3193
  }
3074
3194
  }
3195
+
3196
+ if (inPlace) {
3197
+ _sanitizeUnvisitedNodes(dirty as Node, inPlaceNodes);
3198
+ }
3075
3199
  } catch (error) {
3076
3200
  if (inPlace) {
3077
3201
  _neutralizeRoot(dirty as Node);
3078
- /* Nodes _forceRemove'd earlier in the aborted walk are already
3079
- detached from the root, so _neutralizeRoot's subtree pass does not
3080
- reach them. Defuse them too, mirroring the success-path loop below. */
3081
- arrayForEach(DOMPurify.removed, (entry) => {
3082
- if (entry.element) {
3083
- _neutralizeSubtree(entry.element as Node);
3084
- }
3085
- });
3202
+ _neutralizeDiscarded(dirty as Node, inPlaceNodes);
3086
3203
  }
3087
3204
 
3088
3205
  throw error;
3206
+ } finally {
3207
+ IN_PLACE_VISITED = outerVisited;
3089
3208
  }
3090
3209
 
3091
- /* If we sanitized `dirty` in-place, return it. */
3092
3210
  if (inPlace) {
3093
- /* Fail-closed completion of the audit-5 F1 fix: every node removed from
3094
- the caller's live tree is detached but may still hold a queued
3095
- resource-event handler that fires in page scope after we return. The
3096
- move-hoist covers only disallowed-tag KEEP_CONTENT removals; strip the
3097
- non-allow-listed attributes off every other removed subtree (clobber,
3098
- mXSS, namespace, comments, KEEP_CONTENT:false, …) so those handlers are
3099
- cancelled before any event can fire. Runs synchronously, pre-return.
3100
-
3101
- The same pass also detects the one shape the attribute strip cannot
3102
- make safe: the in-place ROOT was itself force-removed during the walk
3103
- (a rawtext root killed by the LITERAL_TEXT_CLOSE probe, a clobbered
3104
- root, an mXSS-canary root, …). Such a root is detached and, for a
3105
- rawtext element, still carries a literal `</tag>`-bearing text payload
3106
- that `_neutralizeSubtree`'s attribute-only strip does not touch, so
3107
- re-serialising and re-parsing the returned node re-opens the markup.
3108
- There is no safe node to hand back, so fail closed - identical in
3109
- spirit to the parentless-root throw in `_forceRemove` and the
3110
- clobbered-root throw at the IN_PLACE entry. Covers every present and
3111
- future root-kill reason in one check, on both the attribute and text
3112
- axes. */
3113
- let rootWasRemoved = false;
3114
- arrayForEach(DOMPurify.removed, (entry) => {
3115
- if (entry.element) {
3116
- if (entry.element === dirty) {
3117
- rootWasRemoved = true;
3118
- }
3119
-
3120
- _neutralizeSubtree(entry.element as Node);
3121
- }
3122
- });
3123
-
3124
- if (rootWasRemoved) {
3211
+ // A removed root has no safe state to return
3212
+ if (_neutralizeDiscarded(dirty as Node, inPlaceNodes)) {
3125
3213
  throw typeErrorCreate(
3126
3214
  'a node selected for removal could not be safely returned; ' +
3127
3215
  'refusing to sanitize in place'
@@ -3220,6 +3308,20 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
3220
3308
 
3221
3309
  const lcTag = transformCaseFunc(tag);
3222
3310
  const lcName = transformCaseFunc(attr);
3311
+
3312
+ /* Apply the same value checks sanitize() applies to an attribute when no
3313
+ hook intervenes, so a value approved here is one sanitize() would also
3314
+ keep: comment / raw-text closers, animated hrefs, and the jQuery 3.0
3315
+ self-closing-tag pattern. */
3316
+ if (
3317
+ typeof value === 'string' &&
3318
+ (_isUnsafeAttributeValue(attr, value) ||
3319
+ (!ALLOW_SELF_CLOSE_IN_ATTR &&
3320
+ regExpTest(EXPRESSIONS.SELF_CLOSING_TAG, value)))
3321
+ ) {
3322
+ return false;
3323
+ }
3324
+
3223
3325
  return _isValidAttribute(lcTag, lcName, value);
3224
3326
  };
3225
3327