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.
package/dist/purify.js CHANGED
@@ -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 (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
 
@@ -341,7 +341,6 @@
341
341
  const FALLBACK_TAG_CLOSE = seal(/<\/no(script|embed|frames)/i);
342
342
  const SELF_CLOSING_TAG = seal(/\/>/i);
343
343
 
344
- /* eslint-disable @typescript-eslint/indent */
345
344
  // https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType
346
345
  const NODE_TYPE = {
347
346
  element: 1,
@@ -431,7 +430,7 @@
431
430
  function createDOMPurify() {
432
431
  let window = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : getGlobal();
433
432
  const DOMPurify = root => createDOMPurify(root);
434
- DOMPurify.version = '3.4.10';
433
+ DOMPurify.version = '3.4.12';
435
434
  DOMPurify.removed = [];
436
435
  if (!window || !window.document || window.document.nodeType !== NODE_TYPE.document || !window.Element) {
437
436
  // Not running in a browser, provide a factory function
@@ -620,6 +619,13 @@
620
619
  let WHOLE_DOCUMENT = false;
621
620
  /* Track whether config is already set on this instance of DOMPurify. */
622
621
  let SET_CONFIG = false;
622
+ /* Pristine allowlist bindings captured at setConfig() time. On the
623
+ * persistent-config path sanitize() restores the sets from these before
624
+ * the per-walk hook clone-guard, so a hook's in-call widening cannot
625
+ * carry across calls. Null until setConfig() is called; reset by
626
+ * clearConfig(). */
627
+ let SET_CONFIG_ALLOWED_TAGS = null;
628
+ let SET_CONFIG_ALLOWED_ATTR = null;
623
629
  /* Decide if all elements (e.g. style, script) must be children of
624
630
  * document.body. By default, browsers might move them to document.head */
625
631
  let FORCE_BODY = false;
@@ -929,21 +935,6 @@
929
935
  emptyHTML = _createTrustedHTML('');
930
936
  }
931
937
  }
932
- /*
933
- * Mirror the clone-before-mutate pattern already applied above for
934
- * cfg.ADD_TAGS / cfg.ADD_ATTR: if any uponSanitize* hook is
935
- * registered AND the set still points at the default constant,
936
- * clone it. The hook then mutates the clone (in-call widening
937
- * still works exactly as documented) and the next default-cfg
938
- * call rebinds to the untouched original via the reassignment at
939
- * the top of this function.
940
- */
941
- if ((hooks.uponSanitizeElement.length > 0 || hooks.uponSanitizeAttribute.length > 0) && ALLOWED_TAGS === DEFAULT_ALLOWED_TAGS) {
942
- ALLOWED_TAGS = clone(ALLOWED_TAGS);
943
- }
944
- if (hooks.uponSanitizeAttribute.length > 0 && ALLOWED_ATTR === DEFAULT_ALLOWED_ATTR) {
945
- ALLOWED_ATTR = clone(ALLOWED_ATTR);
946
- }
947
938
  // Prevent further manipulation of configuration.
948
939
  // Not available in IE8, Safari 5, etc.
949
940
  if (freeze) {
@@ -1121,6 +1112,13 @@
1121
1112
  * @param root the in-place root to empty
1122
1113
  */
1123
1114
  const _neutralizeRoot = function _neutralizeRoot(root) {
1115
+ /* Strip every disallowed attribute (on* handlers included) off the whole
1116
+ subtree BEFORE detaching anything. Detaching first would hand back
1117
+ handler-bearing originals (e.g. an already-loading `<img onerror>`)
1118
+ whose queued resource event still fires in page scope after we throw.
1119
+ Clobber-safe reads; a doomed clobbered node's own attributes are
1120
+ irrelevant while its non-clobbered descendants are reached and scrubbed. */
1121
+ _neutralizeSubtree(root);
1124
1122
  const childNodes = getChildNodes(root);
1125
1123
  if (childNodes) {
1126
1124
  const snapshot = [];
@@ -1248,6 +1246,82 @@
1248
1246
  }
1249
1247
  }
1250
1248
  };
1249
+ /**
1250
+ * _neutralizePatchLinkage
1251
+ *
1252
+ * IN_PLACE entry pre-pass (declarative-partial-updates / streaming
1253
+ * hardening, https://github.com/WICG/declarative-partial-updates).
1254
+ *
1255
+ * The main walk strips patch linkage (`for`/`patchsrc`) and removes range
1256
+ * markers (PIs / markup comments) node-by-node, in document order, AS it
1257
+ * reaches each node. On a live in-place root that leaves a window: from the
1258
+ * moment the root is connected until the walk arrives at a given node, that
1259
+ * node's linkage is live. A patch applied on connection/stream can fire as
1260
+ * a microtask during the walk and inject or teleport an unsanitized DOM
1261
+ * range into a region the iterator has already passed and will not revisit,
1262
+ * so the post-return "tree is sanitized" contract is violated. Sweep the
1263
+ * whole tree once up front and sever every linkage before the walk begins,
1264
+ * closing that window.
1265
+ *
1266
+ * This CANNOT undo a patch that already fired before sanitize ran — that is
1267
+ * the irreducible "do not IN_PLACE a live-connected attacker tree" caveat —
1268
+ * but it closes everything from sanitize-start onward. Gated on SAFE_FOR_XML
1269
+ * to group with the rest of the declarative-partial-updates handling and
1270
+ * stay overridable, consistent with the codebase.
1271
+ *
1272
+ * Clobber-safe traversal (cached childNodes getter); per-node try/catch so a
1273
+ * clobbered root cannot defeat the sweep of its non-clobbered descendants.
1274
+ *
1275
+ * NOTE (pending real-Chrome confirmation, see test/declarative-patch-probe
1276
+ * .html Q1): this mirrors the existing policy of keeping `for` on
1277
+ * <label>/<output>. If the shipping feature can drive a patch through a
1278
+ * surviving `for`-on-label/output + `id` pair, this pre-pass and the
1279
+ * attribute check at _isBasicCustomElement's caller must additionally drop
1280
+ * that pair on the IN_PLACE path. Left as-is until the taxonomy is verified.
1281
+ *
1282
+ * @param root the in-place root to sweep
1283
+ */
1284
+ const _neutralizePatchLinkage = function _neutralizePatchLinkage(root) {
1285
+ if (!SAFE_FOR_XML) {
1286
+ return;
1287
+ }
1288
+ const stack = [root];
1289
+ while (stack.length > 0) {
1290
+ const node = stack.pop();
1291
+ const nodeType = getNodeType ? getNodeType(node) : node.nodeType;
1292
+ /* Remove range markers (the target side of a patch linkage): every
1293
+ processing instruction, and any markup-bearing comment. */
1294
+ if (nodeType === NODE_TYPE.processingInstruction || nodeType === NODE_TYPE.comment && regExpTest(COMMENT_MARKUP_PROBE, node.data)) {
1295
+ try {
1296
+ remove(node);
1297
+ } catch (_) {
1298
+ /* Best-effort */
1299
+ }
1300
+ continue;
1301
+ }
1302
+ /* Strip patch-source attributes (the source side) off elements. */
1303
+ if (nodeType === NODE_TYPE.element) {
1304
+ const element = node;
1305
+ const lcTag = transformCaseFunc(getNodeName ? getNodeName(node) : node.nodeName);
1306
+ try {
1307
+ if (element.hasAttribute && element.hasAttribute('patchsrc')) {
1308
+ element.removeAttribute('patchsrc');
1309
+ }
1310
+ if (element.hasAttribute && element.hasAttribute('for') && lcTag !== 'label' && lcTag !== 'output') {
1311
+ element.removeAttribute('for');
1312
+ }
1313
+ } catch (_) {
1314
+ /* Clobbered removeAttribute/hasAttribute on a doomed node — ignore */
1315
+ }
1316
+ }
1317
+ const childNodes = getChildNodes(node);
1318
+ if (childNodes) {
1319
+ for (let i = childNodes.length - 1; i >= 0; --i) {
1320
+ stack.push(childNodes[i]);
1321
+ }
1322
+ }
1323
+ }
1324
+ };
1251
1325
  /**
1252
1326
  * _initDocument
1253
1327
  *
@@ -1498,9 +1572,15 @@
1498
1572
  /**
1499
1573
  * Handle a node whose tag is forbidden or not allowlisted: keep
1500
1574
  * allowed custom elements (false return exits _sanitizeElements
1501
- * early - namespace/fallback checks and the afterSanitizeElements
1502
- * hook are intentionally skipped for kept custom elements), else
1503
- * hoist content per KEEP_CONTENT and remove.
1575
+ * early - the namespace and fallback-tag removal checks are
1576
+ * intentionally skipped for kept custom elements), else hoist
1577
+ * content per KEEP_CONTENT and remove.
1578
+ *
1579
+ * A kept custom element is the ONLY case in which this function
1580
+ * returns false, so the caller uses that return value to run the
1581
+ * afterSanitizeElements hook on the kept element and keep the
1582
+ * element-hook lifecycle consistent with normal allowlisted
1583
+ * elements (GHSA-c2j3-45gr-mqc4).
1504
1584
  *
1505
1585
  * @param currentNode the disallowed node
1506
1586
  * @param tagName the node's transformCaseFunc'd tag name
@@ -1566,9 +1646,15 @@
1566
1646
  * @param currentNode to check for permission to exist
1567
1647
  * @return true if node was killed, false if left alive
1568
1648
  */
1569
- const _sanitizeElements = function _sanitizeElements(currentNode) {
1649
+ // eslint-disable-next-line complexity
1650
+ const _sanitizeElements = function _sanitizeElements(currentNode, root) {
1570
1651
  /* Execute a hook if present */
1571
1652
  _executeHooks(hooks.beforeSanitizeElements, currentNode, null);
1653
+ /* A hook may have detached the node — treat it as removed (see the
1654
+ detached-node comment after the uponSanitizeElement hook below). */
1655
+ if (currentNode !== root && getParentNode(currentNode) === null) {
1656
+ return true;
1657
+ }
1572
1658
  /* Check if element is clobbered or can clobber */
1573
1659
  if (_isClobbered(currentNode)) {
1574
1660
  _forceRemove(currentNode);
@@ -1581,6 +1667,24 @@
1581
1667
  tagName,
1582
1668
  allowedTags: ALLOWED_TAGS
1583
1669
  });
1670
+ /* A hook may have detached the node from the tree — a long-standing
1671
+ user pattern (issue #469; draw.io-style foreignObject filtering).
1672
+ Per the cached, unclobberable parentNode getter the node is
1673
+ genuinely out of the tree, so it can reach neither the serialized
1674
+ output nor an IN_PLACE live tree; treat it as removed and stop
1675
+ processing it. Without this guard, the unsafe-node / namespace
1676
+ checks below would call _forceRemove on a parentless node and hit
1677
+ the REPORT-3 fail-closed throw — which exists for nodes DOMPurify
1678
+ wants gone but *cannot* detach (clobbered / parentless roots), the
1679
+ opposite of a node that is already safely gone. The walk root is
1680
+ exempt: a detached IN_PLACE root is legitimate input and must still
1681
+ be fully sanitized, and a kill-decision on it must keep hitting the
1682
+ REPORT-3 throw. Nodes detached by hooks are the hook's
1683
+ responsibility: they are not recorded in DOMPurify.removed and are
1684
+ not neutralized by the post-walk IN_PLACE pass. */
1685
+ if (currentNode !== root && getParentNode(currentNode) === null) {
1686
+ return true;
1687
+ }
1584
1688
  /* Remove mXSS vectors, processing instructions and risky comments */
1585
1689
  if (_isUnsafeNode(currentNode, tagName)) {
1586
1690
  _forceRemove(currentNode);
@@ -1588,7 +1692,22 @@
1588
1692
  }
1589
1693
  /* Remove element if anything forbids its presence */
1590
1694
  if (FORBID_TAGS[tagName] || !(EXTRA_ELEMENT_HANDLING.tagCheck instanceof Function && EXTRA_ELEMENT_HANDLING.tagCheck(tagName)) && !ALLOWED_TAGS[tagName]) {
1591
- return _sanitizeDisallowedNode(currentNode, tagName);
1695
+ const removed = _sanitizeDisallowedNode(currentNode, tagName);
1696
+ /* A false return means the node is a custom element kept via
1697
+ CUSTOM_ELEMENT_HANDLING - the only keep path through
1698
+ _sanitizeDisallowedNode. Run afterSanitizeElements on it so the
1699
+ element-hook lifecycle matches normal allowlisted elements: a
1700
+ security policy applied in this hook (e.g. stripping an attribute
1701
+ from every surviving element) must not silently skip kept custom
1702
+ elements (GHSA-c2j3-45gr-mqc4). This mirrors the normal-element
1703
+ tail below - the hook runs, then the walker's subsequent
1704
+ _sanitizeAttributes pass sanitizes the element's attributes. The
1705
+ deliberately skipped namespace and fallback-tag removal checks stay
1706
+ skipped; they are removal decisions, not the hook contract. */
1707
+ if (removed === false) {
1708
+ _executeHooks(hooks.afterSanitizeElements, currentNode, null);
1709
+ }
1710
+ return removed;
1592
1711
  }
1593
1712
  /* Check whether element has a valid namespace.
1594
1713
  Realm-safe check (GHSA-hpcv-96wg-7vj8): use the cached Node.prototype
@@ -1635,6 +1754,33 @@
1635
1754
  if (FORBID_ATTR[lcName]) {
1636
1755
  return false;
1637
1756
  }
1757
+ /* Reject declarative-partial-updates patch-linkage attributes
1758
+ (https://github.com/WICG/declarative-partial-updates).
1759
+ Empirical note (Chrome 150, verified — see
1760
+ test/declarative-patch-probe-v3.html): expansion is NOT applied after
1761
+ sanitization. For the string path it fires during sanitize()'s own
1762
+ parse, so the walk sees and sanitizes the fully materialized expanded
1763
+ tree — teleports into MathML/SVG integration points included; a
1764
+ weaponized `<template for>`->`<img onerror>` comes back with the handler
1765
+ stripped. For the IN_PLACE path it fires on connection, before the walk.
1766
+ Either way DOMPurify is NOT blind to the patch.
1767
+ This removal is therefore defense-in-depth rather than the sole barrier:
1768
+ it prevents live linkage from surviving into the OUTPUT and re-expanding
1769
+ in the caller's context, and keeps behaviour deterministic if a future
1770
+ engine defers expansion. `for` is legitimate only on <label>/<output>;
1771
+ anywhere else (notably <template for>) it links the element to a patch
1772
+ target and teleports or removes an arbitrary DOM range by id/marker name.
1773
+ `patchsrc` fetches remote markup and is treated as a script-loading
1774
+ mechanism (CSP). Gated on SAFE_FOR_XML so the removal groups with the
1775
+ other structural-threat checks and stays overridable, consistent with
1776
+ the rest of the codebase. PI range markers are already removed by
1777
+ _isUnsafeNode. */
1778
+ if (SAFE_FOR_XML && lcName === 'patchsrc') {
1779
+ return false;
1780
+ }
1781
+ if (SAFE_FOR_XML && lcName === 'for' && lcTag !== 'label' && lcTag !== 'output') {
1782
+ return false;
1783
+ }
1638
1784
  /* Make sure attribute cannot clobber */
1639
1785
  if (SANITIZE_DOM && (lcName === 'id' || lcName === 'name') && (value in document || value in formElement)) {
1640
1786
  return false;
@@ -1844,7 +1990,7 @@
1844
1990
  /* Execute a hook if present */
1845
1991
  _executeHooks(hooks.uponSanitizeShadowNode, shadowNode, null);
1846
1992
  /* Sanitize tags and elements */
1847
- _sanitizeElements(shadowNode);
1993
+ _sanitizeElements(shadowNode, fragment);
1848
1994
  /* Check attributes next */
1849
1995
  _sanitizeAttributes(shadowNode);
1850
1996
  /* Deep shadow DOM detected.
@@ -2000,9 +2146,31 @@
2000
2146
  return dirty;
2001
2147
  }
2002
2148
  /* Assign config vars */
2003
- if (!SET_CONFIG) {
2149
+ if (SET_CONFIG) {
2150
+ /* Persistent setConfig() path: _parseConfig is skipped, so the sets are
2151
+ * not re-derived per call. Restore them from the pristine bindings
2152
+ * captured at setConfig() time so a previous call's hook clone (mutated
2153
+ * below) does not carry over. */
2154
+ ALLOWED_TAGS = SET_CONFIG_ALLOWED_TAGS;
2155
+ ALLOWED_ATTR = SET_CONFIG_ALLOWED_ATTR;
2156
+ } else {
2004
2157
  _parseConfig(cfg);
2005
2158
  }
2159
+ /* Clone the hook-mutable allowlists before the walk whenever an
2160
+ * uponSanitize* hook is registered. The hook event exposes ALLOWED_TAGS
2161
+ * and ALLOWED_ATTR by reference (as allowedTags / allowedAttributes), so
2162
+ * a hook that widens them would otherwise mutate the shared set
2163
+ * permanently: across later calls and across every element. Cloning per
2164
+ * walk keeps documented in-call widening working while scoping it to the
2165
+ * call. A single guard for both config paths - the per-call path rebinds
2166
+ * the sets in _parseConfig each call, the persistent path restores them
2167
+ * from the captured bindings just above - so the two cannot diverge. */
2168
+ if (hooks.uponSanitizeElement.length > 0 || hooks.uponSanitizeAttribute.length > 0) {
2169
+ ALLOWED_TAGS = clone(ALLOWED_TAGS);
2170
+ }
2171
+ if (hooks.uponSanitizeAttribute.length > 0) {
2172
+ ALLOWED_ATTR = clone(ALLOWED_ATTR);
2173
+ }
2006
2174
  /* Clean up removed elements */
2007
2175
  DOMPurify.removed = [];
2008
2176
  /* Resolve IN_PLACE for this call without mutating persistent config.
@@ -2013,6 +2181,11 @@
2013
2181
  keep using and whose return value they ignore — unsanitized. REPORT-2. */
2014
2182
  const inPlace = IN_PLACE && typeof dirty !== 'string' && _isNode(dirty);
2015
2183
  if (inPlace) {
2184
+ /* Declarative-partial-updates / streaming pre-pass: sever every patch
2185
+ linkage across the live tree BEFORE the walk, so no patch can fire
2186
+ mid-walk and inject into an already-processed region. Runs first, so
2187
+ it also covers the forbidden/clobbered roots that throw below. */
2188
+ _neutralizePatchLinkage(dirty);
2016
2189
  /* Do some early pre-sanitization to avoid unsafe root nodes.
2017
2190
  Read nodeName through the cached prototype getter — a clobbering
2018
2191
  child named "nodeName" on the form root would otherwise shadow
@@ -2022,6 +2195,9 @@
2022
2195
  if (typeof nn === 'string') {
2023
2196
  const tagName = transformCaseFunc(nn);
2024
2197
  if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) {
2198
+ /* Fail closed on a live root: neutralize handlers/children before
2199
+ throwing, exactly as the mid-walk abort path does. */
2200
+ _neutralizeRoot(dirty);
2025
2201
  throw typeErrorCreate('root node is forbidden and cannot be sanitized in-place');
2026
2202
  }
2027
2203
  }
@@ -2036,6 +2212,10 @@
2036
2212
  the application unsanitized. Refuse to sanitize such a root
2037
2213
  the same way we refuse a forbidden tag. GHSA-r47g-fvhr-h676. */
2038
2214
  if (_isClobbered(dirty)) {
2215
+ /* Fail closed on a live clobbered root before throwing.
2216
+ _neutralizeRoot's reads are clobber-safe (cached getters); the
2217
+ form's non-clobbered descendants, e.g. an armed <img>, are scrubbed. */
2218
+ _neutralizeRoot(dirty);
2039
2219
  throw typeErrorCreate('root node is clobbered and cannot be sanitized in-place');
2040
2220
  }
2041
2221
  /* Sanitize attached shadow roots before the main iterator runs.
@@ -2088,7 +2268,8 @@
2088
2268
  _forceRemove(body.firstChild);
2089
2269
  }
2090
2270
  /* Get node iterator */
2091
- const nodeIterator = _createNodeIterator(inPlace ? dirty : body);
2271
+ const walkRoot = inPlace ? dirty : body;
2272
+ const nodeIterator = _createNodeIterator(walkRoot);
2092
2273
  /* Now start iterating over the created document.
2093
2274
  The walk runs inside an exception barrier (campaign-3 F2): a re-entrant
2094
2275
  engine/custom-element mutation can detach a node mid-walk so
@@ -2101,7 +2282,7 @@
2101
2282
  try {
2102
2283
  while (currentNode = nodeIterator.nextNode()) {
2103
2284
  /* Sanitize tags and elements */
2104
- _sanitizeElements(currentNode);
2285
+ _sanitizeElements(currentNode, walkRoot);
2105
2286
  /* Check attributes next */
2106
2287
  _sanitizeAttributes(currentNode);
2107
2288
  /* Shadow DOM detected, sanitize it.
@@ -2115,6 +2296,14 @@
2115
2296
  } catch (error) {
2116
2297
  if (inPlace) {
2117
2298
  _neutralizeRoot(dirty);
2299
+ /* Nodes _forceRemove'd earlier in the aborted walk are already
2300
+ detached from the root, so _neutralizeRoot's subtree pass does not
2301
+ reach them. Defuse them too, mirroring the success-path loop below. */
2302
+ arrayForEach(DOMPurify.removed, entry => {
2303
+ if (entry.element) {
2304
+ _neutralizeSubtree(entry.element);
2305
+ }
2306
+ });
2118
2307
  }
2119
2308
  throw error;
2120
2309
  }
@@ -2178,10 +2367,14 @@
2178
2367
  let cfg = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
2179
2368
  _parseConfig(cfg);
2180
2369
  SET_CONFIG = true;
2370
+ SET_CONFIG_ALLOWED_TAGS = ALLOWED_TAGS;
2371
+ SET_CONFIG_ALLOWED_ATTR = ALLOWED_ATTR;
2181
2372
  };
2182
2373
  DOMPurify.clearConfig = function () {
2183
2374
  CONFIG = null;
2184
2375
  SET_CONFIG = false;
2376
+ SET_CONFIG_ALLOWED_TAGS = null;
2377
+ SET_CONFIG_ALLOWED_ATTR = null;
2185
2378
  // Drop any caller-supplied Trusted Types policy so it cannot poison later
2186
2379
  // `RETURN_TRUSTED_TYPE` output. The internal default policy (cached, and
2187
2380
  // never recreated — Trusted Types throws on duplicate names) is restored by
@@ -2202,9 +2395,19 @@
2202
2395
  if (typeof hookFunction !== 'function') {
2203
2396
  return;
2204
2397
  }
2398
+ /* Reject unknown entry points. Without this, a non-hook key (e.g.
2399
+ * '__proto__') indexes off the prototype chain rather than a real
2400
+ * hook array, and arrayPush then writes to Object.prototype. Guard
2401
+ * with an own-property check against the known hook names. */
2402
+ if (!objectHasOwnProperty(hooks, entryPoint)) {
2403
+ return;
2404
+ }
2205
2405
  arrayPush(hooks[entryPoint], hookFunction);
2206
2406
  };
2207
2407
  DOMPurify.removeHook = function (entryPoint, hookFunction) {
2408
+ if (!objectHasOwnProperty(hooks, entryPoint)) {
2409
+ return undefined;
2410
+ }
2208
2411
  if (hookFunction !== undefined) {
2209
2412
  const index = arrayLastIndexOf(hooks[entryPoint], hookFunction);
2210
2413
  return index === -1 ? undefined : arraySplice(hooks[entryPoint], index, 1)[0];
@@ -2212,6 +2415,9 @@
2212
2415
  return arrayPop(hooks[entryPoint]);
2213
2416
  };
2214
2417
  DOMPurify.removeHooks = function (entryPoint) {
2418
+ if (!objectHasOwnProperty(hooks, entryPoint)) {
2419
+ return;
2420
+ }
2215
2421
  hooks[entryPoint] = [];
2216
2422
  };
2217
2423
  DOMPurify.removeAllHooks = function () {