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.
@@ -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 _arrayLikeToArray(r, a) {
4
4
  (null == a || a > r.length) && (a = r.length);
@@ -311,7 +311,7 @@ const mathMlDisallowed = freeze(['maction', 'maligngroup', 'malignmark', 'mlongd
311
311
  const text = freeze(['#text']);
312
312
 
313
313
  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']);
314
- 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']);
314
+ 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']);
315
315
  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']);
316
316
  const xml = freeze(['xlink:href', 'xml:id', 'xlink:title', 'xml:space', 'xmlns:xlink']);
317
317
 
@@ -424,7 +424,7 @@ const _resolveSetOption = function _resolveSetOption(cfg, key, fallback, options
424
424
  function createDOMPurify() {
425
425
  let window = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : getGlobal();
426
426
  const DOMPurify = root => createDOMPurify(root);
427
- DOMPurify.version = '3.4.11';
427
+ DOMPurify.version = '3.4.13';
428
428
  DOMPurify.removed = [];
429
429
  if (!window || !window.document || window.document.nodeType !== NODE_TYPE.document || !window.Element) {
430
430
  // Not running in a browser, provide a factory function
@@ -455,6 +455,7 @@ function createDOMPurify() {
455
455
  const getAttributes = lookupGetter(ElementPrototype, 'attributes');
456
456
  const getNodeType = Node && Node.prototype ? lookupGetter(Node.prototype, 'nodeType') : null;
457
457
  const getNodeName = Node && Node.prototype ? lookupGetter(Node.prototype, 'nodeName') : null;
458
+ const getOwnerDocument = Node && Node.prototype ? lookupGetter(Node.prototype, 'ownerDocument') : null;
458
459
  // As per issue #47, the web-components registry is inherited by a
459
460
  // new document created via createHTMLDocument. As per the spec
460
461
  // (http://w3c.github.io/webcomponents/spec/custom/#creating-and-passing-registries)
@@ -1106,6 +1107,13 @@ function createDOMPurify() {
1106
1107
  * @param root the in-place root to empty
1107
1108
  */
1108
1109
  const _neutralizeRoot = function _neutralizeRoot(root) {
1110
+ /* Strip every disallowed attribute (on* handlers included) off the whole
1111
+ subtree BEFORE detaching anything. Detaching first would hand back
1112
+ handler-bearing originals (e.g. an already-loading `<img onerror>`)
1113
+ whose queued resource event still fires in page scope after we throw.
1114
+ Clobber-safe reads; a doomed clobbered node's own attributes are
1115
+ irrelevant while its non-clobbered descendants are reached and scrubbed. */
1116
+ _neutralizeSubtree(root);
1109
1117
  const childNodes = getChildNodes(root);
1110
1118
  if (childNodes) {
1111
1119
  const snapshot = [];
@@ -1233,6 +1241,82 @@ function createDOMPurify() {
1233
1241
  }
1234
1242
  }
1235
1243
  };
1244
+ /**
1245
+ * _neutralizePatchLinkage
1246
+ *
1247
+ * IN_PLACE entry pre-pass (declarative-partial-updates / streaming
1248
+ * hardening, https://github.com/WICG/declarative-partial-updates).
1249
+ *
1250
+ * The main walk strips patch linkage (`for`/`patchsrc`) and removes range
1251
+ * markers (PIs / markup comments) node-by-node, in document order, AS it
1252
+ * reaches each node. On a live in-place root that leaves a window: from the
1253
+ * moment the root is connected until the walk arrives at a given node, that
1254
+ * node's linkage is live. A patch applied on connection/stream can fire as
1255
+ * a microtask during the walk and inject or teleport an unsanitized DOM
1256
+ * range into a region the iterator has already passed and will not revisit,
1257
+ * so the post-return "tree is sanitized" contract is violated. Sweep the
1258
+ * whole tree once up front and sever every linkage before the walk begins,
1259
+ * closing that window.
1260
+ *
1261
+ * This CANNOT undo a patch that already fired before sanitize ran — that is
1262
+ * the irreducible "do not IN_PLACE a live-connected attacker tree" caveat —
1263
+ * but it closes everything from sanitize-start onward. Gated on SAFE_FOR_XML
1264
+ * to group with the rest of the declarative-partial-updates handling and
1265
+ * stay overridable, consistent with the codebase.
1266
+ *
1267
+ * Clobber-safe traversal (cached childNodes getter); per-node try/catch so a
1268
+ * clobbered root cannot defeat the sweep of its non-clobbered descendants.
1269
+ *
1270
+ * NOTE (pending real-Chrome confirmation, see test/declarative-patch-probe
1271
+ * .html Q1): this mirrors the existing policy of keeping `for` on
1272
+ * <label>/<output>. If the shipping feature can drive a patch through a
1273
+ * surviving `for`-on-label/output + `id` pair, this pre-pass and the
1274
+ * attribute check at _isBasicCustomElement's caller must additionally drop
1275
+ * that pair on the IN_PLACE path. Left as-is until the taxonomy is verified.
1276
+ *
1277
+ * @param root the in-place root to sweep
1278
+ */
1279
+ const _neutralizePatchLinkage = function _neutralizePatchLinkage(root) {
1280
+ if (!SAFE_FOR_XML) {
1281
+ return;
1282
+ }
1283
+ const stack = [root];
1284
+ while (stack.length > 0) {
1285
+ const node = stack.pop();
1286
+ const nodeType = getNodeType ? getNodeType(node) : node.nodeType;
1287
+ /* Remove range markers (the target side of a patch linkage): every
1288
+ processing instruction, and any markup-bearing comment. */
1289
+ if (nodeType === NODE_TYPE.processingInstruction || nodeType === NODE_TYPE.comment && regExpTest(COMMENT_MARKUP_PROBE, node.data)) {
1290
+ try {
1291
+ remove(node);
1292
+ } catch (_) {
1293
+ /* Best-effort */
1294
+ }
1295
+ continue;
1296
+ }
1297
+ /* Strip patch-source attributes (the source side) off elements. */
1298
+ if (nodeType === NODE_TYPE.element) {
1299
+ const element = node;
1300
+ const lcTag = transformCaseFunc(getNodeName ? getNodeName(node) : node.nodeName);
1301
+ try {
1302
+ if (element.hasAttribute && element.hasAttribute('patchsrc')) {
1303
+ element.removeAttribute('patchsrc');
1304
+ }
1305
+ if (element.hasAttribute && element.hasAttribute('for') && lcTag !== 'label' && lcTag !== 'output') {
1306
+ element.removeAttribute('for');
1307
+ }
1308
+ } catch (_) {
1309
+ /* Clobbered removeAttribute/hasAttribute on a doomed node — ignore */
1310
+ }
1311
+ }
1312
+ const childNodes = getChildNodes(node);
1313
+ if (childNodes) {
1314
+ for (let i = childNodes.length - 1; i >= 0; --i) {
1315
+ stack.push(childNodes[i]);
1316
+ }
1317
+ }
1318
+ }
1319
+ };
1236
1320
  /**
1237
1321
  * _initDocument
1238
1322
  *
@@ -1290,7 +1374,17 @@ function createDOMPurify() {
1290
1374
  * @return The created NodeIterator
1291
1375
  */
1292
1376
  const _createNodeIterator = function _createNodeIterator(root) {
1293
- return createNodeIterator.call(root.ownerDocument || root, root,
1377
+ /* Read ownerDocument through the cached Node.prototype getter, never the
1378
+ direct property. HTMLFormElement has [LegacyOverrideBuiltIns], so a
1379
+ clobbering child (<input name="ownerDocument"> or a form-associated
1380
+ external input) shadows the prototype getter and makes a direct read
1381
+ return that <input>. createNodeIterator.call(<input>, ...) then throws
1382
+ "Illegal invocation", and on the IN_PLACE path that throw lands before
1383
+ the walk's fail-closed barrier - leaving the caller's live tree, with
1384
+ any already-armed handler in it, un-neutralized. The cached getter
1385
+ returns the real Document regardless of the clobber. */
1386
+ const doc = getOwnerDocument ? getOwnerDocument(root) : root.ownerDocument;
1387
+ return createNodeIterator.call(doc || root, root,
1294
1388
  // eslint-disable-next-line no-bitwise
1295
1389
  NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT | NodeFilter.SHOW_PROCESSING_INSTRUCTION | NodeFilter.SHOW_CDATA_SECTION, null);
1296
1390
  };
@@ -1330,7 +1424,11 @@ function createDOMPurify() {
1330
1424
  const _scrubTemplateExpressions2 = function _scrubTemplateExpressions(node) {
1331
1425
  var _node$querySelectorAl;
1332
1426
  node.normalize();
1333
- const walker = createNodeIterator.call(node.ownerDocument || node, node,
1427
+ /* Clobber-safe ownerDocument read, same reasoning as _createNodeIterator:
1428
+ under SAFE_FOR_TEMPLATES this runs on the live IN_PLACE root, which may
1429
+ carry a form-named-getter override of ownerDocument. */
1430
+ const doc = getOwnerDocument ? getOwnerDocument(node) : node.ownerDocument;
1431
+ const walker = createNodeIterator.call(doc || node, node,
1334
1432
  // eslint-disable-next-line no-bitwise
1335
1433
  NodeFilter.SHOW_TEXT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_CDATA_SECTION | NodeFilter.SHOW_PROCESSING_INSTRUCTION, null);
1336
1434
  let currentNode = walker.nextNode();
@@ -1483,15 +1581,21 @@ function createDOMPurify() {
1483
1581
  /**
1484
1582
  * Handle a node whose tag is forbidden or not allowlisted: keep
1485
1583
  * allowed custom elements (false return exits _sanitizeElements
1486
- * early - namespace/fallback checks and the afterSanitizeElements
1487
- * hook are intentionally skipped for kept custom elements), else
1488
- * hoist content per KEEP_CONTENT and remove.
1584
+ * early - the namespace and fallback-tag removal checks are
1585
+ * intentionally skipped for kept custom elements), else hoist
1586
+ * content per KEEP_CONTENT and remove.
1587
+ *
1588
+ * A kept custom element is the ONLY case in which this function
1589
+ * returns false, so the caller uses that return value to run the
1590
+ * afterSanitizeElements hook on the kept element and keep the
1591
+ * element-hook lifecycle consistent with normal allowlisted
1592
+ * elements (GHSA-c2j3-45gr-mqc4).
1489
1593
  *
1490
1594
  * @param currentNode the disallowed node
1491
1595
  * @param tagName the node's transformCaseFunc'd tag name
1492
1596
  * @return true if the node was removed, false if kept
1493
1597
  */
1494
- const _sanitizeDisallowedNode = function _sanitizeDisallowedNode(currentNode, tagName) {
1598
+ const _sanitizeDisallowedNode = function _sanitizeDisallowedNode(currentNode, tagName, root) {
1495
1599
  /* Check if we have a custom element to handle */
1496
1600
  if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName)) {
1497
1601
  if (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)) {
@@ -1514,27 +1618,26 @@ function createDOMPurify() {
1514
1618
  const childNodes = getChildNodes(currentNode);
1515
1619
  if (childNodes && parentNode) {
1516
1620
  const childCount = childNodes.length;
1517
- /* In-place: hoist the *original* children so the iterator visits
1518
- and sanitises them through the same allowlist pass as every other
1519
- node. The caller built the tree in the live document, so the
1520
- originals carry already-queued resource events (`<img onerror>`,
1521
- `<video>`/`<audio>` error, lazy/`onload`, …); cloning would leave
1522
- those originals detached but still armed, firing in page scope
1523
- while the returned tree looked clean. Moving is safe in-place: the
1524
- root is pre-validated as an allowed tag and so is never the node
1525
- being removed, which keeps `parentNode` inside the iterator root
1526
- and the relocated child inside the serialised tree.
1527
- Otherwise (string / DOM-copy paths): clone. The iterator is rooted
1528
- at and the result serialised from `body`, so a restrictive
1529
- ALLOWED_TAGS that removes `body` itself must leave its content in
1530
- place, which only cloning does; and those paths parse into an
1531
- inert document, so their discarded originals never had a queued
1532
- event to neutralise.
1621
+ /* Hoist by moving each child up one level rather than deep-cloning
1622
+ it. Moving transfers every descendant exactly once, so a chain of
1623
+ nested disallowed elements costs O(n) instead of the O(n^2) that
1624
+ re-cloning the shrinking subtree at each level produced; it also
1625
+ empties the removed original, so `DOMPurify.removed` no longer
1626
+ pins whole subtrees. Moving preserves the in-place guarantee too:
1627
+ an original carrying already-queued resource events (`<img
1628
+ onerror>`, `<video>`/`<audio>` error, lazy/`onload`, …) is
1629
+ relocated and sanitised rather than left detached but still armed.
1630
+ The sole case that must clone is removing the walk root itself.
1631
+ The result is serialised from the root's subtree, so a restrictive
1632
+ ALLOWED_TAGS that strips the root (`body` on the string path) must
1633
+ leave the content inside it, which only cloning does. In IN_PLACE
1634
+ the root is pre-validated as an allowed tag and so is never removed
1635
+ here, so that path always takes the move branch.
1533
1636
  `childNodes` is live; a tail-to-head walk keeps `childNodes[i]`
1534
1637
  valid whether we move (drops the trailing entry) or clone (leaves
1535
1638
  the list intact). */
1536
1639
  for (let i = childCount - 1; i >= 0; --i) {
1537
- const hoisted = IN_PLACE ? childNodes[i] : cloneNode(childNodes[i], true);
1640
+ const hoisted = currentNode === root ? cloneNode(childNodes[i], true) : childNodes[i];
1538
1641
  parentNode.insertBefore(hoisted, getNextSibling(currentNode));
1539
1642
  }
1540
1643
  }
@@ -1542,6 +1645,26 @@ function createDOMPurify() {
1542
1645
  _forceRemove(currentNode);
1543
1646
  return true;
1544
1647
  };
1648
+ /**
1649
+ * Fork a hook-mutable allowlist off its shared binding the first time a
1650
+ * (possibly lazily-installed) uponSanitize* hook is about to see it, so the
1651
+ * hook cannot widen the per-instance default or the setConfig binding by
1652
+ * reference and leak past the call. Returns the set unchanged once it is
1653
+ * already call-local, so repeated calls across elements are idempotent.
1654
+ *
1655
+ * @param hookList the uponSanitize* hook array for this event
1656
+ * @param set the current ALLOWED_TAGS / ALLOWED_ATTR binding
1657
+ * @param defaultSet the per-instance DEFAULT_ALLOWED_* constant
1658
+ * @param setConfigSet the captured setConfig() binding, or null
1659
+ * @return a call-local clone if a hook is present and set is still shared,
1660
+ * else set unchanged
1661
+ */
1662
+ const _forkSharedAllowlist = function _forkSharedAllowlist(hookList, set, defaultSet, setConfigSet) {
1663
+ if (hookList.length === 0) {
1664
+ return set;
1665
+ }
1666
+ return set === defaultSet || set === setConfigSet ? clone(set) : set;
1667
+ };
1545
1668
  /**
1546
1669
  * _sanitizeElements
1547
1670
  *
@@ -1551,9 +1674,20 @@ function createDOMPurify() {
1551
1674
  * @param currentNode to check for permission to exist
1552
1675
  * @return true if node was killed, false if left alive
1553
1676
  */
1554
- const _sanitizeElements = function _sanitizeElements(currentNode) {
1677
+ // eslint-disable-next-line complexity
1678
+ const _sanitizeElements = function _sanitizeElements(currentNode, root) {
1555
1679
  /* Execute a hook if present */
1556
1680
  _executeHooks(hooks.beforeSanitizeElements, currentNode, null);
1681
+ /* A hook may have detached the node - treat it as removed (see the
1682
+ detached-node comment after the uponSanitizeElement hook below). On
1683
+ the IN_PLACE path, neutralize the detached subtree first so a queued
1684
+ resource handler on it cannot fire in page scope after we return. */
1685
+ if (currentNode !== root && getParentNode(currentNode) === null) {
1686
+ if (IN_PLACE) {
1687
+ _neutralizeSubtree(currentNode);
1688
+ }
1689
+ return true;
1690
+ }
1557
1691
  /* Check if element is clobbered or can clobber */
1558
1692
  if (_isClobbered(currentNode)) {
1559
1693
  _forceRemove(currentNode);
@@ -1561,11 +1695,47 @@ function createDOMPurify() {
1561
1695
  }
1562
1696
  /* Now let's check the element's type and name */
1563
1697
  const tagName = transformCaseFunc(getNodeName ? getNodeName(currentNode) : currentNode.nodeName);
1698
+ /* Close the pre-walk clone-guard's timing gap: an uponSanitizeElement
1699
+ hook may have been installed after that guard sampled the hook arrays
1700
+ (e.g. lazily from beforeSanitizeElements), leaving ALLOWED_TAGS still
1701
+ aliasing a shared binding that a widening hook would mutate by
1702
+ reference. Fork it before exposing it to the hook. */
1703
+ ALLOWED_TAGS = _forkSharedAllowlist(hooks.uponSanitizeElement, ALLOWED_TAGS, DEFAULT_ALLOWED_TAGS, SET_CONFIG_ALLOWED_TAGS);
1564
1704
  /* Execute a hook if present */
1565
1705
  _executeHooks(hooks.uponSanitizeElement, currentNode, {
1566
1706
  tagName,
1567
1707
  allowedTags: ALLOWED_TAGS
1568
1708
  });
1709
+ /* A hook may have detached the node from the tree — a long-standing
1710
+ user pattern (issue #469; draw.io-style foreignObject filtering).
1711
+ Per the cached, unclobberable parentNode getter the node is
1712
+ genuinely out of the tree, so it can reach neither the serialized
1713
+ output nor an IN_PLACE live tree; treat it as removed and stop
1714
+ processing it. Without this guard, the unsafe-node / namespace
1715
+ checks below would call _forceRemove on a parentless node and hit
1716
+ the REPORT-3 fail-closed throw — which exists for nodes DOMPurify
1717
+ wants gone but *cannot* detach (clobbered / parentless roots), the
1718
+ opposite of a node that is already safely gone. The walk root is
1719
+ exempt: a detached IN_PLACE root is legitimate input and must still
1720
+ be fully sanitized, and a kill-decision on it must keep hitting the
1721
+ REPORT-3 throw. Nodes detached by hooks stay the hook's
1722
+ responsibility for placement: they are not recorded in
1723
+ DOMPurify.removed, so the post-walk IN_PLACE pass (which iterates
1724
+ DOMPurify.removed) does not reach them. But a hook-detached subtree
1725
+ can still hold a queued resource-event handler - e.g. an <img onload>
1726
+ that began loading when the caller built the live tree - which fires
1727
+ in page scope after sanitize returns even though the handler never
1728
+ reached the returned tree. That is the audit-5 F1 hazard, and the
1729
+ documented node.remove() hook pattern walks straight into it. So on
1730
+ the IN_PLACE path we neutralize the detached subtree inline here,
1731
+ stripping its non-allow-listed attributes before returning, exactly
1732
+ as the post-walk pass does for _forceRemove'd subtrees. */
1733
+ if (currentNode !== root && getParentNode(currentNode) === null) {
1734
+ if (IN_PLACE) {
1735
+ _neutralizeSubtree(currentNode);
1736
+ }
1737
+ return true;
1738
+ }
1569
1739
  /* Remove mXSS vectors, processing instructions and risky comments */
1570
1740
  if (_isUnsafeNode(currentNode, tagName)) {
1571
1741
  _forceRemove(currentNode);
@@ -1573,7 +1743,22 @@ function createDOMPurify() {
1573
1743
  }
1574
1744
  /* Remove element if anything forbids its presence */
1575
1745
  if (FORBID_TAGS[tagName] || !(EXTRA_ELEMENT_HANDLING.tagCheck instanceof Function && EXTRA_ELEMENT_HANDLING.tagCheck(tagName)) && !ALLOWED_TAGS[tagName]) {
1576
- return _sanitizeDisallowedNode(currentNode, tagName);
1746
+ const removed = _sanitizeDisallowedNode(currentNode, tagName, root);
1747
+ /* A false return means the node is a custom element kept via
1748
+ CUSTOM_ELEMENT_HANDLING - the only keep path through
1749
+ _sanitizeDisallowedNode. Run afterSanitizeElements on it so the
1750
+ element-hook lifecycle matches normal allowlisted elements: a
1751
+ security policy applied in this hook (e.g. stripping an attribute
1752
+ from every surviving element) must not silently skip kept custom
1753
+ elements (GHSA-c2j3-45gr-mqc4). This mirrors the normal-element
1754
+ tail below - the hook runs, then the walker's subsequent
1755
+ _sanitizeAttributes pass sanitizes the element's attributes. The
1756
+ deliberately skipped namespace and fallback-tag removal checks stay
1757
+ skipped; they are removal decisions, not the hook contract. */
1758
+ if (removed === false) {
1759
+ _executeHooks(hooks.afterSanitizeElements, currentNode, null);
1760
+ }
1761
+ return removed;
1577
1762
  }
1578
1763
  /* Check whether element has a valid namespace.
1579
1764
  Realm-safe check (GHSA-hpcv-96wg-7vj8): use the cached Node.prototype
@@ -1620,6 +1805,33 @@ function createDOMPurify() {
1620
1805
  if (FORBID_ATTR[lcName]) {
1621
1806
  return false;
1622
1807
  }
1808
+ /* Reject declarative-partial-updates patch-linkage attributes
1809
+ (https://github.com/WICG/declarative-partial-updates).
1810
+ Empirical note (Chrome 150, verified — see
1811
+ test/declarative-patch-probe-v3.html): expansion is NOT applied after
1812
+ sanitization. For the string path it fires during sanitize()'s own
1813
+ parse, so the walk sees and sanitizes the fully materialized expanded
1814
+ tree — teleports into MathML/SVG integration points included; a
1815
+ weaponized `<template for>`->`<img onerror>` comes back with the handler
1816
+ stripped. For the IN_PLACE path it fires on connection, before the walk.
1817
+ Either way DOMPurify is NOT blind to the patch.
1818
+ This removal is therefore defense-in-depth rather than the sole barrier:
1819
+ it prevents live linkage from surviving into the OUTPUT and re-expanding
1820
+ in the caller's context, and keeps behaviour deterministic if a future
1821
+ engine defers expansion. `for` is legitimate only on <label>/<output>;
1822
+ anywhere else (notably <template for>) it links the element to a patch
1823
+ target and teleports or removes an arbitrary DOM range by id/marker name.
1824
+ `patchsrc` fetches remote markup and is treated as a script-loading
1825
+ mechanism (CSP). Gated on SAFE_FOR_XML so the removal groups with the
1826
+ other structural-threat checks and stays overridable, consistent with
1827
+ the rest of the codebase. PI range markers are already removed by
1828
+ _isUnsafeNode. */
1829
+ if (SAFE_FOR_XML && lcName === 'patchsrc') {
1830
+ return false;
1831
+ }
1832
+ if (SAFE_FOR_XML && lcName === 'for' && lcTag !== 'label' && lcTag !== 'output') {
1833
+ return false;
1834
+ }
1623
1835
  /* Make sure attribute cannot clobber */
1624
1836
  if (SANITIZE_DOM && (lcName === 'id' || lcName === 'name') && (value in document || value in formElement)) {
1625
1837
  return false;
@@ -1736,6 +1948,9 @@ function createDOMPurify() {
1736
1948
  if (!attributes || _isClobbered(currentNode)) {
1737
1949
  return;
1738
1950
  }
1951
+ /* Same lazy-install guard as uponSanitizeElement (see there): fork the
1952
+ attribute allowlist off its shared binding before a hook can see it. */
1953
+ ALLOWED_ATTR = _forkSharedAllowlist(hooks.uponSanitizeAttribute, ALLOWED_ATTR, DEFAULT_ALLOWED_ATTR, SET_CONFIG_ALLOWED_ATTR);
1739
1954
  const hookEvent = {
1740
1955
  attrName: '',
1741
1956
  attrValue: '',
@@ -1829,7 +2044,7 @@ function createDOMPurify() {
1829
2044
  /* Execute a hook if present */
1830
2045
  _executeHooks(hooks.uponSanitizeShadowNode, shadowNode, null);
1831
2046
  /* Sanitize tags and elements */
1832
- _sanitizeElements(shadowNode);
2047
+ _sanitizeElements(shadowNode, fragment);
1833
2048
  /* Check attributes next */
1834
2049
  _sanitizeAttributes(shadowNode);
1835
2050
  /* Deep shadow DOM detected.
@@ -2020,6 +2235,11 @@ function createDOMPurify() {
2020
2235
  keep using and whose return value they ignore — unsanitized. REPORT-2. */
2021
2236
  const inPlace = IN_PLACE && typeof dirty !== 'string' && _isNode(dirty);
2022
2237
  if (inPlace) {
2238
+ /* Declarative-partial-updates / streaming pre-pass: sever every patch
2239
+ linkage across the live tree BEFORE the walk, so no patch can fire
2240
+ mid-walk and inject into an already-processed region. Runs first, so
2241
+ it also covers the forbidden/clobbered roots that throw below. */
2242
+ _neutralizePatchLinkage(dirty);
2023
2243
  /* Do some early pre-sanitization to avoid unsafe root nodes.
2024
2244
  Read nodeName through the cached prototype getter — a clobbering
2025
2245
  child named "nodeName" on the form root would otherwise shadow
@@ -2029,6 +2249,9 @@ function createDOMPurify() {
2029
2249
  if (typeof nn === 'string') {
2030
2250
  const tagName = transformCaseFunc(nn);
2031
2251
  if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) {
2252
+ /* Fail closed on a live root: neutralize handlers/children before
2253
+ throwing, exactly as the mid-walk abort path does. */
2254
+ _neutralizeRoot(dirty);
2032
2255
  throw typeErrorCreate('root node is forbidden and cannot be sanitized in-place');
2033
2256
  }
2034
2257
  }
@@ -2043,6 +2266,10 @@ function createDOMPurify() {
2043
2266
  the application unsanitized. Refuse to sanitize such a root
2044
2267
  the same way we refuse a forbidden tag. GHSA-r47g-fvhr-h676. */
2045
2268
  if (_isClobbered(dirty)) {
2269
+ /* Fail closed on a live clobbered root before throwing.
2270
+ _neutralizeRoot's reads are clobber-safe (cached getters); the
2271
+ form's non-clobbered descendants, e.g. an armed <img>, are scrubbed. */
2272
+ _neutralizeRoot(dirty);
2046
2273
  throw typeErrorCreate('root node is clobbered and cannot be sanitized in-place');
2047
2274
  }
2048
2275
  /* Sanitize attached shadow roots before the main iterator runs.
@@ -2095,20 +2322,24 @@ function createDOMPurify() {
2095
2322
  _forceRemove(body.firstChild);
2096
2323
  }
2097
2324
  /* Get node iterator */
2098
- const nodeIterator = _createNodeIterator(inPlace ? dirty : body);
2325
+ const walkRoot = inPlace ? dirty : body;
2099
2326
  /* Now start iterating over the created document.
2100
2327
  The walk runs inside an exception barrier (campaign-3 F2): a re-entrant
2101
2328
  engine/custom-element mutation can detach a node mid-walk so
2102
2329
  `_forceRemove`'s parentless guard throws, aborting the loop. Without the
2103
2330
  barrier the caller's in-place tree would be left half-sanitized with the
2104
- unvisited tail still armed. On any throw we fail closed — strip the
2105
- in-place root bare then rethrow so the existing throw contract is
2106
- preserved. (String/DOM-copy paths never return the partial body, so the
2107
- propagating throw is already fail-closed there.) */
2331
+ unvisited tail still armed. _createNodeIterator itself is inside the
2332
+ barrier too: constructing the iterator dereferences the root's document,
2333
+ and any failure there (e.g. an exotic/clobbered root) must still fail
2334
+ closed rather than skip the neutralize. On any throw we fail closed -
2335
+ strip the in-place root bare - then rethrow so the existing throw
2336
+ contract is preserved. (String/DOM-copy paths never return the partial
2337
+ body, so the propagating throw is already fail-closed there.) */
2108
2338
  try {
2339
+ const nodeIterator = _createNodeIterator(walkRoot);
2109
2340
  while (currentNode = nodeIterator.nextNode()) {
2110
2341
  /* Sanitize tags and elements */
2111
- _sanitizeElements(currentNode);
2342
+ _sanitizeElements(currentNode, walkRoot);
2112
2343
  /* Check attributes next */
2113
2344
  _sanitizeAttributes(currentNode);
2114
2345
  /* Shadow DOM detected, sanitize it.
@@ -2122,6 +2353,14 @@ function createDOMPurify() {
2122
2353
  } catch (error) {
2123
2354
  if (inPlace) {
2124
2355
  _neutralizeRoot(dirty);
2356
+ /* Nodes _forceRemove'd earlier in the aborted walk are already
2357
+ detached from the root, so _neutralizeRoot's subtree pass does not
2358
+ reach them. Defuse them too, mirroring the success-path loop below. */
2359
+ arrayForEach(DOMPurify.removed, entry => {
2360
+ if (entry.element) {
2361
+ _neutralizeSubtree(entry.element);
2362
+ }
2363
+ });
2125
2364
  }
2126
2365
  throw error;
2127
2366
  }