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.
package/dist/purify.js CHANGED
@@ -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 (global, factory) {
4
4
  typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
@@ -317,7 +317,7 @@
317
317
  const text = freeze(['#text']);
318
318
 
319
319
  const html = freeze(['accept', 'action', 'align', 'alt', 'autocapitalize', 'autocomplete', 'autopictureinpicture', 'autoplay', 'background', 'bgcolor', 'border', 'capture', 'cellpadding', 'cellspacing', 'checked', 'cite', 'class', 'clear', 'color', 'cols', 'colspan', 'command', 'commandfor', 'controls', 'controlslist', 'coords', 'crossorigin', 'datetime', 'decoding', 'default', 'dir', 'disabled', 'disablepictureinpicture', 'disableremoteplayback', 'download', 'draggable', 'enctype', 'enterkeyhint', 'exportparts', 'face', 'for', 'headers', 'height', 'hidden', 'high', 'href', 'hreflang', 'id', 'inert', 'inputmode', 'integrity', 'ismap', 'kind', 'label', 'lang', 'list', 'loading', 'loop', 'low', 'max', 'maxlength', 'media', 'method', 'min', 'minlength', 'multiple', 'muted', 'name', 'nonce', 'noshade', 'novalidate', 'nowrap', 'open', 'optimum', 'part', 'pattern', 'placeholder', 'playsinline', 'popover', 'popovertarget', 'popovertargetaction', 'poster', 'preload', 'pubdate', 'radiogroup', 'readonly', 'rel', 'required', 'rev', 'reversed', 'role', 'rows', 'rowspan', 'spellcheck', 'scope', 'selected', 'shape', 'size', 'sizes', 'slot', 'span', 'srclang', 'start', 'src', 'srcset', 'step', 'style', 'summary', 'tabindex', 'title', 'translate', 'type', 'usemap', 'valign', 'value', 'width', 'wrap', 'xmlns']);
320
- const svg = freeze(['accent-height', 'accumulate', 'additive', 'alignment-baseline', 'amplitude', 'ascent', 'attributename', 'attributetype', 'azimuth', 'basefrequency', 'baseline-shift', 'begin', 'bias', 'by', 'class', 'clip', 'clippathunits', 'clip-path', 'clip-rule', 'color', 'color-interpolation', 'color-interpolation-filters', 'color-profile', 'color-rendering', 'cx', 'cy', 'd', 'dx', 'dy', 'diffuseconstant', 'direction', 'display', 'divisor', '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']);
320
+ const svg = freeze(['accent-height', 'accumulate', 'additive', 'alignment-baseline', 'amplitude', 'ascent', 'attributename', 'attributetype', 'azimuth', 'basefrequency', 'baseline-shift', 'begin', 'bias', 'by', 'class', 'clip', 'clippathunits', 'clip-path', 'clip-rule', 'color', 'color-interpolation', 'color-interpolation-filters', 'color-profile', 'color-rendering', 'cx', 'cy', 'd', 'dx', 'dy', 'diffuseconstant', 'direction', 'display', 'divisor', 'dominant-baseline', 'dur', 'edgemode', 'elevation', 'end', 'exponent', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'filterunits', 'flood-color', 'flood-opacity', 'font-family', 'font-size', 'font-size-adjust', 'font-stretch', 'font-style', 'font-variant', 'font-weight', 'fx', 'fy', 'g1', 'g2', 'glyph-name', 'glyphref', 'gradientunits', 'gradienttransform', 'height', 'href', 'id', 'image-rendering', 'in', 'in2', 'intercept', 'k', 'k1', 'k2', 'k3', 'k4', 'kerning', 'keypoints', 'keysplines', 'keytimes', 'lang', 'lengthadjust', 'letter-spacing', 'kernelmatrix', 'kernelunitlength', 'lighting-color', 'local', 'marker-end', 'marker-mid', 'marker-start', 'markerheight', 'markerunits', 'markerwidth', 'maskcontentunits', 'maskunits', 'max', 'mask', 'mask-type', 'media', 'method', 'mode', 'min', 'name', 'numoctaves', 'offset', 'operator', 'opacity', 'order', 'orient', 'orientation', 'origin', 'overflow', 'paint-order', 'path', 'pathlength', 'patterncontentunits', 'patterntransform', 'patternunits', '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']);
321
321
  const mathMl = freeze(['accent', 'accentunder', 'align', 'bevelled', 'close', 'columnalign', 'columnlines', 'columnspacing', 'columnspan', 'denomalign', 'depth', 'dir', 'display', 'displaystyle', 'encoding', 'fence', 'frame', 'height', 'href', 'id', 'largeop', 'length', 'linethickness', 'lquote', 'lspace', 'mathbackground', 'mathcolor', 'mathsize', 'mathvariant', 'maxsize', 'minsize', 'movablelimits', 'notation', 'numalign', 'open', 'rowalign', 'rowlines', 'rowspacing', 'rowspan', 'rspace', 'rquote', 'scriptlevel', 'scriptminsize', 'scriptsizemultiplier', 'selection', 'separator', 'separators', 'stretchy', 'subscriptshift', 'supscriptshift', 'symmetric', 'voffset', 'width', 'xmlns']);
322
322
  const xml = freeze(['xlink:href', 'xml:id', 'xlink:title', 'xml:space', 'xmlns:xlink']);
323
323
 
@@ -358,6 +358,37 @@
358
358
  documentFragment: 11,
359
359
  notation: 12 // Deprecated
360
360
  };
361
+ /* HTML-namespace elements whose child text nodes are serialized *literally*
362
+ (unescaped) by the HTML fragment-serialization algorithm. Two reparse-mXSS
363
+ shapes ride on that literal serialization:
364
+ (a) an element child - a tree the HTML parser can never build, but the DOM
365
+ API and an XML/XHTML parse can - after which a `</tag>`-bearing text
366
+ sibling breaks the element open on reparse; and
367
+ (b) text-only content that already carries the element's OWN end tag, e.g.
368
+ `<style>...</style><img onerror=x>` built as a node, which the literal
369
+ serializer emits verbatim for the HTML parser to re-open.
370
+ Shape (a) is handled by the firstElementChild branch in _isUnsafeNode; shape
371
+ (b) by the LITERAL_TEXT_CLOSE probe. Both read textContent (the raw-serialized
372
+ form for these elements) rather than innerHTML, because an XML/XHTML working
373
+ document serializes innerHTML with `<` escaped, which silently blinds the
374
+ innerHTML-based probes (rule 1's second probe and FALLBACK_TAG_CLOSE) there.
375
+ `script` is never allow-listed, but is kept here so the guard matches the
376
+ serializer's own literal-text list exactly. */
377
+ const LITERAL_TEXT_ELEMENT_NAMES = ['style', 'script', 'xmp', 'iframe', 'noembed', 'noframes', 'plaintext', 'noscript'];
378
+ const LITERAL_TEXT_ELEMENTS = freeze(addToSet({}, LITERAL_TEXT_ELEMENT_NAMES));
379
+ /* Per-element end-tag matcher. On an HTML reparse the ONLY token that
380
+ terminates a literal-text element's raw content is its own end tag; a foreign
381
+ literal-text close (e.g. `</xmp>` sitting inside `<style>`) does not break
382
+ out, so matching is per-element, not a shared alternation. The lookahead
383
+ requires an HTML tag-name terminator (whitespace, `/` or `>`) so a longer
384
+ name such as `</styles` is not mistaken for `</style`. */
385
+ const LITERAL_TEXT_CLOSE = function () {
386
+ const map = {};
387
+ arrayForEach(LITERAL_TEXT_ELEMENT_NAMES, name => {
388
+ map[name] = seal(new RegExp('</' + name + '(?=[\\t\\n\\f\\r />])', 'i'));
389
+ });
390
+ return freeze(map);
391
+ }();
361
392
  const getGlobal = function getGlobal() {
362
393
  return typeof window === 'undefined' ? null : window;
363
394
  };
@@ -427,10 +458,25 @@
427
458
  const _resolveSetOption = function _resolveSetOption(cfg, key, fallback, options) {
428
459
  return objectHasOwnProperty(cfg, key) && arrayIsArray(cfg[key]) ? addToSet(options.base ? clone(options.base) : {}, cfg[key], options.transform) : fallback;
429
460
  };
461
+ /**
462
+ * Resolve an object-valued configuration option: a prototype-free clone
463
+ * of cfg[key] when it is an own, truthy object property, else a fresh
464
+ * fallback built by makeFallback (fresh on every parse, so a previous
465
+ * parse can never leak state into the next one).
466
+ *
467
+ * @param cfg the cloned, prototype-free configuration object
468
+ * @param key the configuration property to read
469
+ * @param makeFallback builds the fallback value when the option is absent
470
+ * @returns the resolved object
471
+ */
472
+ const _resolveObjectOption = function _resolveObjectOption(cfg, key, makeFallback) {
473
+ const value = objectHasOwnProperty(cfg, key) ? cfg[key] : undefined;
474
+ return value && typeof value === 'object' ? clone(value) : makeFallback();
475
+ };
430
476
  function createDOMPurify() {
431
477
  let window = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : getGlobal();
432
478
  const DOMPurify = root => createDOMPurify(root);
433
- DOMPurify.version = '3.4.13';
479
+ DOMPurify.version = '3.4.15';
434
480
  DOMPurify.removed = [];
435
481
  if (!window || !window.document || window.document.nodeType !== NODE_TYPE.document || !window.Element) {
436
482
  // Not running in a browser, provide a factory function
@@ -454,6 +500,12 @@
454
500
  const ElementPrototype = Element.prototype;
455
501
  const cloneNode = lookupGetter(ElementPrototype, 'cloneNode');
456
502
  const remove = lookupGetter(ElementPrototype, 'remove');
503
+ // Clobber-safe Attr-node removal. On an HTMLFormElement a descendant named
504
+ // "removeAttributeNode" shadows the prototype method via
505
+ // [LegacyOverrideBuiltIns], so element.removeAttributeNode(attr) throws.
506
+ // Calling the cached Element.prototype method with the element as the
507
+ // receiver removes the exact live Attr node regardless of the shadowing.
508
+ const removeAttributeNode = lookupGetter(ElementPrototype, 'removeAttributeNode');
457
509
  const getNextSibling = lookupGetter(ElementPrototype, 'nextSibling');
458
510
  const getChildNodes = lookupGetter(ElementPrototype, 'childNodes');
459
511
  const getParentNode = lookupGetter(ElementPrototype, 'parentNode');
@@ -462,6 +514,16 @@
462
514
  const getNodeType = Node && Node.prototype ? lookupGetter(Node.prototype, 'nodeType') : null;
463
515
  const getNodeName = Node && Node.prototype ? lookupGetter(Node.prototype, 'nodeName') : null;
464
516
  const getOwnerDocument = Node && Node.prototype ? lookupGetter(Node.prototype, 'ownerDocument') : null;
517
+ /* Clobber-safe nodeType / nodeName reads through the cached Node.prototype
518
+ getters, with a direct-property fallback for environments that lack
519
+ Node.prototype. Sites that need a different fallback (e.g. _isClobbered
520
+ returns early on a null name) intentionally keep their own reads. */
521
+ const _readNodeType = function _readNodeType(node) {
522
+ return getNodeType ? getNodeType(node) : node.nodeType;
523
+ };
524
+ const _readNodeName = function _readNodeName(node) {
525
+ return getNodeName ? getNodeName(node) : node.nodeName;
526
+ };
465
527
  // As per issue #47, the web-components registry is inherited by a
466
528
  // new document created via createHTMLDocument. As per the spec
467
529
  // (http://w3c.github.io/webcomponents/spec/custom/#creating-and-passing-registries)
@@ -784,9 +846,11 @@
784
846
  IN_PLACE = cfg.IN_PLACE || false; // Default false
785
847
  IS_ALLOWED_URI$1 = isRegex(cfg.ALLOWED_URI_REGEXP) ? cfg.ALLOWED_URI_REGEXP : IS_ALLOWED_URI; // Default regexp
786
848
  NAMESPACE = typeof cfg.NAMESPACE === 'string' ? cfg.NAMESPACE : HTML_NAMESPACE; // Default HTML namespace
787
- 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
788
- 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
789
- const customElementHandling = objectHasOwnProperty(cfg, 'CUSTOM_ELEMENT_HANDLING') && cfg.CUSTOM_ELEMENT_HANDLING && typeof cfg.CUSTOM_ELEMENT_HANDLING === 'object' ? clone(cfg.CUSTOM_ELEMENT_HANDLING) : create(null);
849
+ MATHML_TEXT_INTEGRATION_POINTS = _resolveObjectOption(cfg, 'MATHML_TEXT_INTEGRATION_POINTS', () => addToSet({}, DEFAULT_MATHML_TEXT_INTEGRATION_POINTS) // Default built-in map
850
+ );
851
+ HTML_INTEGRATION_POINTS = _resolveObjectOption(cfg, 'HTML_INTEGRATION_POINTS', () => addToSet({}, DEFAULT_HTML_INTEGRATION_POINTS) // Default built-in map
852
+ );
853
+ const customElementHandling = _resolveObjectOption(cfg, 'CUSTOM_ELEMENT_HANDLING', () => create(null));
790
854
  CUSTOM_ELEMENT_HANDLING = create(null);
791
855
  if (objectHasOwnProperty(customElementHandling, 'tagNameCheck') && isRegexOrFunction(customElementHandling.tagNameCheck)) {
792
856
  CUSTOM_ELEMENT_HANDLING.tagNameCheck = customElementHandling.tagNameCheck; // Default undefined
@@ -853,15 +917,6 @@
853
917
  addToSet(ALLOWED_ATTR, cfg.ADD_ATTR, transformCaseFunc);
854
918
  }
855
919
  }
856
- if (objectHasOwnProperty(cfg, 'ADD_URI_SAFE_ATTR') && arrayIsArray(cfg.ADD_URI_SAFE_ATTR)) {
857
- addToSet(URI_SAFE_ATTRIBUTES, cfg.ADD_URI_SAFE_ATTR, transformCaseFunc);
858
- }
859
- if (objectHasOwnProperty(cfg, 'FORBID_CONTENTS') && arrayIsArray(cfg.FORBID_CONTENTS)) {
860
- if (FORBID_CONTENTS === DEFAULT_FORBID_CONTENTS) {
861
- FORBID_CONTENTS = clone(FORBID_CONTENTS);
862
- }
863
- addToSet(FORBID_CONTENTS, cfg.FORBID_CONTENTS, transformCaseFunc);
864
- }
865
920
  if (objectHasOwnProperty(cfg, 'ADD_FORBID_CONTENTS') && arrayIsArray(cfg.ADD_FORBID_CONTENTS)) {
866
921
  if (FORBID_CONTENTS === DEFAULT_FORBID_CONTENTS) {
867
922
  FORBID_CONTENTS = clone(FORBID_CONTENTS);
@@ -1096,6 +1151,32 @@
1096
1151
  }
1097
1152
  }
1098
1153
  };
1154
+ /**
1155
+ * _stripAttributeNode
1156
+ *
1157
+ * Remove a single Attr node case/namespace-exactly on an attribute-teardown
1158
+ * path. Name-based removeAttribute() ASCII-lowercases its lookup key for an
1159
+ * HTML element in an HTML document and so silently misses a case-preserved
1160
+ * handler (e.g. `ONERROR` off an XML/XHTML import) - the same defect
1161
+ * _removeAttribute() was fixed for, which a name-based call would reintroduce
1162
+ * on these IN_PLACE teardown paths. Unlike _removeAttribute this does not
1163
+ * record into DOMPurify.removed: the neutralize passes intentionally do not
1164
+ * book-keep. A clobbered/detached node falls back to best-effort name-based
1165
+ * removal.
1166
+ *
1167
+ * @param element the element to strip the attribute from
1168
+ * @param attribute the Attr node to remove
1169
+ * @param name the attribute's name, for the fallback path
1170
+ */
1171
+ const _stripAttributeNode = function _stripAttributeNode(element, attribute, name) {
1172
+ try {
1173
+ removeAttributeNode(element, attribute);
1174
+ } catch (_) {
1175
+ try {
1176
+ element.removeAttribute(name);
1177
+ } catch (_) {}
1178
+ }
1179
+ };
1099
1180
  /**
1100
1181
  * _neutralizeRoot
1101
1182
  *
@@ -1140,11 +1221,7 @@
1140
1221
  const attribute = attributes[i];
1141
1222
  const name = attribute && attribute.name;
1142
1223
  if (typeof name === 'string') {
1143
- try {
1144
- root.removeAttribute(name);
1145
- } catch (_) {
1146
- /* Clobbered removeAttribute — ignore (fail-closed best effort) */
1147
- }
1224
+ _stripAttributeNode(root, attribute, name);
1148
1225
  }
1149
1226
  }
1150
1227
  }
@@ -1152,22 +1229,51 @@
1152
1229
  /**
1153
1230
  * _removeAttribute
1154
1231
  *
1232
+ * Name-based getAttributeNode()/removeAttribute() ASCII-lowercase their
1233
+ * lookup key for HTML elements in an HTML document, so they silently miss an
1234
+ * attribute whose stored qualified name still contains uppercase ASCII
1235
+ * letters. That happens when the node came from a case-preserving source
1236
+ * (an XML/XHTML document imported via importNode(), or createAttributeNS()),
1237
+ * where e.g. `ONERROR` survives the walk: the policy check lowercases to
1238
+ * `onerror` and rejects it, but `removeAttribute('ONERROR')` looks up
1239
+ * `onerror` and finds nothing. Remove the exact Attr node instead, which is
1240
+ * case- and namespace-exact, and fall back to name-based removal only when
1241
+ * the caller could not supply the node.
1242
+ *
1155
1243
  * @param name an Attribute name
1156
1244
  * @param element a DOM node
1245
+ * @param attr the exact Attr node to remove, when the caller has it
1157
1246
  */
1158
- const _removeAttribute = function _removeAttribute(name, element) {
1247
+ const _removeAttribute = function _removeAttribute(name, element, attr) {
1248
+ if (!attr) {
1249
+ try {
1250
+ attr = element.getAttributeNode(name);
1251
+ } catch (_) {
1252
+ attr = null;
1253
+ }
1254
+ }
1255
+ arrayPush(DOMPurify.removed, {
1256
+ attribute: attr || null,
1257
+ from: element
1258
+ });
1159
1259
  try {
1160
- arrayPush(DOMPurify.removed, {
1161
- attribute: element.getAttributeNode(name),
1162
- from: element
1163
- });
1260
+ if (attr) {
1261
+ removeAttributeNode(element, attr);
1262
+ } else {
1263
+ element.removeAttribute(name);
1264
+ }
1164
1265
  } catch (_) {
1165
- arrayPush(DOMPurify.removed, {
1166
- attribute: null,
1167
- from: element
1168
- });
1266
+ /* Clobbered or already-detached node - best-effort fall back to a
1267
+ name-based removal so the "is" handling below still runs. Note this
1268
+ fallback ASCII-lowercases its lookup key in an HTML document and so
1269
+ cannot reach a case-preserved attribute name; the cached
1270
+ removeAttributeNode() above is what removes those, and a clobbered
1271
+ form is already removed wholesale by _isClobbered() before reaching
1272
+ here. */
1273
+ try {
1274
+ element.removeAttribute(name);
1275
+ } catch (_) {}
1169
1276
  }
1170
- element.removeAttribute(name);
1171
1277
  // We void attribute values for unremovable "is" attributes
1172
1278
  if (name === 'is') {
1173
1279
  if (RETURN_DOM || RETURN_DOM_FRAGMENT) {
@@ -1202,11 +1308,7 @@
1202
1308
  if (typeof name !== 'string' || ALLOWED_ATTR[transformCaseFunc(name)]) {
1203
1309
  continue;
1204
1310
  }
1205
- try {
1206
- element.removeAttribute(name);
1207
- } catch (_) {
1208
- /* Clobbered removeAttribute on a doomed node — ignore */
1209
- }
1311
+ _stripAttributeNode(element, attribute, name);
1210
1312
  }
1211
1313
  };
1212
1314
  /**
@@ -1235,7 +1337,7 @@
1235
1337
  const stack = [root];
1236
1338
  while (stack.length > 0) {
1237
1339
  const node = stack.pop();
1238
- const nodeType = getNodeType ? getNodeType(node) : node.nodeType;
1340
+ const nodeType = _readNodeType(node);
1239
1341
  if (nodeType === NODE_TYPE.element) {
1240
1342
  _stripDisallowedAttributes(node);
1241
1343
  }
@@ -1282,6 +1384,26 @@
1282
1384
  *
1283
1385
  * @param root the in-place root to sweep
1284
1386
  */
1387
+ /**
1388
+ * Central policy for declarative-partial-updates patch-linkage attributes,
1389
+ * shared by the _neutralizePatchLinkage pre-pass and _isValidAttribute so
1390
+ * the two sites cannot drift: `patchsrc` always links, `for` links
1391
+ * everywhere except on <label>/<output>, and the whole policy is gated on
1392
+ * SAFE_FOR_XML (see the rationale block in _isValidAttribute).
1393
+ *
1394
+ * @param lcName the transformCaseFunc'd attribute name
1395
+ * @param lcTag the transformCaseFunc'd tag name of the carrying element
1396
+ * @return true if the attribute is patch linkage and must be dropped
1397
+ */
1398
+ const _isPatchLinkageAttribute = function _isPatchLinkageAttribute(lcName, lcTag) {
1399
+ if (!SAFE_FOR_XML) {
1400
+ return false;
1401
+ }
1402
+ if (lcName === 'patchsrc') {
1403
+ return true;
1404
+ }
1405
+ return lcName === 'for' && lcTag !== 'label' && lcTag !== 'output';
1406
+ };
1285
1407
  const _neutralizePatchLinkage = function _neutralizePatchLinkage(root) {
1286
1408
  if (!SAFE_FOR_XML) {
1287
1409
  return;
@@ -1289,7 +1411,7 @@
1289
1411
  const stack = [root];
1290
1412
  while (stack.length > 0) {
1291
1413
  const node = stack.pop();
1292
- const nodeType = getNodeType ? getNodeType(node) : node.nodeType;
1414
+ const nodeType = _readNodeType(node);
1293
1415
  /* Remove range markers (the target side of a patch linkage): every
1294
1416
  processing instruction, and any markup-bearing comment. */
1295
1417
  if (nodeType === NODE_TYPE.processingInstruction || nodeType === NODE_TYPE.comment && regExpTest(COMMENT_MARKUP_PROBE, node.data)) {
@@ -1303,12 +1425,12 @@
1303
1425
  /* Strip patch-source attributes (the source side) off elements. */
1304
1426
  if (nodeType === NODE_TYPE.element) {
1305
1427
  const element = node;
1306
- const lcTag = transformCaseFunc(getNodeName ? getNodeName(node) : node.nodeName);
1428
+ const lcTag = transformCaseFunc(_readNodeName(node));
1307
1429
  try {
1308
1430
  if (element.hasAttribute && element.hasAttribute('patchsrc')) {
1309
1431
  element.removeAttribute('patchsrc');
1310
1432
  }
1311
- if (element.hasAttribute && element.hasAttribute('for') && lcTag !== 'label' && lcTag !== 'output') {
1433
+ if (element.hasAttribute && element.hasAttribute('for') && _isPatchLinkageAttribute('for', lcTag)) {
1312
1434
  element.removeAttribute('for');
1313
1435
  }
1314
1436
  } catch (_) {
@@ -1483,7 +1605,17 @@
1483
1605
  // makes the direct read diverge from the cached read; a clean form
1484
1606
  // (same-realm OR foreign-realm) has both reads pointing at the same
1485
1607
  // canonical NamedNodeMap.
1486
- element.attributes !== getAttributes(element) || typeof element.removeAttribute !== 'function' || typeof element.setAttribute !== 'function' || typeof element.namespaceURI !== 'string' || typeof element.insertBefore !== 'function' || typeof element.hasChildNodes !== 'function' ||
1608
+ element.attributes !== getAttributes(element) || typeof element.removeAttribute !== 'function' ||
1609
+ // A form descendant named "removeAttributeNode" or "getAttributeNode"
1610
+ // shadows these Attr-node methods via [LegacyOverrideBuiltIns].
1611
+ // _removeAttribute() / _stripAttributeNode() reach for
1612
+ // element.removeAttributeNode(attr) first; when it is shadowed the call
1613
+ // throws and the name-based fallback element.removeAttribute(name)
1614
+ // ASCII-lowercases its lookup key in an HTML document, silently missing
1615
+ // a case-preserved event-handler attribute (e.g. an ONANIMATIONSTART
1616
+ // that reached the sanitizer through an XML/XHTML parse). Flag the form
1617
+ // so it is removed wholesale, exactly as for the other shadowed methods.
1618
+ typeof element.removeAttributeNode !== 'function' || typeof element.getAttributeNode !== 'function' || typeof element.setAttribute !== 'function' || typeof element.namespaceURI !== 'string' || typeof element.insertBefore !== 'function' || typeof element.hasChildNodes !== 'function' ||
1487
1619
  // NodeType clobbering probe. Cached Node.prototype.nodeType getter
1488
1620
  // returns the integer 1 for any Element regardless of realm; direct
1489
1621
  // read on a clobbered form (e.g. <input name="nodeType">) returns
@@ -1570,8 +1702,16 @@
1570
1702
  if (SAFE_FOR_XML && currentNode.hasChildNodes() && !_isNode(currentNode.firstElementChild) && regExpTest(ELEMENT_MARKUP_PROBE, currentNode.textContent) && regExpTest(ELEMENT_MARKUP_PROBE, currentNode.innerHTML)) {
1571
1703
  return true;
1572
1704
  }
1573
- /* Remove risky CSS construction leading to mXSS */
1574
- if (SAFE_FOR_XML && currentNode.namespaceURI === HTML_NAMESPACE && tagName === 'style' && _isNode(currentNode.firstElementChild)) {
1705
+ /* Remove rawtext/literal-text elements whose literal serialization
1706
+ re-opens markup on an HTML reparse - shapes (a) and (b) documented at
1707
+ LITERAL_TEXT_ELEMENTS. Both are invisible to rule 1 above (which
1708
+ self-disables once there is an element child, and whose second probe
1709
+ reads the innerHTML an XML/XHTML document serializes escaped), which
1710
+ is why both probes here read textContent instead. Previously only
1711
+ `style`-with-element-child was covered; every element in
1712
+ LITERAL_TEXT_ELEMENTS shares this literal serialization and is
1713
+ equally affected. */
1714
+ 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))) {
1575
1715
  return true;
1576
1716
  }
1577
1717
  /* Remove any occurrence of processing instructions */
@@ -1584,6 +1724,30 @@
1584
1724
  }
1585
1725
  return false;
1586
1726
  };
1727
+ /**
1728
+ * Evaluate a CUSTOM_ELEMENT_HANDLING check (a RegExp or a predicate
1729
+ * function, per the validation in _parseConfig) against a name.
1730
+ * Additional arguments are forwarded to predicate functions - the
1731
+ * attributeNameCheck predicate receives the tag name as its second
1732
+ * argument. A null/absent check never matches.
1733
+ *
1734
+ * @param check the configured tagNameCheck / attributeNameCheck value
1735
+ * @param name the name to test
1736
+ * @param args extra arguments forwarded to a predicate function
1737
+ * @return true if the check matches the name
1738
+ */
1739
+ const _matchesNameCheck = function _matchesNameCheck(check, name) {
1740
+ if (check instanceof RegExp) {
1741
+ return regExpTest(check, name);
1742
+ }
1743
+ if (check instanceof Function) {
1744
+ for (var _len = arguments.length, args = new Array(_len > 2 ? _len - 2 : 0), _key = 2; _key < _len; _key++) {
1745
+ args[_key - 2] = arguments[_key];
1746
+ }
1747
+ return Boolean(check(name, ...args));
1748
+ }
1749
+ return false;
1750
+ };
1587
1751
  /**
1588
1752
  * Handle a node whose tag is forbidden or not allowlisted: keep
1589
1753
  * allowed custom elements (false return exits _sanitizeElements
@@ -1603,13 +1767,8 @@
1603
1767
  */
1604
1768
  const _sanitizeDisallowedNode = function _sanitizeDisallowedNode(currentNode, tagName, root) {
1605
1769
  /* Check if we have a custom element to handle */
1606
- if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName)) {
1607
- if (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)) {
1608
- return false;
1609
- }
1610
- if (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.tagNameCheck(tagName)) {
1611
- return false;
1612
- }
1770
+ if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName) && _matchesNameCheck(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)) {
1771
+ return false;
1613
1772
  }
1614
1773
  /* Keep content except for bad-listed elements.
1615
1774
  Use the cached prototype getters exclusively — the previous code
@@ -1671,6 +1830,46 @@
1671
1830
  }
1672
1831
  return set === defaultSet || set === setConfigSet ? clone(set) : set;
1673
1832
  };
1833
+ /**
1834
+ * Shared guard for a node that a hook has detached from the walk tree,
1835
+ * used after each element-hook site in _sanitizeElements. Detaching is a
1836
+ * long-standing user pattern (issue #469; draw.io-style foreignObject
1837
+ * filtering). Per the cached, unclobberable parentNode getter the node is
1838
+ * genuinely out of the tree, so it can reach neither the serialized
1839
+ * output nor an IN_PLACE live tree; treat it as removed and stop
1840
+ * processing it. Without this guard, the unsafe-node / namespace checks
1841
+ * would call _forceRemove on a parentless node and hit the REPORT-3
1842
+ * fail-closed throw — which exists for nodes DOMPurify wants gone but
1843
+ * *cannot* detach (clobbered / parentless roots), the opposite of a node
1844
+ * that is already safely gone. The walk root is exempt: a detached
1845
+ * IN_PLACE root is legitimate input and must still be fully sanitized,
1846
+ * and a kill-decision on it must keep hitting the REPORT-3 throw.
1847
+ *
1848
+ * Nodes detached by hooks stay the hook's responsibility for placement:
1849
+ * they are not recorded in DOMPurify.removed, so the post-walk IN_PLACE
1850
+ * pass (which iterates DOMPurify.removed) does not reach them. But a
1851
+ * hook-detached subtree can still hold a queued resource-event handler -
1852
+ * e.g. an <img onload> that began loading when the caller built the live
1853
+ * tree - which fires in page scope after sanitize returns even though the
1854
+ * handler never reached the returned tree. That is the audit-5 F1 hazard,
1855
+ * and the documented node.remove() hook pattern walks straight into it.
1856
+ * So on the IN_PLACE path we neutralize the detached subtree inline,
1857
+ * stripping its non-allow-listed attributes before returning, exactly as
1858
+ * the post-walk pass does for _forceRemove'd subtrees.
1859
+ *
1860
+ * @param currentNode the node a hook may have detached
1861
+ * @param root the current walk root
1862
+ * @return true if the node is detached and now handled, false otherwise
1863
+ */
1864
+ const _handleHookDetachedNode = function _handleHookDetachedNode(currentNode, root) {
1865
+ if (currentNode === root || getParentNode(currentNode) !== null) {
1866
+ return false;
1867
+ }
1868
+ if (IN_PLACE) {
1869
+ _neutralizeSubtree(currentNode);
1870
+ }
1871
+ return true;
1872
+ };
1674
1873
  /**
1675
1874
  * _sanitizeElements
1676
1875
  *
@@ -1680,18 +1879,12 @@
1680
1879
  * @param currentNode to check for permission to exist
1681
1880
  * @return true if node was killed, false if left alive
1682
1881
  */
1683
- // eslint-disable-next-line complexity
1684
1882
  const _sanitizeElements = function _sanitizeElements(currentNode, root) {
1685
1883
  /* Execute a hook if present */
1686
1884
  _executeHooks(hooks.beforeSanitizeElements, currentNode, null);
1687
- /* A hook may have detached the node - treat it as removed (see the
1688
- detached-node comment after the uponSanitizeElement hook below). On
1689
- the IN_PLACE path, neutralize the detached subtree first so a queued
1690
- resource handler on it cannot fire in page scope after we return. */
1691
- if (currentNode !== root && getParentNode(currentNode) === null) {
1692
- if (IN_PLACE) {
1693
- _neutralizeSubtree(currentNode);
1694
- }
1885
+ /* A hook may have detached the node - treat it as removed (see
1886
+ _handleHookDetachedNode for the full rationale). */
1887
+ if (_handleHookDetachedNode(currentNode, root)) {
1695
1888
  return true;
1696
1889
  }
1697
1890
  /* Check if element is clobbered or can clobber */
@@ -1700,7 +1893,7 @@
1700
1893
  return true;
1701
1894
  }
1702
1895
  /* Now let's check the element's type and name */
1703
- const tagName = transformCaseFunc(getNodeName ? getNodeName(currentNode) : currentNode.nodeName);
1896
+ const tagName = transformCaseFunc(_readNodeName(currentNode));
1704
1897
  /* Close the pre-walk clone-guard's timing gap: an uponSanitizeElement
1705
1898
  hook may have been installed after that guard sampled the hook arrays
1706
1899
  (e.g. lazily from beforeSanitizeElements), leaving ALLOWED_TAGS still
@@ -1712,34 +1905,9 @@
1712
1905
  tagName,
1713
1906
  allowedTags: ALLOWED_TAGS
1714
1907
  });
1715
- /* A hook may have detached the node from the tree — a long-standing
1716
- user pattern (issue #469; draw.io-style foreignObject filtering).
1717
- Per the cached, unclobberable parentNode getter the node is
1718
- genuinely out of the tree, so it can reach neither the serialized
1719
- output nor an IN_PLACE live tree; treat it as removed and stop
1720
- processing it. Without this guard, the unsafe-node / namespace
1721
- checks below would call _forceRemove on a parentless node and hit
1722
- the REPORT-3 fail-closed throw — which exists for nodes DOMPurify
1723
- wants gone but *cannot* detach (clobbered / parentless roots), the
1724
- opposite of a node that is already safely gone. The walk root is
1725
- exempt: a detached IN_PLACE root is legitimate input and must still
1726
- be fully sanitized, and a kill-decision on it must keep hitting the
1727
- REPORT-3 throw. Nodes detached by hooks stay the hook's
1728
- responsibility for placement: they are not recorded in
1729
- DOMPurify.removed, so the post-walk IN_PLACE pass (which iterates
1730
- DOMPurify.removed) does not reach them. But a hook-detached subtree
1731
- can still hold a queued resource-event handler - e.g. an <img onload>
1732
- that began loading when the caller built the live tree - which fires
1733
- in page scope after sanitize returns even though the handler never
1734
- reached the returned tree. That is the audit-5 F1 hazard, and the
1735
- documented node.remove() hook pattern walks straight into it. So on
1736
- the IN_PLACE path we neutralize the detached subtree inline here,
1737
- stripping its non-allow-listed attributes before returning, exactly
1738
- as the post-walk pass does for _forceRemove'd subtrees. */
1739
- if (currentNode !== root && getParentNode(currentNode) === null) {
1740
- if (IN_PLACE) {
1741
- _neutralizeSubtree(currentNode);
1742
- }
1908
+ /* The uponSanitizeElement hook may have detached the node, exactly as
1909
+ above (see _handleHookDetachedNode for the full rationale). */
1910
+ if (_handleHookDetachedNode(currentNode, root)) {
1743
1911
  return true;
1744
1912
  }
1745
1913
  /* Remove mXSS vectors, processing instructions and risky comments */
@@ -1772,7 +1940,7 @@
1772
1940
  bound and short-circuits to false for any node minted in a different
1773
1941
  realm — letting a foreign-realm element with a forbidden namespace
1774
1942
  slip past the namespace check entirely. */
1775
- const nt = getNodeType ? getNodeType(currentNode) : currentNode.nodeType;
1943
+ const nt = _readNodeType(currentNode);
1776
1944
  if (nt === NODE_TYPE.element && !_checkValidNamespace(currentNode)) {
1777
1945
  _forceRemove(currentNode);
1778
1946
  return true;
@@ -1832,10 +2000,7 @@
1832
2000
  other structural-threat checks and stays overridable, consistent with
1833
2001
  the rest of the codebase. PI range markers are already removed by
1834
2002
  _isUnsafeNode. */
1835
- if (SAFE_FOR_XML && lcName === 'patchsrc') {
1836
- return false;
1837
- }
1838
- if (SAFE_FOR_XML && lcName === 'for' && lcTag !== 'label' && lcTag !== 'output') {
2003
+ if (_isPatchLinkageAttribute(lcName, lcTag)) {
1839
2004
  return false;
1840
2005
  }
1841
2006
  /* Make sure attribute cannot clobber */
@@ -1847,22 +2012,54 @@
1847
2012
  (https://html.spec.whatwg.org/multipage/dom.html#embedding-custom-non-visible-data-with-the-data-*-attributes)
1848
2013
  XML-compatible (https://html.spec.whatwg.org/multipage/infrastructure.html#xml-compatible and http://www.w3.org/TR/xml/#d0e804)
1849
2014
  We don't need to check the value; it's always URI safe. */
1850
- if (ALLOW_DATA_ATTR && regExpTest(DATA_ATTR$1, lcName)) ; else if (ALLOW_ARIA_ATTR && regExpTest(ARIA_ATTR$1, lcName)) ; else if (!nameIsPermitted) {
1851
- if (
1852
- // First condition does a very basic check if a) it's basically a valid custom element tagname AND
1853
- // b) if the tagName passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck
1854
- // and c) if the attribute name passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.attributeNameCheck
1855
- _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)) ||
1856
- // Alternative, second condition checks if it's an `is`-attribute, AND
1857
- // the value passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck
1858
- 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 {
1859
- return false;
1860
- }
1861
- /* Check value is safe. First, is attr inert? If so, is safe */
1862
- } 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) {
1863
- return false;
1864
- } else ;
1865
- return true;
2015
+ if (ALLOW_DATA_ATTR && regExpTest(DATA_ATTR$1, lcName)) {
2016
+ return true;
2017
+ }
2018
+ /* Allow valid aria-* attributes, the value is always URI safe */
2019
+ if (ALLOW_ARIA_ATTR && regExpTest(ARIA_ATTR$1, lcName)) {
2020
+ return true;
2021
+ }
2022
+ /* A name outside the allowlist is acceptable on custom-element terms
2023
+ only. The value checks below are intentionally skipped in that case:
2024
+ if the user supplied a tagNameCheck we also allow derived custom
2025
+ elements using the same test, and attributes passing the configured
2026
+ attributeNameCheck are allowed as custom elements define these at
2027
+ their own discretion. */
2028
+ if (!nameIsPermitted) {
2029
+ return (
2030
+ // Condition a) covers a basically valid custom element tag name whose
2031
+ // tag passes the configured tagNameCheck and whose attribute name
2032
+ // passes the configured attributeNameCheck ...
2033
+ _isBasicCustomElement(lcTag) && _matchesNameCheck(CUSTOM_ELEMENT_HANDLING.tagNameCheck, lcTag) && _matchesNameCheck(CUSTOM_ELEMENT_HANDLING.attributeNameCheck, lcName, lcTag) ||
2034
+ // Condition b) covers an `is` attribute whose value passes the
2035
+ // configured tagNameCheck while customized built-in elements are
2036
+ // allowed.
2037
+ lcName === 'is' && CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements && _matchesNameCheck(CUSTOM_ELEMENT_HANDLING.tagNameCheck, value)
2038
+ );
2039
+ }
2040
+ /* Check value is safe. First, is attr inert? If so, is safe */
2041
+ if (URI_SAFE_ATTRIBUTES[lcName]) {
2042
+ return true;
2043
+ }
2044
+ /* Check no script, data or unknown possibly unsafe URI
2045
+ unless we know URI values are safe for that attribute */
2046
+ if (regExpTest(IS_ALLOWED_URI$1, stringReplace(value, ATTR_WHITESPACE$1, ''))) {
2047
+ return true;
2048
+ }
2049
+ /* Keep image data URIs alive if src/xlink:href is allowed */
2050
+ /* Further prevent gadget XSS for dynamically built script tags */
2051
+ if ((lcName === 'src' || lcName === 'xlink:href' || lcName === 'href') && lcTag !== 'script' && stringIndexOf(value, 'data:') === 0 && DATA_URI_TAGS[lcTag]) {
2052
+ return true;
2053
+ }
2054
+ /* Allow unknown protocols: This provides support for links that
2055
+ are handled by protocol handlers which may be unknown ahead of
2056
+ time, e.g. fb:, spotify: */
2057
+ if (ALLOW_UNKNOWN_PROTOCOLS && !regExpTest(IS_SCRIPT_OR_DATA$1, stringReplace(value, ATTR_WHITESPACE$1, ''))) {
2058
+ return true;
2059
+ }
2060
+ /* Only an empty (binary) value remains safe at this point;
2061
+ anything else is presumed unsafe, do not add it back */
2062
+ return !value;
1866
2063
  };
1867
2064
  /* Names the HTML spec reserves from valid-custom-element-name; these must
1868
2065
  * never be treated as basic custom elements even when a permissive
@@ -1909,15 +2106,25 @@
1909
2106
  /**
1910
2107
  * Write a modified attribute value back onto the element. On
1911
2108
  * success, re-probe for clobbering introduced by the new value and
1912
- * remove the element when found; otherwise pop the removal entry
1913
- * recorded by the earlier _removeAttribute (long-standing pairing
1914
- * with the SANITIZE_NAMED_PROPS path - do not "fix" casually). On
2109
+ * remove the element when found; otherwise, when this writeback is the
2110
+ * recreate half of the SANITIZE_NAMED_PROPS remove-and-recreate, pop the
2111
+ * removal entry that path recorded so it does not show as removed. On
1915
2112
  * failure, remove the attribute instead.
1916
2113
  *
2114
+ * Returns true only on a clean write (the value was set and the new value
2115
+ * introduced no clobbering). The caller uses that, together with its own
2116
+ * knowledge of whether this attribute pushed a DOMPurify.removed record, to
2117
+ * decide whether to pop that record. The pop must happen ONLY for the
2118
+ * named-prop remove-and-recreate; popping on any other value change (trim,
2119
+ * template scrubbing, Trusted Types) would consume an unrelated _forceRemove
2120
+ * subtree-cleanup record and let that detached subtree keep a live event
2121
+ * handler through the IN_PLACE neutralization pass (SO-001).
2122
+ *
1917
2123
  * @param currentNode the element carrying the attribute
1918
2124
  * @param name the attribute name as present on the element
1919
2125
  * @param namespaceURI the attribute's namespace, if any
1920
2126
  * @param value the new attribute value
2127
+ * @return true if the value was written without introducing clobbering
1921
2128
  */
1922
2129
  const _setAttributeValue = function _setAttributeValue(currentNode, name, namespaceURI, value) {
1923
2130
  try {
@@ -1929,11 +2136,12 @@
1929
2136
  }
1930
2137
  if (_isClobbered(currentNode)) {
1931
2138
  _forceRemove(currentNode);
1932
- } else {
1933
- arrayPop(DOMPurify.removed);
2139
+ return false;
1934
2140
  }
2141
+ return true;
1935
2142
  } catch (_) {
1936
2143
  _removeAttribute(name, currentNode);
2144
+ return false;
1937
2145
  }
1938
2146
  };
1939
2147
  /**
@@ -1946,6 +2154,7 @@
1946
2154
  *
1947
2155
  * @param currentNode to sanitize
1948
2156
  */
2157
+ // eslint-disable-next-line complexity
1949
2158
  const _sanitizeAttributes = function _sanitizeAttributes(currentNode) {
1950
2159
  /* Execute a hook if present */
1951
2160
  _executeHooks(hooks.beforeSanitizeAttributes, currentNode, null);
@@ -1975,6 +2184,15 @@
1975
2184
  const lcName = transformCaseFunc(name);
1976
2185
  const initValue = attrValue;
1977
2186
  let value = name === 'value' ? initValue : stringTrim(initValue);
2187
+ /* Tracks whether this specific attribute pushed a DOMPurify.removed
2188
+ record that a later _setAttributeValue() writeback is expected to pop
2189
+ back off (the SANITIZE_NAMED_PROPS remove-and-recreate below is the
2190
+ only path that does). A value that merely changed via trim, template
2191
+ scrubbing, or Trusted Types pushes no such record, so its writeback
2192
+ must NOT pop — otherwise it consumes an unrelated _forceRemove subtree
2193
+ record and that detached subtree escapes the IN_PLACE handler-
2194
+ neutralization pass (SO-001). */
2195
+ let recreatedNamedProp = false;
1978
2196
  /* Execute a hook if present */
1979
2197
  hookEvent.attrName = lcName;
1980
2198
  hookEvent.attrValue = value;
@@ -1987,20 +2205,23 @@
1987
2205
  */
1988
2206
  if (SANITIZE_NAMED_PROPS && (lcName === 'id' || lcName === 'name') && stringIndexOf(value, SANITIZE_NAMED_PROPS_PREFIX) !== 0) {
1989
2207
  // Remove the attribute with this value
1990
- _removeAttribute(name, currentNode);
2208
+ _removeAttribute(name, currentNode, attr);
1991
2209
  // Prefix the value and later re-create the attribute with the sanitized value
1992
2210
  value = SANITIZE_NAMED_PROPS_PREFIX + value;
2211
+ // This attribute owns the record just pushed; its recreate writeback
2212
+ // is the one allowed to pop it back off.
2213
+ recreatedNamedProp = true;
1993
2214
  }
1994
2215
  // Else: already prefixed, leave the attribute alone — the prefix is
1995
2216
  // itself the clobbering protection, and re-applying it is incorrect.
1996
2217
  /* Work around a security issue with comments inside attributes */
1997
2218
  if (SAFE_FOR_XML && regExpTest(/((--!?|])>)|<\/(style|script|title|xmp|textarea|noscript|iframe|noembed|noframes)/i, value)) {
1998
- _removeAttribute(name, currentNode);
2219
+ _removeAttribute(name, currentNode, attr);
1999
2220
  continue;
2000
2221
  }
2001
2222
  /* Make sure we cannot easily use animated hrefs, even if animations are allowed */
2002
2223
  if (lcName === 'attributename' && stringMatch(value, 'href')) {
2003
- _removeAttribute(name, currentNode);
2224
+ _removeAttribute(name, currentNode, attr);
2004
2225
  continue;
2005
2226
  }
2006
2227
  /* Did the hooks force-keep the attribute? */
@@ -2009,12 +2230,12 @@
2009
2230
  }
2010
2231
  /* Did the hooks approve of the attribute? */
2011
2232
  if (!hookEvent.keepAttr) {
2012
- _removeAttribute(name, currentNode);
2233
+ _removeAttribute(name, currentNode, attr);
2013
2234
  continue;
2014
2235
  }
2015
2236
  /* Work around a security issue in jQuery 3.0 */
2016
2237
  if (!ALLOW_SELF_CLOSE_IN_ATTR && regExpTest(SELF_CLOSING_TAG, value)) {
2017
- _removeAttribute(name, currentNode);
2238
+ _removeAttribute(name, currentNode, attr);
2018
2239
  continue;
2019
2240
  }
2020
2241
  /* Sanitize attribute content to be template-safe */
@@ -2023,14 +2244,21 @@
2023
2244
  }
2024
2245
  /* Is `value` valid for this attribute? */
2025
2246
  if (!_isValidAttribute(lcTag, lcName, value)) {
2026
- _removeAttribute(name, currentNode);
2247
+ _removeAttribute(name, currentNode, attr);
2027
2248
  continue;
2028
2249
  }
2029
2250
  /* Handle attributes that require Trusted Types */
2030
2251
  value = _applyTrustedTypesToAttribute(lcTag, lcName, namespaceURI, value);
2031
2252
  /* Handle invalid data-* attribute set by try-catching it */
2032
2253
  if (value !== initValue) {
2033
- _setAttributeValue(currentNode, name, namespaceURI, value);
2254
+ const cleanWrite = _setAttributeValue(currentNode, name, namespaceURI, value);
2255
+ /* Only the named-prop remove-and-recreate owns a DOMPurify.removed
2256
+ record to pop; a plain trim/template/Trusted-Types writeback owns
2257
+ none, so popping here would drop an unrelated subtree's cleanup
2258
+ record (SO-001). */
2259
+ if (cleanWrite && recreatedNamedProp) {
2260
+ arrayPop(DOMPurify.removed);
2261
+ }
2034
2262
  }
2035
2263
  }
2036
2264
  /* Execute a hook if present */
@@ -2070,8 +2298,7 @@
2070
2298
  Walk it explicitly. The nodeType guard avoids reading
2071
2299
  shadowRoot off text / comment / CDATA / PI nodes that the
2072
2300
  iterator also surfaces. */
2073
- const shadowNodeType = getNodeType ? getNodeType(shadowNode) : shadowNode.nodeType;
2074
- if (shadowNodeType === NODE_TYPE.element) {
2301
+ if (_readNodeType(shadowNode) === NODE_TYPE.element) {
2075
2302
  const innerSr = getShadowRoot(shadowNode);
2076
2303
  if (_isDocumentFragment(innerSr)) {
2077
2304
  _sanitizeAttachedShadowRoots(innerSr);
@@ -2129,7 +2356,7 @@
2129
2356
  continue;
2130
2357
  }
2131
2358
  const node = item.node;
2132
- const nodeType = getNodeType ? getNodeType(node) : node.nodeType;
2359
+ const nodeType = _readNodeType(node);
2133
2360
  const isElement = nodeType === NODE_TYPE.element;
2134
2361
  /* (pushed last → processed first) Children, snapshotted in reverse so
2135
2362
  the first child is processed first. Snapshotting matters because a
@@ -2251,7 +2478,7 @@
2251
2478
  child named "nodeName" on the form root would otherwise shadow
2252
2479
  the property and let this check skip the root-allowlist
2253
2480
  validation entirely. */
2254
- const nn = getNodeName ? getNodeName(dirty) : dirty.nodeName;
2481
+ const nn = _readNodeName(dirty);
2255
2482
  if (typeof nn === 'string') {
2256
2483
  const tagName = transformCaseFunc(nn);
2257
2484
  if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) {
@@ -2307,8 +2534,15 @@
2307
2534
  them before the main iterator runs, since the iterator does not
2308
2535
  descend into shadow trees. The walk routes every read through a
2309
2536
  cached prototype getter so clobbering descendants on a form root
2310
- cannot hide a shadow host from this pass. */
2311
- _sanitizeAttachedShadowRoots(importedNode);
2537
+ cannot hide a shadow host from this pass.
2538
+ Traverse from `body`, not `importedNode`: when importedNode is a
2539
+ DocumentFragment, the appendChild() above moves its children into
2540
+ `body` and leaves the fragment empty, so walking importedNode would
2541
+ scan nothing and miss every attached shadow host now under `body`
2542
+ (SO-003). In the BODY/HTML branches `body === importedNode`, so this
2543
+ is equivalent there; in the element branch `body` contains the
2544
+ appended element and its descendants. `body` covers all three. */
2545
+ _sanitizeAttachedShadowRoots(body);
2312
2546
  } else {
2313
2547
  /* Exit directly if we have nothing to do */
2314
2548
  if (!RETURN_DOM && !SAFE_FOR_TEMPLATES && !WHOLE_DOCUMENT &&