dompurify 3.4.13 → 3.4.15

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.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 */
1
+ /*! @license DOMPurify 3.4.15 | (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.15/LICENSE */
2
2
 
3
3
  'use strict';
4
4
 
@@ -313,7 +313,7 @@ const mathMlDisallowed = freeze(['maction', 'maligngroup', 'malignmark', 'mlongd
313
313
  const text = freeze(['#text']);
314
314
 
315
315
  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']);
316
- 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']);
316
+ 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', 'pointer-events', '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', 'vector-effect', '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']);
317
317
  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']);
318
318
  const xml = freeze(['xlink:href', 'xml:id', 'xlink:title', 'xml:space', 'xmlns:xlink']);
319
319
 
@@ -354,6 +354,37 @@ const NODE_TYPE = {
354
354
  documentFragment: 11,
355
355
  notation: 12 // Deprecated
356
356
  };
357
+ /* HTML-namespace elements whose child text nodes are serialized *literally*
358
+ (unescaped) by the HTML fragment-serialization algorithm. Two reparse-mXSS
359
+ shapes ride on that literal serialization:
360
+ (a) an element child - a tree the HTML parser can never build, but the DOM
361
+ API and an XML/XHTML parse can - after which a `</tag>`-bearing text
362
+ sibling breaks the element open on reparse; and
363
+ (b) text-only content that already carries the element's OWN end tag, e.g.
364
+ `<style>...</style><img onerror=x>` built as a node, which the literal
365
+ serializer emits verbatim for the HTML parser to re-open.
366
+ Shape (a) is handled by the firstElementChild branch in _isUnsafeNode; shape
367
+ (b) by the LITERAL_TEXT_CLOSE probe. Both read textContent (the raw-serialized
368
+ form for these elements) rather than innerHTML, because an XML/XHTML working
369
+ document serializes innerHTML with `<` escaped, which silently blinds the
370
+ innerHTML-based probes (rule 1's second probe and FALLBACK_TAG_CLOSE) there.
371
+ `script` is never allow-listed, but is kept here so the guard matches the
372
+ serializer's own literal-text list exactly. */
373
+ const LITERAL_TEXT_ELEMENT_NAMES = ['style', 'script', 'xmp', 'iframe', 'noembed', 'noframes', 'plaintext', 'noscript'];
374
+ const LITERAL_TEXT_ELEMENTS = freeze(addToSet({}, LITERAL_TEXT_ELEMENT_NAMES));
375
+ /* Per-element end-tag matcher. On an HTML reparse the ONLY token that
376
+ terminates a literal-text element's raw content is its own end tag; a foreign
377
+ literal-text close (e.g. `</xmp>` sitting inside `<style>`) does not break
378
+ out, so matching is per-element, not a shared alternation. The lookahead
379
+ requires an HTML tag-name terminator (whitespace, `/` or `>`) so a longer
380
+ name such as `</styles` is not mistaken for `</style`. */
381
+ const LITERAL_TEXT_CLOSE = function () {
382
+ const map = {};
383
+ arrayForEach(LITERAL_TEXT_ELEMENT_NAMES, name => {
384
+ map[name] = seal(new RegExp('</' + name + '(?=[\\t\\n\\f\\r />])', 'i'));
385
+ });
386
+ return freeze(map);
387
+ }();
357
388
  const getGlobal = function getGlobal() {
358
389
  return typeof window === 'undefined' ? null : window;
359
390
  };
@@ -423,10 +454,25 @@ const _createHooksMap = function _createHooksMap() {
423
454
  const _resolveSetOption = function _resolveSetOption(cfg, key, fallback, options) {
424
455
  return objectHasOwnProperty(cfg, key) && arrayIsArray(cfg[key]) ? addToSet(options.base ? clone(options.base) : {}, cfg[key], options.transform) : fallback;
425
456
  };
457
+ /**
458
+ * Resolve an object-valued configuration option: a prototype-free clone
459
+ * of cfg[key] when it is an own, truthy object property, else a fresh
460
+ * fallback built by makeFallback (fresh on every parse, so a previous
461
+ * parse can never leak state into the next one).
462
+ *
463
+ * @param cfg the cloned, prototype-free configuration object
464
+ * @param key the configuration property to read
465
+ * @param makeFallback builds the fallback value when the option is absent
466
+ * @returns the resolved object
467
+ */
468
+ const _resolveObjectOption = function _resolveObjectOption(cfg, key, makeFallback) {
469
+ const value = objectHasOwnProperty(cfg, key) ? cfg[key] : undefined;
470
+ return value && typeof value === 'object' ? clone(value) : makeFallback();
471
+ };
426
472
  function createDOMPurify() {
427
473
  let window = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : getGlobal();
428
474
  const DOMPurify = root => createDOMPurify(root);
429
- DOMPurify.version = '3.4.13';
475
+ DOMPurify.version = '3.4.15';
430
476
  DOMPurify.removed = [];
431
477
  if (!window || !window.document || window.document.nodeType !== NODE_TYPE.document || !window.Element) {
432
478
  // Not running in a browser, provide a factory function
@@ -450,6 +496,12 @@ function createDOMPurify() {
450
496
  const ElementPrototype = Element.prototype;
451
497
  const cloneNode = lookupGetter(ElementPrototype, 'cloneNode');
452
498
  const remove = lookupGetter(ElementPrototype, 'remove');
499
+ // Clobber-safe Attr-node removal. On an HTMLFormElement a descendant named
500
+ // "removeAttributeNode" shadows the prototype method via
501
+ // [LegacyOverrideBuiltIns], so element.removeAttributeNode(attr) throws.
502
+ // Calling the cached Element.prototype method with the element as the
503
+ // receiver removes the exact live Attr node regardless of the shadowing.
504
+ const removeAttributeNode = lookupGetter(ElementPrototype, 'removeAttributeNode');
453
505
  const getNextSibling = lookupGetter(ElementPrototype, 'nextSibling');
454
506
  const getChildNodes = lookupGetter(ElementPrototype, 'childNodes');
455
507
  const getParentNode = lookupGetter(ElementPrototype, 'parentNode');
@@ -458,6 +510,16 @@ function createDOMPurify() {
458
510
  const getNodeType = Node && Node.prototype ? lookupGetter(Node.prototype, 'nodeType') : null;
459
511
  const getNodeName = Node && Node.prototype ? lookupGetter(Node.prototype, 'nodeName') : null;
460
512
  const getOwnerDocument = Node && Node.prototype ? lookupGetter(Node.prototype, 'ownerDocument') : null;
513
+ /* Clobber-safe nodeType / nodeName reads through the cached Node.prototype
514
+ getters, with a direct-property fallback for environments that lack
515
+ Node.prototype. Sites that need a different fallback (e.g. _isClobbered
516
+ returns early on a null name) intentionally keep their own reads. */
517
+ const _readNodeType = function _readNodeType(node) {
518
+ return getNodeType ? getNodeType(node) : node.nodeType;
519
+ };
520
+ const _readNodeName = function _readNodeName(node) {
521
+ return getNodeName ? getNodeName(node) : node.nodeName;
522
+ };
461
523
  // As per issue #47, the web-components registry is inherited by a
462
524
  // new document created via createHTMLDocument. As per the spec
463
525
  // (http://w3c.github.io/webcomponents/spec/custom/#creating-and-passing-registries)
@@ -780,9 +842,11 @@ function createDOMPurify() {
780
842
  IN_PLACE = cfg.IN_PLACE || false; // Default false
781
843
  IS_ALLOWED_URI$1 = isRegex(cfg.ALLOWED_URI_REGEXP) ? cfg.ALLOWED_URI_REGEXP : IS_ALLOWED_URI; // Default regexp
782
844
  NAMESPACE = typeof cfg.NAMESPACE === 'string' ? cfg.NAMESPACE : HTML_NAMESPACE; // Default HTML namespace
783
- MATHML_TEXT_INTEGRATION_POINTS = objectHasOwnProperty(cfg, 'MATHML_TEXT_INTEGRATION_POINTS') && cfg.MATHML_TEXT_INTEGRATION_POINTS && typeof cfg.MATHML_TEXT_INTEGRATION_POINTS === 'object' ? clone(cfg.MATHML_TEXT_INTEGRATION_POINTS) : addToSet({}, DEFAULT_MATHML_TEXT_INTEGRATION_POINTS); // Default built-in map
784
- HTML_INTEGRATION_POINTS = objectHasOwnProperty(cfg, 'HTML_INTEGRATION_POINTS') && cfg.HTML_INTEGRATION_POINTS && typeof cfg.HTML_INTEGRATION_POINTS === 'object' ? clone(cfg.HTML_INTEGRATION_POINTS) : addToSet({}, DEFAULT_HTML_INTEGRATION_POINTS); // Default built-in map
785
- const customElementHandling = objectHasOwnProperty(cfg, 'CUSTOM_ELEMENT_HANDLING') && cfg.CUSTOM_ELEMENT_HANDLING && typeof cfg.CUSTOM_ELEMENT_HANDLING === 'object' ? clone(cfg.CUSTOM_ELEMENT_HANDLING) : create(null);
845
+ MATHML_TEXT_INTEGRATION_POINTS = _resolveObjectOption(cfg, 'MATHML_TEXT_INTEGRATION_POINTS', () => addToSet({}, DEFAULT_MATHML_TEXT_INTEGRATION_POINTS) // Default built-in map
846
+ );
847
+ HTML_INTEGRATION_POINTS = _resolveObjectOption(cfg, 'HTML_INTEGRATION_POINTS', () => addToSet({}, DEFAULT_HTML_INTEGRATION_POINTS) // Default built-in map
848
+ );
849
+ const customElementHandling = _resolveObjectOption(cfg, 'CUSTOM_ELEMENT_HANDLING', () => create(null));
786
850
  CUSTOM_ELEMENT_HANDLING = create(null);
787
851
  if (objectHasOwnProperty(customElementHandling, 'tagNameCheck') && isRegexOrFunction(customElementHandling.tagNameCheck)) {
788
852
  CUSTOM_ELEMENT_HANDLING.tagNameCheck = customElementHandling.tagNameCheck; // Default undefined
@@ -849,15 +913,6 @@ function createDOMPurify() {
849
913
  addToSet(ALLOWED_ATTR, cfg.ADD_ATTR, transformCaseFunc);
850
914
  }
851
915
  }
852
- if (objectHasOwnProperty(cfg, 'ADD_URI_SAFE_ATTR') && arrayIsArray(cfg.ADD_URI_SAFE_ATTR)) {
853
- addToSet(URI_SAFE_ATTRIBUTES, cfg.ADD_URI_SAFE_ATTR, transformCaseFunc);
854
- }
855
- if (objectHasOwnProperty(cfg, 'FORBID_CONTENTS') && arrayIsArray(cfg.FORBID_CONTENTS)) {
856
- if (FORBID_CONTENTS === DEFAULT_FORBID_CONTENTS) {
857
- FORBID_CONTENTS = clone(FORBID_CONTENTS);
858
- }
859
- addToSet(FORBID_CONTENTS, cfg.FORBID_CONTENTS, transformCaseFunc);
860
- }
861
916
  if (objectHasOwnProperty(cfg, 'ADD_FORBID_CONTENTS') && arrayIsArray(cfg.ADD_FORBID_CONTENTS)) {
862
917
  if (FORBID_CONTENTS === DEFAULT_FORBID_CONTENTS) {
863
918
  FORBID_CONTENTS = clone(FORBID_CONTENTS);
@@ -1092,6 +1147,32 @@ function createDOMPurify() {
1092
1147
  }
1093
1148
  }
1094
1149
  };
1150
+ /**
1151
+ * _stripAttributeNode
1152
+ *
1153
+ * Remove a single Attr node case/namespace-exactly on an attribute-teardown
1154
+ * path. Name-based removeAttribute() ASCII-lowercases its lookup key for an
1155
+ * HTML element in an HTML document and so silently misses a case-preserved
1156
+ * handler (e.g. `ONERROR` off an XML/XHTML import) - the same defect
1157
+ * _removeAttribute() was fixed for, which a name-based call would reintroduce
1158
+ * on these IN_PLACE teardown paths. Unlike _removeAttribute this does not
1159
+ * record into DOMPurify.removed: the neutralize passes intentionally do not
1160
+ * book-keep. A clobbered/detached node falls back to best-effort name-based
1161
+ * removal.
1162
+ *
1163
+ * @param element the element to strip the attribute from
1164
+ * @param attribute the Attr node to remove
1165
+ * @param name the attribute's name, for the fallback path
1166
+ */
1167
+ const _stripAttributeNode = function _stripAttributeNode(element, attribute, name) {
1168
+ try {
1169
+ removeAttributeNode(element, attribute);
1170
+ } catch (_) {
1171
+ try {
1172
+ element.removeAttribute(name);
1173
+ } catch (_) {}
1174
+ }
1175
+ };
1095
1176
  /**
1096
1177
  * _neutralizeRoot
1097
1178
  *
@@ -1136,11 +1217,7 @@ function createDOMPurify() {
1136
1217
  const attribute = attributes[i];
1137
1218
  const name = attribute && attribute.name;
1138
1219
  if (typeof name === 'string') {
1139
- try {
1140
- root.removeAttribute(name);
1141
- } catch (_) {
1142
- /* Clobbered removeAttribute — ignore (fail-closed best effort) */
1143
- }
1220
+ _stripAttributeNode(root, attribute, name);
1144
1221
  }
1145
1222
  }
1146
1223
  }
@@ -1148,22 +1225,51 @@ function createDOMPurify() {
1148
1225
  /**
1149
1226
  * _removeAttribute
1150
1227
  *
1228
+ * Name-based getAttributeNode()/removeAttribute() ASCII-lowercase their
1229
+ * lookup key for HTML elements in an HTML document, so they silently miss an
1230
+ * attribute whose stored qualified name still contains uppercase ASCII
1231
+ * letters. That happens when the node came from a case-preserving source
1232
+ * (an XML/XHTML document imported via importNode(), or createAttributeNS()),
1233
+ * where e.g. `ONERROR` survives the walk: the policy check lowercases to
1234
+ * `onerror` and rejects it, but `removeAttribute('ONERROR')` looks up
1235
+ * `onerror` and finds nothing. Remove the exact Attr node instead, which is
1236
+ * case- and namespace-exact, and fall back to name-based removal only when
1237
+ * the caller could not supply the node.
1238
+ *
1151
1239
  * @param name an Attribute name
1152
1240
  * @param element a DOM node
1241
+ * @param attr the exact Attr node to remove, when the caller has it
1153
1242
  */
1154
- const _removeAttribute = function _removeAttribute(name, element) {
1243
+ const _removeAttribute = function _removeAttribute(name, element, attr) {
1244
+ if (!attr) {
1245
+ try {
1246
+ attr = element.getAttributeNode(name);
1247
+ } catch (_) {
1248
+ attr = null;
1249
+ }
1250
+ }
1251
+ arrayPush(DOMPurify.removed, {
1252
+ attribute: attr || null,
1253
+ from: element
1254
+ });
1155
1255
  try {
1156
- arrayPush(DOMPurify.removed, {
1157
- attribute: element.getAttributeNode(name),
1158
- from: element
1159
- });
1256
+ if (attr) {
1257
+ removeAttributeNode(element, attr);
1258
+ } else {
1259
+ element.removeAttribute(name);
1260
+ }
1160
1261
  } catch (_) {
1161
- arrayPush(DOMPurify.removed, {
1162
- attribute: null,
1163
- from: element
1164
- });
1262
+ /* Clobbered or already-detached node - best-effort fall back to a
1263
+ name-based removal so the "is" handling below still runs. Note this
1264
+ fallback ASCII-lowercases its lookup key in an HTML document and so
1265
+ cannot reach a case-preserved attribute name; the cached
1266
+ removeAttributeNode() above is what removes those, and a clobbered
1267
+ form is already removed wholesale by _isClobbered() before reaching
1268
+ here. */
1269
+ try {
1270
+ element.removeAttribute(name);
1271
+ } catch (_) {}
1165
1272
  }
1166
- element.removeAttribute(name);
1167
1273
  // We void attribute values for unremovable "is" attributes
1168
1274
  if (name === 'is') {
1169
1275
  if (RETURN_DOM || RETURN_DOM_FRAGMENT) {
@@ -1198,11 +1304,7 @@ function createDOMPurify() {
1198
1304
  if (typeof name !== 'string' || ALLOWED_ATTR[transformCaseFunc(name)]) {
1199
1305
  continue;
1200
1306
  }
1201
- try {
1202
- element.removeAttribute(name);
1203
- } catch (_) {
1204
- /* Clobbered removeAttribute on a doomed node — ignore */
1205
- }
1307
+ _stripAttributeNode(element, attribute, name);
1206
1308
  }
1207
1309
  };
1208
1310
  /**
@@ -1231,7 +1333,7 @@ function createDOMPurify() {
1231
1333
  const stack = [root];
1232
1334
  while (stack.length > 0) {
1233
1335
  const node = stack.pop();
1234
- const nodeType = getNodeType ? getNodeType(node) : node.nodeType;
1336
+ const nodeType = _readNodeType(node);
1235
1337
  if (nodeType === NODE_TYPE.element) {
1236
1338
  _stripDisallowedAttributes(node);
1237
1339
  }
@@ -1278,6 +1380,26 @@ function createDOMPurify() {
1278
1380
  *
1279
1381
  * @param root the in-place root to sweep
1280
1382
  */
1383
+ /**
1384
+ * Central policy for declarative-partial-updates patch-linkage attributes,
1385
+ * shared by the _neutralizePatchLinkage pre-pass and _isValidAttribute so
1386
+ * the two sites cannot drift: `patchsrc` always links, `for` links
1387
+ * everywhere except on <label>/<output>, and the whole policy is gated on
1388
+ * SAFE_FOR_XML (see the rationale block in _isValidAttribute).
1389
+ *
1390
+ * @param lcName the transformCaseFunc'd attribute name
1391
+ * @param lcTag the transformCaseFunc'd tag name of the carrying element
1392
+ * @return true if the attribute is patch linkage and must be dropped
1393
+ */
1394
+ const _isPatchLinkageAttribute = function _isPatchLinkageAttribute(lcName, lcTag) {
1395
+ if (!SAFE_FOR_XML) {
1396
+ return false;
1397
+ }
1398
+ if (lcName === 'patchsrc') {
1399
+ return true;
1400
+ }
1401
+ return lcName === 'for' && lcTag !== 'label' && lcTag !== 'output';
1402
+ };
1281
1403
  const _neutralizePatchLinkage = function _neutralizePatchLinkage(root) {
1282
1404
  if (!SAFE_FOR_XML) {
1283
1405
  return;
@@ -1285,7 +1407,7 @@ function createDOMPurify() {
1285
1407
  const stack = [root];
1286
1408
  while (stack.length > 0) {
1287
1409
  const node = stack.pop();
1288
- const nodeType = getNodeType ? getNodeType(node) : node.nodeType;
1410
+ const nodeType = _readNodeType(node);
1289
1411
  /* Remove range markers (the target side of a patch linkage): every
1290
1412
  processing instruction, and any markup-bearing comment. */
1291
1413
  if (nodeType === NODE_TYPE.processingInstruction || nodeType === NODE_TYPE.comment && regExpTest(COMMENT_MARKUP_PROBE, node.data)) {
@@ -1299,12 +1421,12 @@ function createDOMPurify() {
1299
1421
  /* Strip patch-source attributes (the source side) off elements. */
1300
1422
  if (nodeType === NODE_TYPE.element) {
1301
1423
  const element = node;
1302
- const lcTag = transformCaseFunc(getNodeName ? getNodeName(node) : node.nodeName);
1424
+ const lcTag = transformCaseFunc(_readNodeName(node));
1303
1425
  try {
1304
1426
  if (element.hasAttribute && element.hasAttribute('patchsrc')) {
1305
1427
  element.removeAttribute('patchsrc');
1306
1428
  }
1307
- if (element.hasAttribute && element.hasAttribute('for') && lcTag !== 'label' && lcTag !== 'output') {
1429
+ if (element.hasAttribute && element.hasAttribute('for') && _isPatchLinkageAttribute('for', lcTag)) {
1308
1430
  element.removeAttribute('for');
1309
1431
  }
1310
1432
  } catch (_) {
@@ -1479,7 +1601,17 @@ function createDOMPurify() {
1479
1601
  // makes the direct read diverge from the cached read; a clean form
1480
1602
  // (same-realm OR foreign-realm) has both reads pointing at the same
1481
1603
  // canonical NamedNodeMap.
1482
- element.attributes !== getAttributes(element) || typeof element.removeAttribute !== 'function' || typeof element.setAttribute !== 'function' || typeof element.namespaceURI !== 'string' || typeof element.insertBefore !== 'function' || typeof element.hasChildNodes !== 'function' ||
1604
+ element.attributes !== getAttributes(element) || typeof element.removeAttribute !== 'function' ||
1605
+ // A form descendant named "removeAttributeNode" or "getAttributeNode"
1606
+ // shadows these Attr-node methods via [LegacyOverrideBuiltIns].
1607
+ // _removeAttribute() / _stripAttributeNode() reach for
1608
+ // element.removeAttributeNode(attr) first; when it is shadowed the call
1609
+ // throws and the name-based fallback element.removeAttribute(name)
1610
+ // ASCII-lowercases its lookup key in an HTML document, silently missing
1611
+ // a case-preserved event-handler attribute (e.g. an ONANIMATIONSTART
1612
+ // that reached the sanitizer through an XML/XHTML parse). Flag the form
1613
+ // so it is removed wholesale, exactly as for the other shadowed methods.
1614
+ typeof element.removeAttributeNode !== 'function' || typeof element.getAttributeNode !== 'function' || typeof element.setAttribute !== 'function' || typeof element.namespaceURI !== 'string' || typeof element.insertBefore !== 'function' || typeof element.hasChildNodes !== 'function' ||
1483
1615
  // NodeType clobbering probe. Cached Node.prototype.nodeType getter
1484
1616
  // returns the integer 1 for any Element regardless of realm; direct
1485
1617
  // read on a clobbered form (e.g. <input name="nodeType">) returns
@@ -1566,8 +1698,16 @@ function createDOMPurify() {
1566
1698
  if (SAFE_FOR_XML && currentNode.hasChildNodes() && !_isNode(currentNode.firstElementChild) && regExpTest(ELEMENT_MARKUP_PROBE, currentNode.textContent) && regExpTest(ELEMENT_MARKUP_PROBE, currentNode.innerHTML)) {
1567
1699
  return true;
1568
1700
  }
1569
- /* Remove risky CSS construction leading to mXSS */
1570
- if (SAFE_FOR_XML && currentNode.namespaceURI === HTML_NAMESPACE && tagName === 'style' && _isNode(currentNode.firstElementChild)) {
1701
+ /* Remove rawtext/literal-text elements whose literal serialization
1702
+ re-opens markup on an HTML reparse - shapes (a) and (b) documented at
1703
+ LITERAL_TEXT_ELEMENTS. Both are invisible to rule 1 above (which
1704
+ self-disables once there is an element child, and whose second probe
1705
+ reads the innerHTML an XML/XHTML document serializes escaped), which
1706
+ is why both probes here read textContent instead. Previously only
1707
+ `style`-with-element-child was covered; every element in
1708
+ LITERAL_TEXT_ELEMENTS shares this literal serialization and is
1709
+ equally affected. */
1710
+ if (SAFE_FOR_XML && currentNode.namespaceURI === HTML_NAMESPACE && LITERAL_TEXT_ELEMENTS[tagName] && (_isNode(currentNode.firstElementChild) || typeof currentNode.textContent === 'string' && regExpTest(LITERAL_TEXT_CLOSE[tagName], currentNode.textContent))) {
1571
1711
  return true;
1572
1712
  }
1573
1713
  /* Remove any occurrence of processing instructions */
@@ -1580,6 +1720,30 @@ function createDOMPurify() {
1580
1720
  }
1581
1721
  return false;
1582
1722
  };
1723
+ /**
1724
+ * Evaluate a CUSTOM_ELEMENT_HANDLING check (a RegExp or a predicate
1725
+ * function, per the validation in _parseConfig) against a name.
1726
+ * Additional arguments are forwarded to predicate functions - the
1727
+ * attributeNameCheck predicate receives the tag name as its second
1728
+ * argument. A null/absent check never matches.
1729
+ *
1730
+ * @param check the configured tagNameCheck / attributeNameCheck value
1731
+ * @param name the name to test
1732
+ * @param args extra arguments forwarded to a predicate function
1733
+ * @return true if the check matches the name
1734
+ */
1735
+ const _matchesNameCheck = function _matchesNameCheck(check, name) {
1736
+ if (check instanceof RegExp) {
1737
+ return regExpTest(check, name);
1738
+ }
1739
+ if (check instanceof Function) {
1740
+ for (var _len = arguments.length, args = new Array(_len > 2 ? _len - 2 : 0), _key = 2; _key < _len; _key++) {
1741
+ args[_key - 2] = arguments[_key];
1742
+ }
1743
+ return Boolean(check(name, ...args));
1744
+ }
1745
+ return false;
1746
+ };
1583
1747
  /**
1584
1748
  * Handle a node whose tag is forbidden or not allowlisted: keep
1585
1749
  * allowed custom elements (false return exits _sanitizeElements
@@ -1599,13 +1763,8 @@ function createDOMPurify() {
1599
1763
  */
1600
1764
  const _sanitizeDisallowedNode = function _sanitizeDisallowedNode(currentNode, tagName, root) {
1601
1765
  /* Check if we have a custom element to handle */
1602
- if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName)) {
1603
- if (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)) {
1604
- return false;
1605
- }
1606
- if (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.tagNameCheck(tagName)) {
1607
- return false;
1608
- }
1766
+ if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName) && _matchesNameCheck(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)) {
1767
+ return false;
1609
1768
  }
1610
1769
  /* Keep content except for bad-listed elements.
1611
1770
  Use the cached prototype getters exclusively — the previous code
@@ -1667,6 +1826,46 @@ function createDOMPurify() {
1667
1826
  }
1668
1827
  return set === defaultSet || set === setConfigSet ? clone(set) : set;
1669
1828
  };
1829
+ /**
1830
+ * Shared guard for a node that a hook has detached from the walk tree,
1831
+ * used after each element-hook site in _sanitizeElements. Detaching is a
1832
+ * long-standing user pattern (issue #469; draw.io-style foreignObject
1833
+ * filtering). Per the cached, unclobberable parentNode getter the node is
1834
+ * genuinely out of the tree, so it can reach neither the serialized
1835
+ * output nor an IN_PLACE live tree; treat it as removed and stop
1836
+ * processing it. Without this guard, the unsafe-node / namespace checks
1837
+ * would call _forceRemove on a parentless node and hit the REPORT-3
1838
+ * fail-closed throw — which exists for nodes DOMPurify wants gone but
1839
+ * *cannot* detach (clobbered / parentless roots), the opposite of a node
1840
+ * that is already safely gone. The walk root is exempt: a detached
1841
+ * IN_PLACE root is legitimate input and must still be fully sanitized,
1842
+ * and a kill-decision on it must keep hitting the REPORT-3 throw.
1843
+ *
1844
+ * Nodes detached by hooks stay the hook's responsibility for placement:
1845
+ * they are not recorded in DOMPurify.removed, so the post-walk IN_PLACE
1846
+ * pass (which iterates DOMPurify.removed) does not reach them. But a
1847
+ * hook-detached subtree can still hold a queued resource-event handler -
1848
+ * e.g. an <img onload> that began loading when the caller built the live
1849
+ * tree - which fires in page scope after sanitize returns even though the
1850
+ * handler never reached the returned tree. That is the audit-5 F1 hazard,
1851
+ * and the documented node.remove() hook pattern walks straight into it.
1852
+ * So on the IN_PLACE path we neutralize the detached subtree inline,
1853
+ * stripping its non-allow-listed attributes before returning, exactly as
1854
+ * the post-walk pass does for _forceRemove'd subtrees.
1855
+ *
1856
+ * @param currentNode the node a hook may have detached
1857
+ * @param root the current walk root
1858
+ * @return true if the node is detached and now handled, false otherwise
1859
+ */
1860
+ const _handleHookDetachedNode = function _handleHookDetachedNode(currentNode, root) {
1861
+ if (currentNode === root || getParentNode(currentNode) !== null) {
1862
+ return false;
1863
+ }
1864
+ if (IN_PLACE) {
1865
+ _neutralizeSubtree(currentNode);
1866
+ }
1867
+ return true;
1868
+ };
1670
1869
  /**
1671
1870
  * _sanitizeElements
1672
1871
  *
@@ -1676,18 +1875,12 @@ function createDOMPurify() {
1676
1875
  * @param currentNode to check for permission to exist
1677
1876
  * @return true if node was killed, false if left alive
1678
1877
  */
1679
- // eslint-disable-next-line complexity
1680
1878
  const _sanitizeElements = function _sanitizeElements(currentNode, root) {
1681
1879
  /* Execute a hook if present */
1682
1880
  _executeHooks(hooks.beforeSanitizeElements, currentNode, null);
1683
- /* A hook may have detached the node - treat it as removed (see the
1684
- detached-node comment after the uponSanitizeElement hook below). On
1685
- the IN_PLACE path, neutralize the detached subtree first so a queued
1686
- resource handler on it cannot fire in page scope after we return. */
1687
- if (currentNode !== root && getParentNode(currentNode) === null) {
1688
- if (IN_PLACE) {
1689
- _neutralizeSubtree(currentNode);
1690
- }
1881
+ /* A hook may have detached the node - treat it as removed (see
1882
+ _handleHookDetachedNode for the full rationale). */
1883
+ if (_handleHookDetachedNode(currentNode, root)) {
1691
1884
  return true;
1692
1885
  }
1693
1886
  /* Check if element is clobbered or can clobber */
@@ -1696,7 +1889,7 @@ function createDOMPurify() {
1696
1889
  return true;
1697
1890
  }
1698
1891
  /* Now let's check the element's type and name */
1699
- const tagName = transformCaseFunc(getNodeName ? getNodeName(currentNode) : currentNode.nodeName);
1892
+ const tagName = transformCaseFunc(_readNodeName(currentNode));
1700
1893
  /* Close the pre-walk clone-guard's timing gap: an uponSanitizeElement
1701
1894
  hook may have been installed after that guard sampled the hook arrays
1702
1895
  (e.g. lazily from beforeSanitizeElements), leaving ALLOWED_TAGS still
@@ -1708,34 +1901,9 @@ function createDOMPurify() {
1708
1901
  tagName,
1709
1902
  allowedTags: ALLOWED_TAGS
1710
1903
  });
1711
- /* A hook may have detached the node from the tree — a long-standing
1712
- user pattern (issue #469; draw.io-style foreignObject filtering).
1713
- Per the cached, unclobberable parentNode getter the node is
1714
- genuinely out of the tree, so it can reach neither the serialized
1715
- output nor an IN_PLACE live tree; treat it as removed and stop
1716
- processing it. Without this guard, the unsafe-node / namespace
1717
- checks below would call _forceRemove on a parentless node and hit
1718
- the REPORT-3 fail-closed throw — which exists for nodes DOMPurify
1719
- wants gone but *cannot* detach (clobbered / parentless roots), the
1720
- opposite of a node that is already safely gone. The walk root is
1721
- exempt: a detached IN_PLACE root is legitimate input and must still
1722
- be fully sanitized, and a kill-decision on it must keep hitting the
1723
- REPORT-3 throw. Nodes detached by hooks stay the hook's
1724
- responsibility for placement: they are not recorded in
1725
- DOMPurify.removed, so the post-walk IN_PLACE pass (which iterates
1726
- DOMPurify.removed) does not reach them. But a hook-detached subtree
1727
- can still hold a queued resource-event handler - e.g. an <img onload>
1728
- that began loading when the caller built the live tree - which fires
1729
- in page scope after sanitize returns even though the handler never
1730
- reached the returned tree. That is the audit-5 F1 hazard, and the
1731
- documented node.remove() hook pattern walks straight into it. So on
1732
- the IN_PLACE path we neutralize the detached subtree inline here,
1733
- stripping its non-allow-listed attributes before returning, exactly
1734
- as the post-walk pass does for _forceRemove'd subtrees. */
1735
- if (currentNode !== root && getParentNode(currentNode) === null) {
1736
- if (IN_PLACE) {
1737
- _neutralizeSubtree(currentNode);
1738
- }
1904
+ /* The uponSanitizeElement hook may have detached the node, exactly as
1905
+ above (see _handleHookDetachedNode for the full rationale). */
1906
+ if (_handleHookDetachedNode(currentNode, root)) {
1739
1907
  return true;
1740
1908
  }
1741
1909
  /* Remove mXSS vectors, processing instructions and risky comments */
@@ -1768,7 +1936,7 @@ function createDOMPurify() {
1768
1936
  bound and short-circuits to false for any node minted in a different
1769
1937
  realm — letting a foreign-realm element with a forbidden namespace
1770
1938
  slip past the namespace check entirely. */
1771
- const nt = getNodeType ? getNodeType(currentNode) : currentNode.nodeType;
1939
+ const nt = _readNodeType(currentNode);
1772
1940
  if (nt === NODE_TYPE.element && !_checkValidNamespace(currentNode)) {
1773
1941
  _forceRemove(currentNode);
1774
1942
  return true;
@@ -1828,10 +1996,7 @@ function createDOMPurify() {
1828
1996
  other structural-threat checks and stays overridable, consistent with
1829
1997
  the rest of the codebase. PI range markers are already removed by
1830
1998
  _isUnsafeNode. */
1831
- if (SAFE_FOR_XML && lcName === 'patchsrc') {
1832
- return false;
1833
- }
1834
- if (SAFE_FOR_XML && lcName === 'for' && lcTag !== 'label' && lcTag !== 'output') {
1999
+ if (_isPatchLinkageAttribute(lcName, lcTag)) {
1835
2000
  return false;
1836
2001
  }
1837
2002
  /* Make sure attribute cannot clobber */
@@ -1843,22 +2008,54 @@ function createDOMPurify() {
1843
2008
  (https://html.spec.whatwg.org/multipage/dom.html#embedding-custom-non-visible-data-with-the-data-*-attributes)
1844
2009
  XML-compatible (https://html.spec.whatwg.org/multipage/infrastructure.html#xml-compatible and http://www.w3.org/TR/xml/#d0e804)
1845
2010
  We don't need to check the value; it's always URI safe. */
1846
- if (ALLOW_DATA_ATTR && regExpTest(DATA_ATTR$1, lcName)) ; else if (ALLOW_ARIA_ATTR && regExpTest(ARIA_ATTR$1, lcName)) ; else if (!nameIsPermitted) {
1847
- if (
1848
- // First condition does a very basic check if a) it's basically a valid custom element tagname AND
1849
- // b) if the tagName passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck
1850
- // and c) if the attribute name passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.attributeNameCheck
1851
- _isBasicCustomElement(lcTag) && (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, lcTag) || CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.tagNameCheck(lcTag)) && (CUSTOM_ELEMENT_HANDLING.attributeNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.attributeNameCheck, lcName) || CUSTOM_ELEMENT_HANDLING.attributeNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.attributeNameCheck(lcName, lcTag)) ||
1852
- // Alternative, second condition checks if it's an `is`-attribute, AND
1853
- // the value passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck
1854
- lcName === 'is' && CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements && (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, value) || CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.tagNameCheck(value))) ; else {
1855
- return false;
1856
- }
1857
- /* Check value is safe. First, is attr inert? If so, is safe */
1858
- } else if (URI_SAFE_ATTRIBUTES[lcName]) ; else if (regExpTest(IS_ALLOWED_URI$1, stringReplace(value, ATTR_WHITESPACE$1, ''))) ; else if ((lcName === 'src' || lcName === 'xlink:href' || lcName === 'href') && lcTag !== 'script' && stringIndexOf(value, 'data:') === 0 && DATA_URI_TAGS[lcTag]) ; else if (ALLOW_UNKNOWN_PROTOCOLS && !regExpTest(IS_SCRIPT_OR_DATA$1, stringReplace(value, ATTR_WHITESPACE$1, ''))) ; else if (value) {
1859
- return false;
1860
- } else ;
1861
- return true;
2011
+ if (ALLOW_DATA_ATTR && regExpTest(DATA_ATTR$1, lcName)) {
2012
+ return true;
2013
+ }
2014
+ /* Allow valid aria-* attributes, the value is always URI safe */
2015
+ if (ALLOW_ARIA_ATTR && regExpTest(ARIA_ATTR$1, lcName)) {
2016
+ return true;
2017
+ }
2018
+ /* A name outside the allowlist is acceptable on custom-element terms
2019
+ only. The value checks below are intentionally skipped in that case:
2020
+ if the user supplied a tagNameCheck we also allow derived custom
2021
+ elements using the same test, and attributes passing the configured
2022
+ attributeNameCheck are allowed as custom elements define these at
2023
+ their own discretion. */
2024
+ if (!nameIsPermitted) {
2025
+ return (
2026
+ // Condition a) covers a basically valid custom element tag name whose
2027
+ // tag passes the configured tagNameCheck and whose attribute name
2028
+ // passes the configured attributeNameCheck ...
2029
+ _isBasicCustomElement(lcTag) && _matchesNameCheck(CUSTOM_ELEMENT_HANDLING.tagNameCheck, lcTag) && _matchesNameCheck(CUSTOM_ELEMENT_HANDLING.attributeNameCheck, lcName, lcTag) ||
2030
+ // Condition b) covers an `is` attribute whose value passes the
2031
+ // configured tagNameCheck while customized built-in elements are
2032
+ // allowed.
2033
+ lcName === 'is' && CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements && _matchesNameCheck(CUSTOM_ELEMENT_HANDLING.tagNameCheck, value)
2034
+ );
2035
+ }
2036
+ /* Check value is safe. First, is attr inert? If so, is safe */
2037
+ if (URI_SAFE_ATTRIBUTES[lcName]) {
2038
+ return true;
2039
+ }
2040
+ /* Check no script, data or unknown possibly unsafe URI
2041
+ unless we know URI values are safe for that attribute */
2042
+ if (regExpTest(IS_ALLOWED_URI$1, stringReplace(value, ATTR_WHITESPACE$1, ''))) {
2043
+ return true;
2044
+ }
2045
+ /* Keep image data URIs alive if src/xlink:href is allowed */
2046
+ /* Further prevent gadget XSS for dynamically built script tags */
2047
+ if ((lcName === 'src' || lcName === 'xlink:href' || lcName === 'href') && lcTag !== 'script' && stringIndexOf(value, 'data:') === 0 && DATA_URI_TAGS[lcTag]) {
2048
+ return true;
2049
+ }
2050
+ /* Allow unknown protocols: This provides support for links that
2051
+ are handled by protocol handlers which may be unknown ahead of
2052
+ time, e.g. fb:, spotify: */
2053
+ if (ALLOW_UNKNOWN_PROTOCOLS && !regExpTest(IS_SCRIPT_OR_DATA$1, stringReplace(value, ATTR_WHITESPACE$1, ''))) {
2054
+ return true;
2055
+ }
2056
+ /* Only an empty (binary) value remains safe at this point;
2057
+ anything else is presumed unsafe, do not add it back */
2058
+ return !value;
1862
2059
  };
1863
2060
  /* Names the HTML spec reserves from valid-custom-element-name; these must
1864
2061
  * never be treated as basic custom elements even when a permissive
@@ -1905,15 +2102,25 @@ function createDOMPurify() {
1905
2102
  /**
1906
2103
  * Write a modified attribute value back onto the element. On
1907
2104
  * success, re-probe for clobbering introduced by the new value and
1908
- * remove the element when found; otherwise pop the removal entry
1909
- * recorded by the earlier _removeAttribute (long-standing pairing
1910
- * with the SANITIZE_NAMED_PROPS path - do not "fix" casually). On
2105
+ * remove the element when found; otherwise, when this writeback is the
2106
+ * recreate half of the SANITIZE_NAMED_PROPS remove-and-recreate, pop the
2107
+ * removal entry that path recorded so it does not show as removed. On
1911
2108
  * failure, remove the attribute instead.
1912
2109
  *
2110
+ * Returns true only on a clean write (the value was set and the new value
2111
+ * introduced no clobbering). The caller uses that, together with its own
2112
+ * knowledge of whether this attribute pushed a DOMPurify.removed record, to
2113
+ * decide whether to pop that record. The pop must happen ONLY for the
2114
+ * named-prop remove-and-recreate; popping on any other value change (trim,
2115
+ * template scrubbing, Trusted Types) would consume an unrelated _forceRemove
2116
+ * subtree-cleanup record and let that detached subtree keep a live event
2117
+ * handler through the IN_PLACE neutralization pass (SO-001).
2118
+ *
1913
2119
  * @param currentNode the element carrying the attribute
1914
2120
  * @param name the attribute name as present on the element
1915
2121
  * @param namespaceURI the attribute's namespace, if any
1916
2122
  * @param value the new attribute value
2123
+ * @return true if the value was written without introducing clobbering
1917
2124
  */
1918
2125
  const _setAttributeValue = function _setAttributeValue(currentNode, name, namespaceURI, value) {
1919
2126
  try {
@@ -1925,11 +2132,12 @@ function createDOMPurify() {
1925
2132
  }
1926
2133
  if (_isClobbered(currentNode)) {
1927
2134
  _forceRemove(currentNode);
1928
- } else {
1929
- arrayPop(DOMPurify.removed);
2135
+ return false;
1930
2136
  }
2137
+ return true;
1931
2138
  } catch (_) {
1932
2139
  _removeAttribute(name, currentNode);
2140
+ return false;
1933
2141
  }
1934
2142
  };
1935
2143
  /**
@@ -1942,6 +2150,7 @@ function createDOMPurify() {
1942
2150
  *
1943
2151
  * @param currentNode to sanitize
1944
2152
  */
2153
+ // eslint-disable-next-line complexity
1945
2154
  const _sanitizeAttributes = function _sanitizeAttributes(currentNode) {
1946
2155
  /* Execute a hook if present */
1947
2156
  _executeHooks(hooks.beforeSanitizeAttributes, currentNode, null);
@@ -1971,6 +2180,15 @@ function createDOMPurify() {
1971
2180
  const lcName = transformCaseFunc(name);
1972
2181
  const initValue = attrValue;
1973
2182
  let value = name === 'value' ? initValue : stringTrim(initValue);
2183
+ /* Tracks whether this specific attribute pushed a DOMPurify.removed
2184
+ record that a later _setAttributeValue() writeback is expected to pop
2185
+ back off (the SANITIZE_NAMED_PROPS remove-and-recreate below is the
2186
+ only path that does). A value that merely changed via trim, template
2187
+ scrubbing, or Trusted Types pushes no such record, so its writeback
2188
+ must NOT pop — otherwise it consumes an unrelated _forceRemove subtree
2189
+ record and that detached subtree escapes the IN_PLACE handler-
2190
+ neutralization pass (SO-001). */
2191
+ let recreatedNamedProp = false;
1974
2192
  /* Execute a hook if present */
1975
2193
  hookEvent.attrName = lcName;
1976
2194
  hookEvent.attrValue = value;
@@ -1983,20 +2201,23 @@ function createDOMPurify() {
1983
2201
  */
1984
2202
  if (SANITIZE_NAMED_PROPS && (lcName === 'id' || lcName === 'name') && stringIndexOf(value, SANITIZE_NAMED_PROPS_PREFIX) !== 0) {
1985
2203
  // Remove the attribute with this value
1986
- _removeAttribute(name, currentNode);
2204
+ _removeAttribute(name, currentNode, attr);
1987
2205
  // Prefix the value and later re-create the attribute with the sanitized value
1988
2206
  value = SANITIZE_NAMED_PROPS_PREFIX + value;
2207
+ // This attribute owns the record just pushed; its recreate writeback
2208
+ // is the one allowed to pop it back off.
2209
+ recreatedNamedProp = true;
1989
2210
  }
1990
2211
  // Else: already prefixed, leave the attribute alone — the prefix is
1991
2212
  // itself the clobbering protection, and re-applying it is incorrect.
1992
2213
  /* Work around a security issue with comments inside attributes */
1993
2214
  if (SAFE_FOR_XML && regExpTest(/((--!?|])>)|<\/(style|script|title|xmp|textarea|noscript|iframe|noembed|noframes)/i, value)) {
1994
- _removeAttribute(name, currentNode);
2215
+ _removeAttribute(name, currentNode, attr);
1995
2216
  continue;
1996
2217
  }
1997
2218
  /* Make sure we cannot easily use animated hrefs, even if animations are allowed */
1998
2219
  if (lcName === 'attributename' && stringMatch(value, 'href')) {
1999
- _removeAttribute(name, currentNode);
2220
+ _removeAttribute(name, currentNode, attr);
2000
2221
  continue;
2001
2222
  }
2002
2223
  /* Did the hooks force-keep the attribute? */
@@ -2005,12 +2226,12 @@ function createDOMPurify() {
2005
2226
  }
2006
2227
  /* Did the hooks approve of the attribute? */
2007
2228
  if (!hookEvent.keepAttr) {
2008
- _removeAttribute(name, currentNode);
2229
+ _removeAttribute(name, currentNode, attr);
2009
2230
  continue;
2010
2231
  }
2011
2232
  /* Work around a security issue in jQuery 3.0 */
2012
2233
  if (!ALLOW_SELF_CLOSE_IN_ATTR && regExpTest(SELF_CLOSING_TAG, value)) {
2013
- _removeAttribute(name, currentNode);
2234
+ _removeAttribute(name, currentNode, attr);
2014
2235
  continue;
2015
2236
  }
2016
2237
  /* Sanitize attribute content to be template-safe */
@@ -2019,14 +2240,21 @@ function createDOMPurify() {
2019
2240
  }
2020
2241
  /* Is `value` valid for this attribute? */
2021
2242
  if (!_isValidAttribute(lcTag, lcName, value)) {
2022
- _removeAttribute(name, currentNode);
2243
+ _removeAttribute(name, currentNode, attr);
2023
2244
  continue;
2024
2245
  }
2025
2246
  /* Handle attributes that require Trusted Types */
2026
2247
  value = _applyTrustedTypesToAttribute(lcTag, lcName, namespaceURI, value);
2027
2248
  /* Handle invalid data-* attribute set by try-catching it */
2028
2249
  if (value !== initValue) {
2029
- _setAttributeValue(currentNode, name, namespaceURI, value);
2250
+ const cleanWrite = _setAttributeValue(currentNode, name, namespaceURI, value);
2251
+ /* Only the named-prop remove-and-recreate owns a DOMPurify.removed
2252
+ record to pop; a plain trim/template/Trusted-Types writeback owns
2253
+ none, so popping here would drop an unrelated subtree's cleanup
2254
+ record (SO-001). */
2255
+ if (cleanWrite && recreatedNamedProp) {
2256
+ arrayPop(DOMPurify.removed);
2257
+ }
2030
2258
  }
2031
2259
  }
2032
2260
  /* Execute a hook if present */
@@ -2066,8 +2294,7 @@ function createDOMPurify() {
2066
2294
  Walk it explicitly. The nodeType guard avoids reading
2067
2295
  shadowRoot off text / comment / CDATA / PI nodes that the
2068
2296
  iterator also surfaces. */
2069
- const shadowNodeType = getNodeType ? getNodeType(shadowNode) : shadowNode.nodeType;
2070
- if (shadowNodeType === NODE_TYPE.element) {
2297
+ if (_readNodeType(shadowNode) === NODE_TYPE.element) {
2071
2298
  const innerSr = getShadowRoot(shadowNode);
2072
2299
  if (_isDocumentFragment(innerSr)) {
2073
2300
  _sanitizeAttachedShadowRoots(innerSr);
@@ -2125,7 +2352,7 @@ function createDOMPurify() {
2125
2352
  continue;
2126
2353
  }
2127
2354
  const node = item.node;
2128
- const nodeType = getNodeType ? getNodeType(node) : node.nodeType;
2355
+ const nodeType = _readNodeType(node);
2129
2356
  const isElement = nodeType === NODE_TYPE.element;
2130
2357
  /* (pushed last → processed first) Children, snapshotted in reverse so
2131
2358
  the first child is processed first. Snapshotting matters because a
@@ -2247,7 +2474,7 @@ function createDOMPurify() {
2247
2474
  child named "nodeName" on the form root would otherwise shadow
2248
2475
  the property and let this check skip the root-allowlist
2249
2476
  validation entirely. */
2250
- const nn = getNodeName ? getNodeName(dirty) : dirty.nodeName;
2477
+ const nn = _readNodeName(dirty);
2251
2478
  if (typeof nn === 'string') {
2252
2479
  const tagName = transformCaseFunc(nn);
2253
2480
  if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) {
@@ -2303,8 +2530,15 @@ function createDOMPurify() {
2303
2530
  them before the main iterator runs, since the iterator does not
2304
2531
  descend into shadow trees. The walk routes every read through a
2305
2532
  cached prototype getter so clobbering descendants on a form root
2306
- cannot hide a shadow host from this pass. */
2307
- _sanitizeAttachedShadowRoots(importedNode);
2533
+ cannot hide a shadow host from this pass.
2534
+ Traverse from `body`, not `importedNode`: when importedNode is a
2535
+ DocumentFragment, the appendChild() above moves its children into
2536
+ `body` and leaves the fragment empty, so walking importedNode would
2537
+ scan nothing and miss every attached shadow host now under `body`
2538
+ (SO-003). In the BODY/HTML branches `body === importedNode`, so this
2539
+ is equivalent there; in the element branch `body` contains the
2540
+ appended element and its descendants. `body` covers all three. */
2541
+ _sanitizeAttachedShadowRoots(body);
2308
2542
  } else {
2309
2543
  /* Exit directly if we have nothing to do */
2310
2544
  if (!RETURN_DOM && !SAFE_FOR_TEMPLATES && !WHOLE_DOCUMENT &&