dompurify 3.4.11 → 3.4.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/purify.js CHANGED
@@ -1,4 +1,4 @@
1
- /*! @license DOMPurify 3.4.11 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.11/LICENSE */
1
+ /*! @license DOMPurify 3.4.13 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.13/LICENSE */
2
2
 
3
3
  (function (global, factory) {
4
4
  typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
@@ -317,7 +317,7 @@
317
317
  const text = freeze(['#text']);
318
318
 
319
319
  const html = freeze(['accept', 'action', 'align', 'alt', 'autocapitalize', 'autocomplete', 'autopictureinpicture', 'autoplay', 'background', 'bgcolor', 'border', 'capture', 'cellpadding', 'cellspacing', 'checked', 'cite', 'class', 'clear', 'color', 'cols', 'colspan', 'command', 'commandfor', 'controls', 'controlslist', 'coords', 'crossorigin', 'datetime', 'decoding', 'default', 'dir', 'disabled', 'disablepictureinpicture', 'disableremoteplayback', 'download', 'draggable', 'enctype', 'enterkeyhint', 'exportparts', 'face', 'for', 'headers', 'height', 'hidden', 'high', 'href', 'hreflang', 'id', 'inert', 'inputmode', 'integrity', 'ismap', 'kind', 'label', 'lang', 'list', 'loading', 'loop', 'low', 'max', 'maxlength', 'media', 'method', 'min', 'minlength', 'multiple', 'muted', 'name', 'nonce', 'noshade', 'novalidate', 'nowrap', 'open', 'optimum', 'part', 'pattern', 'placeholder', 'playsinline', 'popover', 'popovertarget', 'popovertargetaction', 'poster', 'preload', 'pubdate', 'radiogroup', 'readonly', 'rel', 'required', 'rev', 'reversed', 'role', 'rows', 'rowspan', 'spellcheck', 'scope', 'selected', 'shape', 'size', 'sizes', 'slot', 'span', 'srclang', 'start', 'src', 'srcset', 'step', 'style', 'summary', 'tabindex', 'title', 'translate', 'type', 'usemap', 'valign', 'value', 'width', 'wrap', 'xmlns']);
320
- const svg = freeze(['accent-height', 'accumulate', 'additive', 'alignment-baseline', 'amplitude', 'ascent', 'attributename', 'attributetype', 'azimuth', 'basefrequency', 'baseline-shift', 'begin', 'bias', 'by', 'class', 'clip', 'clippathunits', 'clip-path', 'clip-rule', 'color', 'color-interpolation', 'color-interpolation-filters', 'color-profile', 'color-rendering', 'cx', 'cy', 'd', 'dx', 'dy', 'diffuseconstant', 'direction', 'display', 'divisor', 'dur', 'edgemode', 'elevation', 'end', 'exponent', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'filterunits', 'flood-color', 'flood-opacity', 'font-family', 'font-size', 'font-size-adjust', 'font-stretch', 'font-style', 'font-variant', 'font-weight', 'fx', 'fy', 'g1', 'g2', 'glyph-name', 'glyphref', 'gradientunits', 'gradienttransform', 'height', 'href', 'id', 'image-rendering', 'in', 'in2', 'intercept', 'k', 'k1', 'k2', 'k3', 'k4', 'kerning', 'keypoints', 'keysplines', 'keytimes', 'lang', 'lengthadjust', 'letter-spacing', 'kernelmatrix', 'kernelunitlength', 'lighting-color', 'local', 'marker-end', 'marker-mid', 'marker-start', 'markerheight', 'markerunits', 'markerwidth', 'maskcontentunits', 'maskunits', 'max', 'mask', 'mask-type', 'media', 'method', 'mode', 'min', 'name', 'numoctaves', 'offset', 'operator', 'opacity', 'order', 'orient', 'orientation', 'origin', 'overflow', 'paint-order', 'path', 'pathlength', 'patterncontentunits', 'patterntransform', 'patternunits', 'points', 'preservealpha', 'preserveaspectratio', 'primitiveunits', 'r', 'rx', 'ry', 'radius', 'refx', 'refy', 'repeatcount', 'repeatdur', 'restart', 'result', 'rotate', 'scale', 'seed', 'shape-rendering', 'slope', 'specularconstant', 'specularexponent', 'spreadmethod', 'startoffset', 'stddeviation', 'stitchtiles', 'stop-color', 'stop-opacity', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke', 'stroke-width', 'style', 'surfacescale', 'systemlanguage', 'tabindex', 'tablevalues', 'targetx', 'targety', 'transform', 'transform-origin', 'text-anchor', 'text-decoration', 'text-rendering', 'textlength', 'type', 'u1', 'u2', 'unicode', 'values', 'viewbox', 'visibility', 'version', 'vert-adv-y', 'vert-origin-x', 'vert-origin-y', 'width', 'word-spacing', 'wrap', 'writing-mode', 'xchannelselector', 'ychannelselector', 'x', 'x1', 'x2', 'xmlns', 'y', 'y1', 'y2', 'z', 'zoomandpan']);
320
+ const svg = freeze(['accent-height', 'accumulate', 'additive', 'alignment-baseline', 'amplitude', 'ascent', 'attributename', 'attributetype', 'azimuth', 'basefrequency', 'baseline-shift', 'begin', 'bias', 'by', 'class', 'clip', 'clippathunits', 'clip-path', 'clip-rule', 'color', 'color-interpolation', 'color-interpolation-filters', 'color-profile', 'color-rendering', 'cx', 'cy', 'd', 'dx', 'dy', 'diffuseconstant', 'direction', 'display', 'divisor', 'dominant-baseline', 'dur', 'edgemode', 'elevation', 'end', 'exponent', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'filterunits', 'flood-color', 'flood-opacity', 'font-family', 'font-size', 'font-size-adjust', 'font-stretch', 'font-style', 'font-variant', 'font-weight', 'fx', 'fy', 'g1', 'g2', 'glyph-name', 'glyphref', 'gradientunits', 'gradienttransform', 'height', 'href', 'id', 'image-rendering', 'in', 'in2', 'intercept', 'k', 'k1', 'k2', 'k3', 'k4', 'kerning', 'keypoints', 'keysplines', 'keytimes', 'lang', 'lengthadjust', 'letter-spacing', 'kernelmatrix', 'kernelunitlength', 'lighting-color', 'local', 'marker-end', 'marker-mid', 'marker-start', 'markerheight', 'markerunits', 'markerwidth', 'maskcontentunits', 'maskunits', 'max', 'mask', 'mask-type', 'media', 'method', 'mode', 'min', 'name', 'numoctaves', 'offset', 'operator', 'opacity', 'order', 'orient', 'orientation', 'origin', 'overflow', 'paint-order', 'path', 'pathlength', 'patterncontentunits', 'patterntransform', 'patternunits', 'points', 'preservealpha', 'preserveaspectratio', 'primitiveunits', 'r', 'rx', 'ry', 'radius', 'refx', 'refy', 'repeatcount', 'repeatdur', 'restart', 'result', 'rotate', 'scale', 'seed', 'shape-rendering', 'slope', 'specularconstant', 'specularexponent', 'spreadmethod', 'startoffset', 'stddeviation', 'stitchtiles', 'stop-color', 'stop-opacity', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke', 'stroke-width', 'style', 'surfacescale', 'systemlanguage', 'tabindex', 'tablevalues', 'targetx', 'targety', 'transform', 'transform-origin', 'text-anchor', 'text-decoration', 'text-orientation', 'text-rendering', 'textlength', 'type', 'u1', 'u2', 'unicode', 'values', 'viewbox', 'visibility', 'version', 'vert-adv-y', 'vert-origin-x', 'vert-origin-y', 'width', 'word-spacing', 'wrap', 'writing-mode', 'xchannelselector', 'ychannelselector', 'x', 'x1', 'x2', 'xmlns', 'y', 'y1', 'y2', 'z', 'zoomandpan']);
321
321
  const mathMl = freeze(['accent', 'accentunder', 'align', 'bevelled', 'close', 'columnalign', 'columnlines', 'columnspacing', 'columnspan', 'denomalign', 'depth', 'dir', 'display', 'displaystyle', 'encoding', 'fence', 'frame', 'height', 'href', 'id', 'largeop', 'length', 'linethickness', 'lquote', 'lspace', 'mathbackground', 'mathcolor', 'mathsize', 'mathvariant', 'maxsize', 'minsize', 'movablelimits', 'notation', 'numalign', 'open', 'rowalign', 'rowlines', 'rowspacing', 'rowspan', 'rspace', 'rquote', 'scriptlevel', 'scriptminsize', 'scriptsizemultiplier', 'selection', 'separator', 'separators', 'stretchy', 'subscriptshift', 'supscriptshift', 'symmetric', 'voffset', 'width', 'xmlns']);
322
322
  const xml = freeze(['xlink:href', 'xml:id', 'xlink:title', 'xml:space', 'xmlns:xlink']);
323
323
 
@@ -430,7 +430,7 @@
430
430
  function createDOMPurify() {
431
431
  let window = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : getGlobal();
432
432
  const DOMPurify = root => createDOMPurify(root);
433
- DOMPurify.version = '3.4.11';
433
+ DOMPurify.version = '3.4.13';
434
434
  DOMPurify.removed = [];
435
435
  if (!window || !window.document || window.document.nodeType !== NODE_TYPE.document || !window.Element) {
436
436
  // Not running in a browser, provide a factory function
@@ -461,6 +461,7 @@
461
461
  const getAttributes = lookupGetter(ElementPrototype, 'attributes');
462
462
  const getNodeType = Node && Node.prototype ? lookupGetter(Node.prototype, 'nodeType') : null;
463
463
  const getNodeName = Node && Node.prototype ? lookupGetter(Node.prototype, 'nodeName') : null;
464
+ const getOwnerDocument = Node && Node.prototype ? lookupGetter(Node.prototype, 'ownerDocument') : null;
464
465
  // As per issue #47, the web-components registry is inherited by a
465
466
  // new document created via createHTMLDocument. As per the spec
466
467
  // (http://w3c.github.io/webcomponents/spec/custom/#creating-and-passing-registries)
@@ -1112,6 +1113,13 @@
1112
1113
  * @param root the in-place root to empty
1113
1114
  */
1114
1115
  const _neutralizeRoot = function _neutralizeRoot(root) {
1116
+ /* Strip every disallowed attribute (on* handlers included) off the whole
1117
+ subtree BEFORE detaching anything. Detaching first would hand back
1118
+ handler-bearing originals (e.g. an already-loading `<img onerror>`)
1119
+ whose queued resource event still fires in page scope after we throw.
1120
+ Clobber-safe reads; a doomed clobbered node's own attributes are
1121
+ irrelevant while its non-clobbered descendants are reached and scrubbed. */
1122
+ _neutralizeSubtree(root);
1115
1123
  const childNodes = getChildNodes(root);
1116
1124
  if (childNodes) {
1117
1125
  const snapshot = [];
@@ -1239,6 +1247,82 @@
1239
1247
  }
1240
1248
  }
1241
1249
  };
1250
+ /**
1251
+ * _neutralizePatchLinkage
1252
+ *
1253
+ * IN_PLACE entry pre-pass (declarative-partial-updates / streaming
1254
+ * hardening, https://github.com/WICG/declarative-partial-updates).
1255
+ *
1256
+ * The main walk strips patch linkage (`for`/`patchsrc`) and removes range
1257
+ * markers (PIs / markup comments) node-by-node, in document order, AS it
1258
+ * reaches each node. On a live in-place root that leaves a window: from the
1259
+ * moment the root is connected until the walk arrives at a given node, that
1260
+ * node's linkage is live. A patch applied on connection/stream can fire as
1261
+ * a microtask during the walk and inject or teleport an unsanitized DOM
1262
+ * range into a region the iterator has already passed and will not revisit,
1263
+ * so the post-return "tree is sanitized" contract is violated. Sweep the
1264
+ * whole tree once up front and sever every linkage before the walk begins,
1265
+ * closing that window.
1266
+ *
1267
+ * This CANNOT undo a patch that already fired before sanitize ran — that is
1268
+ * the irreducible "do not IN_PLACE a live-connected attacker tree" caveat —
1269
+ * but it closes everything from sanitize-start onward. Gated on SAFE_FOR_XML
1270
+ * to group with the rest of the declarative-partial-updates handling and
1271
+ * stay overridable, consistent with the codebase.
1272
+ *
1273
+ * Clobber-safe traversal (cached childNodes getter); per-node try/catch so a
1274
+ * clobbered root cannot defeat the sweep of its non-clobbered descendants.
1275
+ *
1276
+ * NOTE (pending real-Chrome confirmation, see test/declarative-patch-probe
1277
+ * .html Q1): this mirrors the existing policy of keeping `for` on
1278
+ * <label>/<output>. If the shipping feature can drive a patch through a
1279
+ * surviving `for`-on-label/output + `id` pair, this pre-pass and the
1280
+ * attribute check at _isBasicCustomElement's caller must additionally drop
1281
+ * that pair on the IN_PLACE path. Left as-is until the taxonomy is verified.
1282
+ *
1283
+ * @param root the in-place root to sweep
1284
+ */
1285
+ const _neutralizePatchLinkage = function _neutralizePatchLinkage(root) {
1286
+ if (!SAFE_FOR_XML) {
1287
+ return;
1288
+ }
1289
+ const stack = [root];
1290
+ while (stack.length > 0) {
1291
+ const node = stack.pop();
1292
+ const nodeType = getNodeType ? getNodeType(node) : node.nodeType;
1293
+ /* Remove range markers (the target side of a patch linkage): every
1294
+ processing instruction, and any markup-bearing comment. */
1295
+ if (nodeType === NODE_TYPE.processingInstruction || nodeType === NODE_TYPE.comment && regExpTest(COMMENT_MARKUP_PROBE, node.data)) {
1296
+ try {
1297
+ remove(node);
1298
+ } catch (_) {
1299
+ /* Best-effort */
1300
+ }
1301
+ continue;
1302
+ }
1303
+ /* Strip patch-source attributes (the source side) off elements. */
1304
+ if (nodeType === NODE_TYPE.element) {
1305
+ const element = node;
1306
+ const lcTag = transformCaseFunc(getNodeName ? getNodeName(node) : node.nodeName);
1307
+ try {
1308
+ if (element.hasAttribute && element.hasAttribute('patchsrc')) {
1309
+ element.removeAttribute('patchsrc');
1310
+ }
1311
+ if (element.hasAttribute && element.hasAttribute('for') && lcTag !== 'label' && lcTag !== 'output') {
1312
+ element.removeAttribute('for');
1313
+ }
1314
+ } catch (_) {
1315
+ /* Clobbered removeAttribute/hasAttribute on a doomed node — ignore */
1316
+ }
1317
+ }
1318
+ const childNodes = getChildNodes(node);
1319
+ if (childNodes) {
1320
+ for (let i = childNodes.length - 1; i >= 0; --i) {
1321
+ stack.push(childNodes[i]);
1322
+ }
1323
+ }
1324
+ }
1325
+ };
1242
1326
  /**
1243
1327
  * _initDocument
1244
1328
  *
@@ -1296,7 +1380,17 @@
1296
1380
  * @return The created NodeIterator
1297
1381
  */
1298
1382
  const _createNodeIterator = function _createNodeIterator(root) {
1299
- return createNodeIterator.call(root.ownerDocument || root, root,
1383
+ /* Read ownerDocument through the cached Node.prototype getter, never the
1384
+ direct property. HTMLFormElement has [LegacyOverrideBuiltIns], so a
1385
+ clobbering child (<input name="ownerDocument"> or a form-associated
1386
+ external input) shadows the prototype getter and makes a direct read
1387
+ return that <input>. createNodeIterator.call(<input>, ...) then throws
1388
+ "Illegal invocation", and on the IN_PLACE path that throw lands before
1389
+ the walk's fail-closed barrier - leaving the caller's live tree, with
1390
+ any already-armed handler in it, un-neutralized. The cached getter
1391
+ returns the real Document regardless of the clobber. */
1392
+ const doc = getOwnerDocument ? getOwnerDocument(root) : root.ownerDocument;
1393
+ return createNodeIterator.call(doc || root, root,
1300
1394
  // eslint-disable-next-line no-bitwise
1301
1395
  NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT | NodeFilter.SHOW_PROCESSING_INSTRUCTION | NodeFilter.SHOW_CDATA_SECTION, null);
1302
1396
  };
@@ -1336,7 +1430,11 @@
1336
1430
  const _scrubTemplateExpressions2 = function _scrubTemplateExpressions(node) {
1337
1431
  var _node$querySelectorAl;
1338
1432
  node.normalize();
1339
- const walker = createNodeIterator.call(node.ownerDocument || node, node,
1433
+ /* Clobber-safe ownerDocument read, same reasoning as _createNodeIterator:
1434
+ under SAFE_FOR_TEMPLATES this runs on the live IN_PLACE root, which may
1435
+ carry a form-named-getter override of ownerDocument. */
1436
+ const doc = getOwnerDocument ? getOwnerDocument(node) : node.ownerDocument;
1437
+ const walker = createNodeIterator.call(doc || node, node,
1340
1438
  // eslint-disable-next-line no-bitwise
1341
1439
  NodeFilter.SHOW_TEXT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_CDATA_SECTION | NodeFilter.SHOW_PROCESSING_INSTRUCTION, null);
1342
1440
  let currentNode = walker.nextNode();
@@ -1489,15 +1587,21 @@
1489
1587
  /**
1490
1588
  * Handle a node whose tag is forbidden or not allowlisted: keep
1491
1589
  * allowed custom elements (false return exits _sanitizeElements
1492
- * early - namespace/fallback checks and the afterSanitizeElements
1493
- * hook are intentionally skipped for kept custom elements), else
1494
- * hoist content per KEEP_CONTENT and remove.
1590
+ * early - the namespace and fallback-tag removal checks are
1591
+ * intentionally skipped for kept custom elements), else hoist
1592
+ * content per KEEP_CONTENT and remove.
1593
+ *
1594
+ * A kept custom element is the ONLY case in which this function
1595
+ * returns false, so the caller uses that return value to run the
1596
+ * afterSanitizeElements hook on the kept element and keep the
1597
+ * element-hook lifecycle consistent with normal allowlisted
1598
+ * elements (GHSA-c2j3-45gr-mqc4).
1495
1599
  *
1496
1600
  * @param currentNode the disallowed node
1497
1601
  * @param tagName the node's transformCaseFunc'd tag name
1498
1602
  * @return true if the node was removed, false if kept
1499
1603
  */
1500
- const _sanitizeDisallowedNode = function _sanitizeDisallowedNode(currentNode, tagName) {
1604
+ const _sanitizeDisallowedNode = function _sanitizeDisallowedNode(currentNode, tagName, root) {
1501
1605
  /* Check if we have a custom element to handle */
1502
1606
  if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName)) {
1503
1607
  if (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)) {
@@ -1520,27 +1624,26 @@
1520
1624
  const childNodes = getChildNodes(currentNode);
1521
1625
  if (childNodes && parentNode) {
1522
1626
  const childCount = childNodes.length;
1523
- /* In-place: hoist the *original* children so the iterator visits
1524
- and sanitises them through the same allowlist pass as every other
1525
- node. The caller built the tree in the live document, so the
1526
- originals carry already-queued resource events (`<img onerror>`,
1527
- `<video>`/`<audio>` error, lazy/`onload`, …); cloning would leave
1528
- those originals detached but still armed, firing in page scope
1529
- while the returned tree looked clean. Moving is safe in-place: the
1530
- root is pre-validated as an allowed tag and so is never the node
1531
- being removed, which keeps `parentNode` inside the iterator root
1532
- and the relocated child inside the serialised tree.
1533
- Otherwise (string / DOM-copy paths): clone. The iterator is rooted
1534
- at and the result serialised from `body`, so a restrictive
1535
- ALLOWED_TAGS that removes `body` itself must leave its content in
1536
- place, which only cloning does; and those paths parse into an
1537
- inert document, so their discarded originals never had a queued
1538
- event to neutralise.
1627
+ /* Hoist by moving each child up one level rather than deep-cloning
1628
+ it. Moving transfers every descendant exactly once, so a chain of
1629
+ nested disallowed elements costs O(n) instead of the O(n^2) that
1630
+ re-cloning the shrinking subtree at each level produced; it also
1631
+ empties the removed original, so `DOMPurify.removed` no longer
1632
+ pins whole subtrees. Moving preserves the in-place guarantee too:
1633
+ an original carrying already-queued resource events (`<img
1634
+ onerror>`, `<video>`/`<audio>` error, lazy/`onload`, …) is
1635
+ relocated and sanitised rather than left detached but still armed.
1636
+ The sole case that must clone is removing the walk root itself.
1637
+ The result is serialised from the root's subtree, so a restrictive
1638
+ ALLOWED_TAGS that strips the root (`body` on the string path) must
1639
+ leave the content inside it, which only cloning does. In IN_PLACE
1640
+ the root is pre-validated as an allowed tag and so is never removed
1641
+ here, so that path always takes the move branch.
1539
1642
  `childNodes` is live; a tail-to-head walk keeps `childNodes[i]`
1540
1643
  valid whether we move (drops the trailing entry) or clone (leaves
1541
1644
  the list intact). */
1542
1645
  for (let i = childCount - 1; i >= 0; --i) {
1543
- const hoisted = IN_PLACE ? childNodes[i] : cloneNode(childNodes[i], true);
1646
+ const hoisted = currentNode === root ? cloneNode(childNodes[i], true) : childNodes[i];
1544
1647
  parentNode.insertBefore(hoisted, getNextSibling(currentNode));
1545
1648
  }
1546
1649
  }
@@ -1548,6 +1651,26 @@
1548
1651
  _forceRemove(currentNode);
1549
1652
  return true;
1550
1653
  };
1654
+ /**
1655
+ * Fork a hook-mutable allowlist off its shared binding the first time a
1656
+ * (possibly lazily-installed) uponSanitize* hook is about to see it, so the
1657
+ * hook cannot widen the per-instance default or the setConfig binding by
1658
+ * reference and leak past the call. Returns the set unchanged once it is
1659
+ * already call-local, so repeated calls across elements are idempotent.
1660
+ *
1661
+ * @param hookList the uponSanitize* hook array for this event
1662
+ * @param set the current ALLOWED_TAGS / ALLOWED_ATTR binding
1663
+ * @param defaultSet the per-instance DEFAULT_ALLOWED_* constant
1664
+ * @param setConfigSet the captured setConfig() binding, or null
1665
+ * @return a call-local clone if a hook is present and set is still shared,
1666
+ * else set unchanged
1667
+ */
1668
+ const _forkSharedAllowlist = function _forkSharedAllowlist(hookList, set, defaultSet, setConfigSet) {
1669
+ if (hookList.length === 0) {
1670
+ return set;
1671
+ }
1672
+ return set === defaultSet || set === setConfigSet ? clone(set) : set;
1673
+ };
1551
1674
  /**
1552
1675
  * _sanitizeElements
1553
1676
  *
@@ -1557,9 +1680,20 @@
1557
1680
  * @param currentNode to check for permission to exist
1558
1681
  * @return true if node was killed, false if left alive
1559
1682
  */
1560
- const _sanitizeElements = function _sanitizeElements(currentNode) {
1683
+ // eslint-disable-next-line complexity
1684
+ const _sanitizeElements = function _sanitizeElements(currentNode, root) {
1561
1685
  /* Execute a hook if present */
1562
1686
  _executeHooks(hooks.beforeSanitizeElements, currentNode, null);
1687
+ /* A hook may have detached the node - treat it as removed (see the
1688
+ detached-node comment after the uponSanitizeElement hook below). On
1689
+ the IN_PLACE path, neutralize the detached subtree first so a queued
1690
+ resource handler on it cannot fire in page scope after we return. */
1691
+ if (currentNode !== root && getParentNode(currentNode) === null) {
1692
+ if (IN_PLACE) {
1693
+ _neutralizeSubtree(currentNode);
1694
+ }
1695
+ return true;
1696
+ }
1563
1697
  /* Check if element is clobbered or can clobber */
1564
1698
  if (_isClobbered(currentNode)) {
1565
1699
  _forceRemove(currentNode);
@@ -1567,11 +1701,47 @@
1567
1701
  }
1568
1702
  /* Now let's check the element's type and name */
1569
1703
  const tagName = transformCaseFunc(getNodeName ? getNodeName(currentNode) : currentNode.nodeName);
1704
+ /* Close the pre-walk clone-guard's timing gap: an uponSanitizeElement
1705
+ hook may have been installed after that guard sampled the hook arrays
1706
+ (e.g. lazily from beforeSanitizeElements), leaving ALLOWED_TAGS still
1707
+ aliasing a shared binding that a widening hook would mutate by
1708
+ reference. Fork it before exposing it to the hook. */
1709
+ ALLOWED_TAGS = _forkSharedAllowlist(hooks.uponSanitizeElement, ALLOWED_TAGS, DEFAULT_ALLOWED_TAGS, SET_CONFIG_ALLOWED_TAGS);
1570
1710
  /* Execute a hook if present */
1571
1711
  _executeHooks(hooks.uponSanitizeElement, currentNode, {
1572
1712
  tagName,
1573
1713
  allowedTags: ALLOWED_TAGS
1574
1714
  });
1715
+ /* A hook may have detached the node from the tree — a long-standing
1716
+ user pattern (issue #469; draw.io-style foreignObject filtering).
1717
+ Per the cached, unclobberable parentNode getter the node is
1718
+ genuinely out of the tree, so it can reach neither the serialized
1719
+ output nor an IN_PLACE live tree; treat it as removed and stop
1720
+ processing it. Without this guard, the unsafe-node / namespace
1721
+ checks below would call _forceRemove on a parentless node and hit
1722
+ the REPORT-3 fail-closed throw — which exists for nodes DOMPurify
1723
+ wants gone but *cannot* detach (clobbered / parentless roots), the
1724
+ opposite of a node that is already safely gone. The walk root is
1725
+ exempt: a detached IN_PLACE root is legitimate input and must still
1726
+ be fully sanitized, and a kill-decision on it must keep hitting the
1727
+ REPORT-3 throw. Nodes detached by hooks stay the hook's
1728
+ responsibility for placement: they are not recorded in
1729
+ DOMPurify.removed, so the post-walk IN_PLACE pass (which iterates
1730
+ DOMPurify.removed) does not reach them. But a hook-detached subtree
1731
+ can still hold a queued resource-event handler - e.g. an <img onload>
1732
+ that began loading when the caller built the live tree - which fires
1733
+ in page scope after sanitize returns even though the handler never
1734
+ reached the returned tree. That is the audit-5 F1 hazard, and the
1735
+ documented node.remove() hook pattern walks straight into it. So on
1736
+ the IN_PLACE path we neutralize the detached subtree inline here,
1737
+ stripping its non-allow-listed attributes before returning, exactly
1738
+ as the post-walk pass does for _forceRemove'd subtrees. */
1739
+ if (currentNode !== root && getParentNode(currentNode) === null) {
1740
+ if (IN_PLACE) {
1741
+ _neutralizeSubtree(currentNode);
1742
+ }
1743
+ return true;
1744
+ }
1575
1745
  /* Remove mXSS vectors, processing instructions and risky comments */
1576
1746
  if (_isUnsafeNode(currentNode, tagName)) {
1577
1747
  _forceRemove(currentNode);
@@ -1579,7 +1749,22 @@
1579
1749
  }
1580
1750
  /* Remove element if anything forbids its presence */
1581
1751
  if (FORBID_TAGS[tagName] || !(EXTRA_ELEMENT_HANDLING.tagCheck instanceof Function && EXTRA_ELEMENT_HANDLING.tagCheck(tagName)) && !ALLOWED_TAGS[tagName]) {
1582
- return _sanitizeDisallowedNode(currentNode, tagName);
1752
+ const removed = _sanitizeDisallowedNode(currentNode, tagName, root);
1753
+ /* A false return means the node is a custom element kept via
1754
+ CUSTOM_ELEMENT_HANDLING - the only keep path through
1755
+ _sanitizeDisallowedNode. Run afterSanitizeElements on it so the
1756
+ element-hook lifecycle matches normal allowlisted elements: a
1757
+ security policy applied in this hook (e.g. stripping an attribute
1758
+ from every surviving element) must not silently skip kept custom
1759
+ elements (GHSA-c2j3-45gr-mqc4). This mirrors the normal-element
1760
+ tail below - the hook runs, then the walker's subsequent
1761
+ _sanitizeAttributes pass sanitizes the element's attributes. The
1762
+ deliberately skipped namespace and fallback-tag removal checks stay
1763
+ skipped; they are removal decisions, not the hook contract. */
1764
+ if (removed === false) {
1765
+ _executeHooks(hooks.afterSanitizeElements, currentNode, null);
1766
+ }
1767
+ return removed;
1583
1768
  }
1584
1769
  /* Check whether element has a valid namespace.
1585
1770
  Realm-safe check (GHSA-hpcv-96wg-7vj8): use the cached Node.prototype
@@ -1626,6 +1811,33 @@
1626
1811
  if (FORBID_ATTR[lcName]) {
1627
1812
  return false;
1628
1813
  }
1814
+ /* Reject declarative-partial-updates patch-linkage attributes
1815
+ (https://github.com/WICG/declarative-partial-updates).
1816
+ Empirical note (Chrome 150, verified — see
1817
+ test/declarative-patch-probe-v3.html): expansion is NOT applied after
1818
+ sanitization. For the string path it fires during sanitize()'s own
1819
+ parse, so the walk sees and sanitizes the fully materialized expanded
1820
+ tree — teleports into MathML/SVG integration points included; a
1821
+ weaponized `<template for>`->`<img onerror>` comes back with the handler
1822
+ stripped. For the IN_PLACE path it fires on connection, before the walk.
1823
+ Either way DOMPurify is NOT blind to the patch.
1824
+ This removal is therefore defense-in-depth rather than the sole barrier:
1825
+ it prevents live linkage from surviving into the OUTPUT and re-expanding
1826
+ in the caller's context, and keeps behaviour deterministic if a future
1827
+ engine defers expansion. `for` is legitimate only on <label>/<output>;
1828
+ anywhere else (notably <template for>) it links the element to a patch
1829
+ target and teleports or removes an arbitrary DOM range by id/marker name.
1830
+ `patchsrc` fetches remote markup and is treated as a script-loading
1831
+ mechanism (CSP). Gated on SAFE_FOR_XML so the removal groups with the
1832
+ other structural-threat checks and stays overridable, consistent with
1833
+ the rest of the codebase. PI range markers are already removed by
1834
+ _isUnsafeNode. */
1835
+ if (SAFE_FOR_XML && lcName === 'patchsrc') {
1836
+ return false;
1837
+ }
1838
+ if (SAFE_FOR_XML && lcName === 'for' && lcTag !== 'label' && lcTag !== 'output') {
1839
+ return false;
1840
+ }
1629
1841
  /* Make sure attribute cannot clobber */
1630
1842
  if (SANITIZE_DOM && (lcName === 'id' || lcName === 'name') && (value in document || value in formElement)) {
1631
1843
  return false;
@@ -1742,6 +1954,9 @@
1742
1954
  if (!attributes || _isClobbered(currentNode)) {
1743
1955
  return;
1744
1956
  }
1957
+ /* Same lazy-install guard as uponSanitizeElement (see there): fork the
1958
+ attribute allowlist off its shared binding before a hook can see it. */
1959
+ ALLOWED_ATTR = _forkSharedAllowlist(hooks.uponSanitizeAttribute, ALLOWED_ATTR, DEFAULT_ALLOWED_ATTR, SET_CONFIG_ALLOWED_ATTR);
1745
1960
  const hookEvent = {
1746
1961
  attrName: '',
1747
1962
  attrValue: '',
@@ -1835,7 +2050,7 @@
1835
2050
  /* Execute a hook if present */
1836
2051
  _executeHooks(hooks.uponSanitizeShadowNode, shadowNode, null);
1837
2052
  /* Sanitize tags and elements */
1838
- _sanitizeElements(shadowNode);
2053
+ _sanitizeElements(shadowNode, fragment);
1839
2054
  /* Check attributes next */
1840
2055
  _sanitizeAttributes(shadowNode);
1841
2056
  /* Deep shadow DOM detected.
@@ -2026,6 +2241,11 @@
2026
2241
  keep using and whose return value they ignore — unsanitized. REPORT-2. */
2027
2242
  const inPlace = IN_PLACE && typeof dirty !== 'string' && _isNode(dirty);
2028
2243
  if (inPlace) {
2244
+ /* Declarative-partial-updates / streaming pre-pass: sever every patch
2245
+ linkage across the live tree BEFORE the walk, so no patch can fire
2246
+ mid-walk and inject into an already-processed region. Runs first, so
2247
+ it also covers the forbidden/clobbered roots that throw below. */
2248
+ _neutralizePatchLinkage(dirty);
2029
2249
  /* Do some early pre-sanitization to avoid unsafe root nodes.
2030
2250
  Read nodeName through the cached prototype getter — a clobbering
2031
2251
  child named "nodeName" on the form root would otherwise shadow
@@ -2035,6 +2255,9 @@
2035
2255
  if (typeof nn === 'string') {
2036
2256
  const tagName = transformCaseFunc(nn);
2037
2257
  if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) {
2258
+ /* Fail closed on a live root: neutralize handlers/children before
2259
+ throwing, exactly as the mid-walk abort path does. */
2260
+ _neutralizeRoot(dirty);
2038
2261
  throw typeErrorCreate('root node is forbidden and cannot be sanitized in-place');
2039
2262
  }
2040
2263
  }
@@ -2049,6 +2272,10 @@
2049
2272
  the application unsanitized. Refuse to sanitize such a root
2050
2273
  the same way we refuse a forbidden tag. GHSA-r47g-fvhr-h676. */
2051
2274
  if (_isClobbered(dirty)) {
2275
+ /* Fail closed on a live clobbered root before throwing.
2276
+ _neutralizeRoot's reads are clobber-safe (cached getters); the
2277
+ form's non-clobbered descendants, e.g. an armed <img>, are scrubbed. */
2278
+ _neutralizeRoot(dirty);
2052
2279
  throw typeErrorCreate('root node is clobbered and cannot be sanitized in-place');
2053
2280
  }
2054
2281
  /* Sanitize attached shadow roots before the main iterator runs.
@@ -2101,20 +2328,24 @@
2101
2328
  _forceRemove(body.firstChild);
2102
2329
  }
2103
2330
  /* Get node iterator */
2104
- const nodeIterator = _createNodeIterator(inPlace ? dirty : body);
2331
+ const walkRoot = inPlace ? dirty : body;
2105
2332
  /* Now start iterating over the created document.
2106
2333
  The walk runs inside an exception barrier (campaign-3 F2): a re-entrant
2107
2334
  engine/custom-element mutation can detach a node mid-walk so
2108
2335
  `_forceRemove`'s parentless guard throws, aborting the loop. Without the
2109
2336
  barrier the caller's in-place tree would be left half-sanitized with the
2110
- unvisited tail still armed. On any throw we fail closed — strip the
2111
- in-place root bare then rethrow so the existing throw contract is
2112
- preserved. (String/DOM-copy paths never return the partial body, so the
2113
- propagating throw is already fail-closed there.) */
2337
+ unvisited tail still armed. _createNodeIterator itself is inside the
2338
+ barrier too: constructing the iterator dereferences the root's document,
2339
+ and any failure there (e.g. an exotic/clobbered root) must still fail
2340
+ closed rather than skip the neutralize. On any throw we fail closed -
2341
+ strip the in-place root bare - then rethrow so the existing throw
2342
+ contract is preserved. (String/DOM-copy paths never return the partial
2343
+ body, so the propagating throw is already fail-closed there.) */
2114
2344
  try {
2345
+ const nodeIterator = _createNodeIterator(walkRoot);
2115
2346
  while (currentNode = nodeIterator.nextNode()) {
2116
2347
  /* Sanitize tags and elements */
2117
- _sanitizeElements(currentNode);
2348
+ _sanitizeElements(currentNode, walkRoot);
2118
2349
  /* Check attributes next */
2119
2350
  _sanitizeAttributes(currentNode);
2120
2351
  /* Shadow DOM detected, sanitize it.
@@ -2128,6 +2359,14 @@
2128
2359
  } catch (error) {
2129
2360
  if (inPlace) {
2130
2361
  _neutralizeRoot(dirty);
2362
+ /* Nodes _forceRemove'd earlier in the aborted walk are already
2363
+ detached from the root, so _neutralizeRoot's subtree pass does not
2364
+ reach them. Defuse them too, mirroring the success-path loop below. */
2365
+ arrayForEach(DOMPurify.removed, entry => {
2366
+ if (entry.element) {
2367
+ _neutralizeSubtree(entry.element);
2368
+ }
2369
+ });
2131
2370
  }
2132
2371
  throw error;
2133
2372
  }