dompurify 3.4.10 → 3.4.12

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.10 | (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.10/LICENSE */
1
+ /*! @license DOMPurify 3.4.12 | (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.12/LICENSE */
2
2
 
3
3
  import { TrustedTypePolicy, TrustedTypesWindow, TrustedHTML } from 'trusted-types/lib/index.js';
4
4
 
@@ -1,4 +1,4 @@
1
- /*! @license DOMPurify 3.4.10 | (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.10/LICENSE */
1
+ /*! @license DOMPurify 3.4.12 | (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.12/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
 
@@ -335,7 +335,6 @@ const COMMENT_MARKUP_PROBE = seal(/<[/\w]/g);
335
335
  const FALLBACK_TAG_CLOSE = seal(/<\/no(script|embed|frames)/i);
336
336
  const SELF_CLOSING_TAG = seal(/\/>/i);
337
337
 
338
- /* eslint-disable @typescript-eslint/indent */
339
338
  // https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType
340
339
  const NODE_TYPE = {
341
340
  element: 1,
@@ -425,7 +424,7 @@ const _resolveSetOption = function _resolveSetOption(cfg, key, fallback, options
425
424
  function createDOMPurify() {
426
425
  let window = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : getGlobal();
427
426
  const DOMPurify = root => createDOMPurify(root);
428
- DOMPurify.version = '3.4.10';
427
+ DOMPurify.version = '3.4.12';
429
428
  DOMPurify.removed = [];
430
429
  if (!window || !window.document || window.document.nodeType !== NODE_TYPE.document || !window.Element) {
431
430
  // Not running in a browser, provide a factory function
@@ -614,6 +613,13 @@ function createDOMPurify() {
614
613
  let WHOLE_DOCUMENT = false;
615
614
  /* Track whether config is already set on this instance of DOMPurify. */
616
615
  let SET_CONFIG = false;
616
+ /* Pristine allowlist bindings captured at setConfig() time. On the
617
+ * persistent-config path sanitize() restores the sets from these before
618
+ * the per-walk hook clone-guard, so a hook's in-call widening cannot
619
+ * carry across calls. Null until setConfig() is called; reset by
620
+ * clearConfig(). */
621
+ let SET_CONFIG_ALLOWED_TAGS = null;
622
+ let SET_CONFIG_ALLOWED_ATTR = null;
617
623
  /* Decide if all elements (e.g. style, script) must be children of
618
624
  * document.body. By default, browsers might move them to document.head */
619
625
  let FORCE_BODY = false;
@@ -923,21 +929,6 @@ function createDOMPurify() {
923
929
  emptyHTML = _createTrustedHTML('');
924
930
  }
925
931
  }
926
- /*
927
- * Mirror the clone-before-mutate pattern already applied above for
928
- * cfg.ADD_TAGS / cfg.ADD_ATTR: if any uponSanitize* hook is
929
- * registered AND the set still points at the default constant,
930
- * clone it. The hook then mutates the clone (in-call widening
931
- * still works exactly as documented) and the next default-cfg
932
- * call rebinds to the untouched original via the reassignment at
933
- * the top of this function.
934
- */
935
- if ((hooks.uponSanitizeElement.length > 0 || hooks.uponSanitizeAttribute.length > 0) && ALLOWED_TAGS === DEFAULT_ALLOWED_TAGS) {
936
- ALLOWED_TAGS = clone(ALLOWED_TAGS);
937
- }
938
- if (hooks.uponSanitizeAttribute.length > 0 && ALLOWED_ATTR === DEFAULT_ALLOWED_ATTR) {
939
- ALLOWED_ATTR = clone(ALLOWED_ATTR);
940
- }
941
932
  // Prevent further manipulation of configuration.
942
933
  // Not available in IE8, Safari 5, etc.
943
934
  if (freeze) {
@@ -1115,6 +1106,13 @@ function createDOMPurify() {
1115
1106
  * @param root the in-place root to empty
1116
1107
  */
1117
1108
  const _neutralizeRoot = function _neutralizeRoot(root) {
1109
+ /* Strip every disallowed attribute (on* handlers included) off the whole
1110
+ subtree BEFORE detaching anything. Detaching first would hand back
1111
+ handler-bearing originals (e.g. an already-loading `<img onerror>`)
1112
+ whose queued resource event still fires in page scope after we throw.
1113
+ Clobber-safe reads; a doomed clobbered node's own attributes are
1114
+ irrelevant while its non-clobbered descendants are reached and scrubbed. */
1115
+ _neutralizeSubtree(root);
1118
1116
  const childNodes = getChildNodes(root);
1119
1117
  if (childNodes) {
1120
1118
  const snapshot = [];
@@ -1242,6 +1240,82 @@ function createDOMPurify() {
1242
1240
  }
1243
1241
  }
1244
1242
  };
1243
+ /**
1244
+ * _neutralizePatchLinkage
1245
+ *
1246
+ * IN_PLACE entry pre-pass (declarative-partial-updates / streaming
1247
+ * hardening, https://github.com/WICG/declarative-partial-updates).
1248
+ *
1249
+ * The main walk strips patch linkage (`for`/`patchsrc`) and removes range
1250
+ * markers (PIs / markup comments) node-by-node, in document order, AS it
1251
+ * reaches each node. On a live in-place root that leaves a window: from the
1252
+ * moment the root is connected until the walk arrives at a given node, that
1253
+ * node's linkage is live. A patch applied on connection/stream can fire as
1254
+ * a microtask during the walk and inject or teleport an unsanitized DOM
1255
+ * range into a region the iterator has already passed and will not revisit,
1256
+ * so the post-return "tree is sanitized" contract is violated. Sweep the
1257
+ * whole tree once up front and sever every linkage before the walk begins,
1258
+ * closing that window.
1259
+ *
1260
+ * This CANNOT undo a patch that already fired before sanitize ran — that is
1261
+ * the irreducible "do not IN_PLACE a live-connected attacker tree" caveat —
1262
+ * but it closes everything from sanitize-start onward. Gated on SAFE_FOR_XML
1263
+ * to group with the rest of the declarative-partial-updates handling and
1264
+ * stay overridable, consistent with the codebase.
1265
+ *
1266
+ * Clobber-safe traversal (cached childNodes getter); per-node try/catch so a
1267
+ * clobbered root cannot defeat the sweep of its non-clobbered descendants.
1268
+ *
1269
+ * NOTE (pending real-Chrome confirmation, see test/declarative-patch-probe
1270
+ * .html Q1): this mirrors the existing policy of keeping `for` on
1271
+ * <label>/<output>. If the shipping feature can drive a patch through a
1272
+ * surviving `for`-on-label/output + `id` pair, this pre-pass and the
1273
+ * attribute check at _isBasicCustomElement's caller must additionally drop
1274
+ * that pair on the IN_PLACE path. Left as-is until the taxonomy is verified.
1275
+ *
1276
+ * @param root the in-place root to sweep
1277
+ */
1278
+ const _neutralizePatchLinkage = function _neutralizePatchLinkage(root) {
1279
+ if (!SAFE_FOR_XML) {
1280
+ return;
1281
+ }
1282
+ const stack = [root];
1283
+ while (stack.length > 0) {
1284
+ const node = stack.pop();
1285
+ const nodeType = getNodeType ? getNodeType(node) : node.nodeType;
1286
+ /* Remove range markers (the target side of a patch linkage): every
1287
+ processing instruction, and any markup-bearing comment. */
1288
+ if (nodeType === NODE_TYPE.processingInstruction || nodeType === NODE_TYPE.comment && regExpTest(COMMENT_MARKUP_PROBE, node.data)) {
1289
+ try {
1290
+ remove(node);
1291
+ } catch (_) {
1292
+ /* Best-effort */
1293
+ }
1294
+ continue;
1295
+ }
1296
+ /* Strip patch-source attributes (the source side) off elements. */
1297
+ if (nodeType === NODE_TYPE.element) {
1298
+ const element = node;
1299
+ const lcTag = transformCaseFunc(getNodeName ? getNodeName(node) : node.nodeName);
1300
+ try {
1301
+ if (element.hasAttribute && element.hasAttribute('patchsrc')) {
1302
+ element.removeAttribute('patchsrc');
1303
+ }
1304
+ if (element.hasAttribute && element.hasAttribute('for') && lcTag !== 'label' && lcTag !== 'output') {
1305
+ element.removeAttribute('for');
1306
+ }
1307
+ } catch (_) {
1308
+ /* Clobbered removeAttribute/hasAttribute on a doomed node — ignore */
1309
+ }
1310
+ }
1311
+ const childNodes = getChildNodes(node);
1312
+ if (childNodes) {
1313
+ for (let i = childNodes.length - 1; i >= 0; --i) {
1314
+ stack.push(childNodes[i]);
1315
+ }
1316
+ }
1317
+ }
1318
+ };
1245
1319
  /**
1246
1320
  * _initDocument
1247
1321
  *
@@ -1492,9 +1566,15 @@ function createDOMPurify() {
1492
1566
  /**
1493
1567
  * Handle a node whose tag is forbidden or not allowlisted: keep
1494
1568
  * allowed custom elements (false return exits _sanitizeElements
1495
- * early - namespace/fallback checks and the afterSanitizeElements
1496
- * hook are intentionally skipped for kept custom elements), else
1497
- * hoist content per KEEP_CONTENT and remove.
1569
+ * early - the namespace and fallback-tag removal checks are
1570
+ * intentionally skipped for kept custom elements), else hoist
1571
+ * content per KEEP_CONTENT and remove.
1572
+ *
1573
+ * A kept custom element is the ONLY case in which this function
1574
+ * returns false, so the caller uses that return value to run the
1575
+ * afterSanitizeElements hook on the kept element and keep the
1576
+ * element-hook lifecycle consistent with normal allowlisted
1577
+ * elements (GHSA-c2j3-45gr-mqc4).
1498
1578
  *
1499
1579
  * @param currentNode the disallowed node
1500
1580
  * @param tagName the node's transformCaseFunc'd tag name
@@ -1560,9 +1640,15 @@ function createDOMPurify() {
1560
1640
  * @param currentNode to check for permission to exist
1561
1641
  * @return true if node was killed, false if left alive
1562
1642
  */
1563
- const _sanitizeElements = function _sanitizeElements(currentNode) {
1643
+ // eslint-disable-next-line complexity
1644
+ const _sanitizeElements = function _sanitizeElements(currentNode, root) {
1564
1645
  /* Execute a hook if present */
1565
1646
  _executeHooks(hooks.beforeSanitizeElements, currentNode, null);
1647
+ /* A hook may have detached the node — treat it as removed (see the
1648
+ detached-node comment after the uponSanitizeElement hook below). */
1649
+ if (currentNode !== root && getParentNode(currentNode) === null) {
1650
+ return true;
1651
+ }
1566
1652
  /* Check if element is clobbered or can clobber */
1567
1653
  if (_isClobbered(currentNode)) {
1568
1654
  _forceRemove(currentNode);
@@ -1575,6 +1661,24 @@ function createDOMPurify() {
1575
1661
  tagName,
1576
1662
  allowedTags: ALLOWED_TAGS
1577
1663
  });
1664
+ /* A hook may have detached the node from the tree — a long-standing
1665
+ user pattern (issue #469; draw.io-style foreignObject filtering).
1666
+ Per the cached, unclobberable parentNode getter the node is
1667
+ genuinely out of the tree, so it can reach neither the serialized
1668
+ output nor an IN_PLACE live tree; treat it as removed and stop
1669
+ processing it. Without this guard, the unsafe-node / namespace
1670
+ checks below would call _forceRemove on a parentless node and hit
1671
+ the REPORT-3 fail-closed throw — which exists for nodes DOMPurify
1672
+ wants gone but *cannot* detach (clobbered / parentless roots), the
1673
+ opposite of a node that is already safely gone. The walk root is
1674
+ exempt: a detached IN_PLACE root is legitimate input and must still
1675
+ be fully sanitized, and a kill-decision on it must keep hitting the
1676
+ REPORT-3 throw. Nodes detached by hooks are the hook's
1677
+ responsibility: they are not recorded in DOMPurify.removed and are
1678
+ not neutralized by the post-walk IN_PLACE pass. */
1679
+ if (currentNode !== root && getParentNode(currentNode) === null) {
1680
+ return true;
1681
+ }
1578
1682
  /* Remove mXSS vectors, processing instructions and risky comments */
1579
1683
  if (_isUnsafeNode(currentNode, tagName)) {
1580
1684
  _forceRemove(currentNode);
@@ -1582,7 +1686,22 @@ function createDOMPurify() {
1582
1686
  }
1583
1687
  /* Remove element if anything forbids its presence */
1584
1688
  if (FORBID_TAGS[tagName] || !(EXTRA_ELEMENT_HANDLING.tagCheck instanceof Function && EXTRA_ELEMENT_HANDLING.tagCheck(tagName)) && !ALLOWED_TAGS[tagName]) {
1585
- return _sanitizeDisallowedNode(currentNode, tagName);
1689
+ const removed = _sanitizeDisallowedNode(currentNode, tagName);
1690
+ /* A false return means the node is a custom element kept via
1691
+ CUSTOM_ELEMENT_HANDLING - the only keep path through
1692
+ _sanitizeDisallowedNode. Run afterSanitizeElements on it so the
1693
+ element-hook lifecycle matches normal allowlisted elements: a
1694
+ security policy applied in this hook (e.g. stripping an attribute
1695
+ from every surviving element) must not silently skip kept custom
1696
+ elements (GHSA-c2j3-45gr-mqc4). This mirrors the normal-element
1697
+ tail below - the hook runs, then the walker's subsequent
1698
+ _sanitizeAttributes pass sanitizes the element's attributes. The
1699
+ deliberately skipped namespace and fallback-tag removal checks stay
1700
+ skipped; they are removal decisions, not the hook contract. */
1701
+ if (removed === false) {
1702
+ _executeHooks(hooks.afterSanitizeElements, currentNode, null);
1703
+ }
1704
+ return removed;
1586
1705
  }
1587
1706
  /* Check whether element has a valid namespace.
1588
1707
  Realm-safe check (GHSA-hpcv-96wg-7vj8): use the cached Node.prototype
@@ -1629,6 +1748,33 @@ function createDOMPurify() {
1629
1748
  if (FORBID_ATTR[lcName]) {
1630
1749
  return false;
1631
1750
  }
1751
+ /* Reject declarative-partial-updates patch-linkage attributes
1752
+ (https://github.com/WICG/declarative-partial-updates).
1753
+ Empirical note (Chrome 150, verified — see
1754
+ test/declarative-patch-probe-v3.html): expansion is NOT applied after
1755
+ sanitization. For the string path it fires during sanitize()'s own
1756
+ parse, so the walk sees and sanitizes the fully materialized expanded
1757
+ tree — teleports into MathML/SVG integration points included; a
1758
+ weaponized `<template for>`->`<img onerror>` comes back with the handler
1759
+ stripped. For the IN_PLACE path it fires on connection, before the walk.
1760
+ Either way DOMPurify is NOT blind to the patch.
1761
+ This removal is therefore defense-in-depth rather than the sole barrier:
1762
+ it prevents live linkage from surviving into the OUTPUT and re-expanding
1763
+ in the caller's context, and keeps behaviour deterministic if a future
1764
+ engine defers expansion. `for` is legitimate only on <label>/<output>;
1765
+ anywhere else (notably <template for>) it links the element to a patch
1766
+ target and teleports or removes an arbitrary DOM range by id/marker name.
1767
+ `patchsrc` fetches remote markup and is treated as a script-loading
1768
+ mechanism (CSP). Gated on SAFE_FOR_XML so the removal groups with the
1769
+ other structural-threat checks and stays overridable, consistent with
1770
+ the rest of the codebase. PI range markers are already removed by
1771
+ _isUnsafeNode. */
1772
+ if (SAFE_FOR_XML && lcName === 'patchsrc') {
1773
+ return false;
1774
+ }
1775
+ if (SAFE_FOR_XML && lcName === 'for' && lcTag !== 'label' && lcTag !== 'output') {
1776
+ return false;
1777
+ }
1632
1778
  /* Make sure attribute cannot clobber */
1633
1779
  if (SANITIZE_DOM && (lcName === 'id' || lcName === 'name') && (value in document || value in formElement)) {
1634
1780
  return false;
@@ -1838,7 +1984,7 @@ function createDOMPurify() {
1838
1984
  /* Execute a hook if present */
1839
1985
  _executeHooks(hooks.uponSanitizeShadowNode, shadowNode, null);
1840
1986
  /* Sanitize tags and elements */
1841
- _sanitizeElements(shadowNode);
1987
+ _sanitizeElements(shadowNode, fragment);
1842
1988
  /* Check attributes next */
1843
1989
  _sanitizeAttributes(shadowNode);
1844
1990
  /* Deep shadow DOM detected.
@@ -1994,9 +2140,31 @@ function createDOMPurify() {
1994
2140
  return dirty;
1995
2141
  }
1996
2142
  /* Assign config vars */
1997
- if (!SET_CONFIG) {
2143
+ if (SET_CONFIG) {
2144
+ /* Persistent setConfig() path: _parseConfig is skipped, so the sets are
2145
+ * not re-derived per call. Restore them from the pristine bindings
2146
+ * captured at setConfig() time so a previous call's hook clone (mutated
2147
+ * below) does not carry over. */
2148
+ ALLOWED_TAGS = SET_CONFIG_ALLOWED_TAGS;
2149
+ ALLOWED_ATTR = SET_CONFIG_ALLOWED_ATTR;
2150
+ } else {
1998
2151
  _parseConfig(cfg);
1999
2152
  }
2153
+ /* Clone the hook-mutable allowlists before the walk whenever an
2154
+ * uponSanitize* hook is registered. The hook event exposes ALLOWED_TAGS
2155
+ * and ALLOWED_ATTR by reference (as allowedTags / allowedAttributes), so
2156
+ * a hook that widens them would otherwise mutate the shared set
2157
+ * permanently: across later calls and across every element. Cloning per
2158
+ * walk keeps documented in-call widening working while scoping it to the
2159
+ * call. A single guard for both config paths - the per-call path rebinds
2160
+ * the sets in _parseConfig each call, the persistent path restores them
2161
+ * from the captured bindings just above - so the two cannot diverge. */
2162
+ if (hooks.uponSanitizeElement.length > 0 || hooks.uponSanitizeAttribute.length > 0) {
2163
+ ALLOWED_TAGS = clone(ALLOWED_TAGS);
2164
+ }
2165
+ if (hooks.uponSanitizeAttribute.length > 0) {
2166
+ ALLOWED_ATTR = clone(ALLOWED_ATTR);
2167
+ }
2000
2168
  /* Clean up removed elements */
2001
2169
  DOMPurify.removed = [];
2002
2170
  /* Resolve IN_PLACE for this call without mutating persistent config.
@@ -2007,6 +2175,11 @@ function createDOMPurify() {
2007
2175
  keep using and whose return value they ignore — unsanitized. REPORT-2. */
2008
2176
  const inPlace = IN_PLACE && typeof dirty !== 'string' && _isNode(dirty);
2009
2177
  if (inPlace) {
2178
+ /* Declarative-partial-updates / streaming pre-pass: sever every patch
2179
+ linkage across the live tree BEFORE the walk, so no patch can fire
2180
+ mid-walk and inject into an already-processed region. Runs first, so
2181
+ it also covers the forbidden/clobbered roots that throw below. */
2182
+ _neutralizePatchLinkage(dirty);
2010
2183
  /* Do some early pre-sanitization to avoid unsafe root nodes.
2011
2184
  Read nodeName through the cached prototype getter — a clobbering
2012
2185
  child named "nodeName" on the form root would otherwise shadow
@@ -2016,6 +2189,9 @@ function createDOMPurify() {
2016
2189
  if (typeof nn === 'string') {
2017
2190
  const tagName = transformCaseFunc(nn);
2018
2191
  if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) {
2192
+ /* Fail closed on a live root: neutralize handlers/children before
2193
+ throwing, exactly as the mid-walk abort path does. */
2194
+ _neutralizeRoot(dirty);
2019
2195
  throw typeErrorCreate('root node is forbidden and cannot be sanitized in-place');
2020
2196
  }
2021
2197
  }
@@ -2030,6 +2206,10 @@ function createDOMPurify() {
2030
2206
  the application unsanitized. Refuse to sanitize such a root
2031
2207
  the same way we refuse a forbidden tag. GHSA-r47g-fvhr-h676. */
2032
2208
  if (_isClobbered(dirty)) {
2209
+ /* Fail closed on a live clobbered root before throwing.
2210
+ _neutralizeRoot's reads are clobber-safe (cached getters); the
2211
+ form's non-clobbered descendants, e.g. an armed <img>, are scrubbed. */
2212
+ _neutralizeRoot(dirty);
2033
2213
  throw typeErrorCreate('root node is clobbered and cannot be sanitized in-place');
2034
2214
  }
2035
2215
  /* Sanitize attached shadow roots before the main iterator runs.
@@ -2082,7 +2262,8 @@ function createDOMPurify() {
2082
2262
  _forceRemove(body.firstChild);
2083
2263
  }
2084
2264
  /* Get node iterator */
2085
- const nodeIterator = _createNodeIterator(inPlace ? dirty : body);
2265
+ const walkRoot = inPlace ? dirty : body;
2266
+ const nodeIterator = _createNodeIterator(walkRoot);
2086
2267
  /* Now start iterating over the created document.
2087
2268
  The walk runs inside an exception barrier (campaign-3 F2): a re-entrant
2088
2269
  engine/custom-element mutation can detach a node mid-walk so
@@ -2095,7 +2276,7 @@ function createDOMPurify() {
2095
2276
  try {
2096
2277
  while (currentNode = nodeIterator.nextNode()) {
2097
2278
  /* Sanitize tags and elements */
2098
- _sanitizeElements(currentNode);
2279
+ _sanitizeElements(currentNode, walkRoot);
2099
2280
  /* Check attributes next */
2100
2281
  _sanitizeAttributes(currentNode);
2101
2282
  /* Shadow DOM detected, sanitize it.
@@ -2109,6 +2290,14 @@ function createDOMPurify() {
2109
2290
  } catch (error) {
2110
2291
  if (inPlace) {
2111
2292
  _neutralizeRoot(dirty);
2293
+ /* Nodes _forceRemove'd earlier in the aborted walk are already
2294
+ detached from the root, so _neutralizeRoot's subtree pass does not
2295
+ reach them. Defuse them too, mirroring the success-path loop below. */
2296
+ arrayForEach(DOMPurify.removed, entry => {
2297
+ if (entry.element) {
2298
+ _neutralizeSubtree(entry.element);
2299
+ }
2300
+ });
2112
2301
  }
2113
2302
  throw error;
2114
2303
  }
@@ -2172,10 +2361,14 @@ function createDOMPurify() {
2172
2361
  let cfg = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
2173
2362
  _parseConfig(cfg);
2174
2363
  SET_CONFIG = true;
2364
+ SET_CONFIG_ALLOWED_TAGS = ALLOWED_TAGS;
2365
+ SET_CONFIG_ALLOWED_ATTR = ALLOWED_ATTR;
2175
2366
  };
2176
2367
  DOMPurify.clearConfig = function () {
2177
2368
  CONFIG = null;
2178
2369
  SET_CONFIG = false;
2370
+ SET_CONFIG_ALLOWED_TAGS = null;
2371
+ SET_CONFIG_ALLOWED_ATTR = null;
2179
2372
  // Drop any caller-supplied Trusted Types policy so it cannot poison later
2180
2373
  // `RETURN_TRUSTED_TYPE` output. The internal default policy (cached, and
2181
2374
  // never recreated — Trusted Types throws on duplicate names) is restored by
@@ -2196,9 +2389,19 @@ function createDOMPurify() {
2196
2389
  if (typeof hookFunction !== 'function') {
2197
2390
  return;
2198
2391
  }
2392
+ /* Reject unknown entry points. Without this, a non-hook key (e.g.
2393
+ * '__proto__') indexes off the prototype chain rather than a real
2394
+ * hook array, and arrayPush then writes to Object.prototype. Guard
2395
+ * with an own-property check against the known hook names. */
2396
+ if (!objectHasOwnProperty(hooks, entryPoint)) {
2397
+ return;
2398
+ }
2199
2399
  arrayPush(hooks[entryPoint], hookFunction);
2200
2400
  };
2201
2401
  DOMPurify.removeHook = function (entryPoint, hookFunction) {
2402
+ if (!objectHasOwnProperty(hooks, entryPoint)) {
2403
+ return undefined;
2404
+ }
2202
2405
  if (hookFunction !== undefined) {
2203
2406
  const index = arrayLastIndexOf(hooks[entryPoint], hookFunction);
2204
2407
  return index === -1 ? undefined : arraySplice(hooks[entryPoint], index, 1)[0];
@@ -2206,6 +2409,9 @@ function createDOMPurify() {
2206
2409
  return arrayPop(hooks[entryPoint]);
2207
2410
  };
2208
2411
  DOMPurify.removeHooks = function (entryPoint) {
2412
+ if (!objectHasOwnProperty(hooks, entryPoint)) {
2413
+ return;
2414
+ }
2209
2415
  hooks[entryPoint] = [];
2210
2416
  };
2211
2417
  DOMPurify.removeAllHooks = function () {