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