dompurify 3.4.12 → 3.4.14

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.12 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.12/LICENSE */
1
+ /*! @license DOMPurify 3.4.14 | (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.14/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.12';
473
+ DOMPurify.version = '3.4.14';
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
@@ -455,6 +501,17 @@ function createDOMPurify() {
455
501
  const getAttributes = lookupGetter(ElementPrototype, 'attributes');
456
502
  const getNodeType = Node && Node.prototype ? lookupGetter(Node.prototype, 'nodeType') : null;
457
503
  const getNodeName = Node && Node.prototype ? lookupGetter(Node.prototype, 'nodeName') : null;
504
+ const getOwnerDocument = Node && Node.prototype ? lookupGetter(Node.prototype, 'ownerDocument') : null;
505
+ /* Clobber-safe nodeType / nodeName reads through the cached Node.prototype
506
+ getters, with a direct-property fallback for environments that lack
507
+ Node.prototype. Sites that need a different fallback (e.g. _isClobbered
508
+ returns early on a null name) intentionally keep their own reads. */
509
+ const _readNodeType = function _readNodeType(node) {
510
+ return getNodeType ? getNodeType(node) : node.nodeType;
511
+ };
512
+ const _readNodeName = function _readNodeName(node) {
513
+ return getNodeName ? getNodeName(node) : node.nodeName;
514
+ };
458
515
  // As per issue #47, the web-components registry is inherited by a
459
516
  // new document created via createHTMLDocument. As per the spec
460
517
  // (http://w3c.github.io/webcomponents/spec/custom/#creating-and-passing-registries)
@@ -777,9 +834,11 @@ function createDOMPurify() {
777
834
  IN_PLACE = cfg.IN_PLACE || false; // Default false
778
835
  IS_ALLOWED_URI$1 = isRegex(cfg.ALLOWED_URI_REGEXP) ? cfg.ALLOWED_URI_REGEXP : IS_ALLOWED_URI; // Default regexp
779
836
  NAMESPACE = typeof cfg.NAMESPACE === 'string' ? cfg.NAMESPACE : HTML_NAMESPACE; // Default HTML namespace
780
- 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
781
- 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
782
- const customElementHandling = objectHasOwnProperty(cfg, 'CUSTOM_ELEMENT_HANDLING') && cfg.CUSTOM_ELEMENT_HANDLING && typeof cfg.CUSTOM_ELEMENT_HANDLING === 'object' ? clone(cfg.CUSTOM_ELEMENT_HANDLING) : create(null);
837
+ MATHML_TEXT_INTEGRATION_POINTS = _resolveObjectOption(cfg, 'MATHML_TEXT_INTEGRATION_POINTS', () => addToSet({}, DEFAULT_MATHML_TEXT_INTEGRATION_POINTS) // Default built-in map
838
+ );
839
+ HTML_INTEGRATION_POINTS = _resolveObjectOption(cfg, 'HTML_INTEGRATION_POINTS', () => addToSet({}, DEFAULT_HTML_INTEGRATION_POINTS) // Default built-in map
840
+ );
841
+ const customElementHandling = _resolveObjectOption(cfg, 'CUSTOM_ELEMENT_HANDLING', () => create(null));
783
842
  CUSTOM_ELEMENT_HANDLING = create(null);
784
843
  if (objectHasOwnProperty(customElementHandling, 'tagNameCheck') && isRegexOrFunction(customElementHandling.tagNameCheck)) {
785
844
  CUSTOM_ELEMENT_HANDLING.tagNameCheck = customElementHandling.tagNameCheck; // Default undefined
@@ -846,15 +905,6 @@ function createDOMPurify() {
846
905
  addToSet(ALLOWED_ATTR, cfg.ADD_ATTR, transformCaseFunc);
847
906
  }
848
907
  }
849
- if (objectHasOwnProperty(cfg, 'ADD_URI_SAFE_ATTR') && arrayIsArray(cfg.ADD_URI_SAFE_ATTR)) {
850
- addToSet(URI_SAFE_ATTRIBUTES, cfg.ADD_URI_SAFE_ATTR, transformCaseFunc);
851
- }
852
- if (objectHasOwnProperty(cfg, 'FORBID_CONTENTS') && arrayIsArray(cfg.FORBID_CONTENTS)) {
853
- if (FORBID_CONTENTS === DEFAULT_FORBID_CONTENTS) {
854
- FORBID_CONTENTS = clone(FORBID_CONTENTS);
855
- }
856
- addToSet(FORBID_CONTENTS, cfg.FORBID_CONTENTS, transformCaseFunc);
857
- }
858
908
  if (objectHasOwnProperty(cfg, 'ADD_FORBID_CONTENTS') && arrayIsArray(cfg.ADD_FORBID_CONTENTS)) {
859
909
  if (FORBID_CONTENTS === DEFAULT_FORBID_CONTENTS) {
860
910
  FORBID_CONTENTS = clone(FORBID_CONTENTS);
@@ -1089,6 +1139,32 @@ function createDOMPurify() {
1089
1139
  }
1090
1140
  }
1091
1141
  };
1142
+ /**
1143
+ * _stripAttributeNode
1144
+ *
1145
+ * Remove a single Attr node case/namespace-exactly on an attribute-teardown
1146
+ * path. Name-based removeAttribute() ASCII-lowercases its lookup key for an
1147
+ * HTML element in an HTML document and so silently misses a case-preserved
1148
+ * handler (e.g. `ONERROR` off an XML/XHTML import) - the same defect
1149
+ * _removeAttribute() was fixed for, which a name-based call would reintroduce
1150
+ * on these IN_PLACE teardown paths. Unlike _removeAttribute this does not
1151
+ * record into DOMPurify.removed: the neutralize passes intentionally do not
1152
+ * book-keep. A clobbered/detached node falls back to best-effort name-based
1153
+ * removal.
1154
+ *
1155
+ * @param element the element to strip the attribute from
1156
+ * @param attribute the Attr node to remove
1157
+ * @param name the attribute's name, for the fallback path
1158
+ */
1159
+ const _stripAttributeNode = function _stripAttributeNode(element, attribute, name) {
1160
+ try {
1161
+ element.removeAttributeNode(attribute);
1162
+ } catch (_) {
1163
+ try {
1164
+ element.removeAttribute(name);
1165
+ } catch (_) {}
1166
+ }
1167
+ };
1092
1168
  /**
1093
1169
  * _neutralizeRoot
1094
1170
  *
@@ -1133,11 +1209,7 @@ function createDOMPurify() {
1133
1209
  const attribute = attributes[i];
1134
1210
  const name = attribute && attribute.name;
1135
1211
  if (typeof name === 'string') {
1136
- try {
1137
- root.removeAttribute(name);
1138
- } catch (_) {
1139
- /* Clobbered removeAttribute — ignore (fail-closed best effort) */
1140
- }
1212
+ _stripAttributeNode(root, attribute, name);
1141
1213
  }
1142
1214
  }
1143
1215
  }
@@ -1145,22 +1217,46 @@ function createDOMPurify() {
1145
1217
  /**
1146
1218
  * _removeAttribute
1147
1219
  *
1220
+ * Name-based getAttributeNode()/removeAttribute() ASCII-lowercase their
1221
+ * lookup key for HTML elements in an HTML document, so they silently miss an
1222
+ * attribute whose stored qualified name still contains uppercase ASCII
1223
+ * letters. That happens when the node came from a case-preserving source
1224
+ * (an XML/XHTML document imported via importNode(), or createAttributeNS()),
1225
+ * where e.g. `ONERROR` survives the walk: the policy check lowercases to
1226
+ * `onerror` and rejects it, but `removeAttribute('ONERROR')` looks up
1227
+ * `onerror` and finds nothing. Remove the exact Attr node instead, which is
1228
+ * case- and namespace-exact, and fall back to name-based removal only when
1229
+ * the caller could not supply the node.
1230
+ *
1148
1231
  * @param name an Attribute name
1149
1232
  * @param element a DOM node
1233
+ * @param attr the exact Attr node to remove, when the caller has it
1150
1234
  */
1151
- const _removeAttribute = function _removeAttribute(name, element) {
1235
+ const _removeAttribute = function _removeAttribute(name, element, attr) {
1236
+ if (!attr) {
1237
+ try {
1238
+ attr = element.getAttributeNode(name);
1239
+ } catch (_) {
1240
+ attr = null;
1241
+ }
1242
+ }
1243
+ arrayPush(DOMPurify.removed, {
1244
+ attribute: attr || null,
1245
+ from: element
1246
+ });
1152
1247
  try {
1153
- arrayPush(DOMPurify.removed, {
1154
- attribute: element.getAttributeNode(name),
1155
- from: element
1156
- });
1248
+ if (attr) {
1249
+ element.removeAttributeNode(attr);
1250
+ } else {
1251
+ element.removeAttribute(name);
1252
+ }
1157
1253
  } catch (_) {
1158
- arrayPush(DOMPurify.removed, {
1159
- attribute: null,
1160
- from: element
1161
- });
1254
+ /* Clobbered or already-detached node - best-effort fall back to a
1255
+ name-based removal so the "is" handling below still runs. */
1256
+ try {
1257
+ element.removeAttribute(name);
1258
+ } catch (_) {}
1162
1259
  }
1163
- element.removeAttribute(name);
1164
1260
  // We void attribute values for unremovable "is" attributes
1165
1261
  if (name === 'is') {
1166
1262
  if (RETURN_DOM || RETURN_DOM_FRAGMENT) {
@@ -1195,11 +1291,7 @@ function createDOMPurify() {
1195
1291
  if (typeof name !== 'string' || ALLOWED_ATTR[transformCaseFunc(name)]) {
1196
1292
  continue;
1197
1293
  }
1198
- try {
1199
- element.removeAttribute(name);
1200
- } catch (_) {
1201
- /* Clobbered removeAttribute on a doomed node — ignore */
1202
- }
1294
+ _stripAttributeNode(element, attribute, name);
1203
1295
  }
1204
1296
  };
1205
1297
  /**
@@ -1228,7 +1320,7 @@ function createDOMPurify() {
1228
1320
  const stack = [root];
1229
1321
  while (stack.length > 0) {
1230
1322
  const node = stack.pop();
1231
- const nodeType = getNodeType ? getNodeType(node) : node.nodeType;
1323
+ const nodeType = _readNodeType(node);
1232
1324
  if (nodeType === NODE_TYPE.element) {
1233
1325
  _stripDisallowedAttributes(node);
1234
1326
  }
@@ -1275,6 +1367,26 @@ function createDOMPurify() {
1275
1367
  *
1276
1368
  * @param root the in-place root to sweep
1277
1369
  */
1370
+ /**
1371
+ * Central policy for declarative-partial-updates patch-linkage attributes,
1372
+ * shared by the _neutralizePatchLinkage pre-pass and _isValidAttribute so
1373
+ * the two sites cannot drift: `patchsrc` always links, `for` links
1374
+ * everywhere except on <label>/<output>, and the whole policy is gated on
1375
+ * SAFE_FOR_XML (see the rationale block in _isValidAttribute).
1376
+ *
1377
+ * @param lcName the transformCaseFunc'd attribute name
1378
+ * @param lcTag the transformCaseFunc'd tag name of the carrying element
1379
+ * @return true if the attribute is patch linkage and must be dropped
1380
+ */
1381
+ const _isPatchLinkageAttribute = function _isPatchLinkageAttribute(lcName, lcTag) {
1382
+ if (!SAFE_FOR_XML) {
1383
+ return false;
1384
+ }
1385
+ if (lcName === 'patchsrc') {
1386
+ return true;
1387
+ }
1388
+ return lcName === 'for' && lcTag !== 'label' && lcTag !== 'output';
1389
+ };
1278
1390
  const _neutralizePatchLinkage = function _neutralizePatchLinkage(root) {
1279
1391
  if (!SAFE_FOR_XML) {
1280
1392
  return;
@@ -1282,7 +1394,7 @@ function createDOMPurify() {
1282
1394
  const stack = [root];
1283
1395
  while (stack.length > 0) {
1284
1396
  const node = stack.pop();
1285
- const nodeType = getNodeType ? getNodeType(node) : node.nodeType;
1397
+ const nodeType = _readNodeType(node);
1286
1398
  /* Remove range markers (the target side of a patch linkage): every
1287
1399
  processing instruction, and any markup-bearing comment. */
1288
1400
  if (nodeType === NODE_TYPE.processingInstruction || nodeType === NODE_TYPE.comment && regExpTest(COMMENT_MARKUP_PROBE, node.data)) {
@@ -1296,12 +1408,12 @@ function createDOMPurify() {
1296
1408
  /* Strip patch-source attributes (the source side) off elements. */
1297
1409
  if (nodeType === NODE_TYPE.element) {
1298
1410
  const element = node;
1299
- const lcTag = transformCaseFunc(getNodeName ? getNodeName(node) : node.nodeName);
1411
+ const lcTag = transformCaseFunc(_readNodeName(node));
1300
1412
  try {
1301
1413
  if (element.hasAttribute && element.hasAttribute('patchsrc')) {
1302
1414
  element.removeAttribute('patchsrc');
1303
1415
  }
1304
- if (element.hasAttribute && element.hasAttribute('for') && lcTag !== 'label' && lcTag !== 'output') {
1416
+ if (element.hasAttribute && element.hasAttribute('for') && _isPatchLinkageAttribute('for', lcTag)) {
1305
1417
  element.removeAttribute('for');
1306
1418
  }
1307
1419
  } catch (_) {
@@ -1373,7 +1485,17 @@ function createDOMPurify() {
1373
1485
  * @return The created NodeIterator
1374
1486
  */
1375
1487
  const _createNodeIterator = function _createNodeIterator(root) {
1376
- return createNodeIterator.call(root.ownerDocument || root, root,
1488
+ /* Read ownerDocument through the cached Node.prototype getter, never the
1489
+ direct property. HTMLFormElement has [LegacyOverrideBuiltIns], so a
1490
+ clobbering child (<input name="ownerDocument"> or a form-associated
1491
+ external input) shadows the prototype getter and makes a direct read
1492
+ return that <input>. createNodeIterator.call(<input>, ...) then throws
1493
+ "Illegal invocation", and on the IN_PLACE path that throw lands before
1494
+ the walk's fail-closed barrier - leaving the caller's live tree, with
1495
+ any already-armed handler in it, un-neutralized. The cached getter
1496
+ returns the real Document regardless of the clobber. */
1497
+ const doc = getOwnerDocument ? getOwnerDocument(root) : root.ownerDocument;
1498
+ return createNodeIterator.call(doc || root, root,
1377
1499
  // eslint-disable-next-line no-bitwise
1378
1500
  NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT | NodeFilter.SHOW_PROCESSING_INSTRUCTION | NodeFilter.SHOW_CDATA_SECTION, null);
1379
1501
  };
@@ -1413,7 +1535,11 @@ function createDOMPurify() {
1413
1535
  const _scrubTemplateExpressions2 = function _scrubTemplateExpressions(node) {
1414
1536
  var _node$querySelectorAl;
1415
1537
  node.normalize();
1416
- const walker = createNodeIterator.call(node.ownerDocument || node, node,
1538
+ /* Clobber-safe ownerDocument read, same reasoning as _createNodeIterator:
1539
+ under SAFE_FOR_TEMPLATES this runs on the live IN_PLACE root, which may
1540
+ carry a form-named-getter override of ownerDocument. */
1541
+ const doc = getOwnerDocument ? getOwnerDocument(node) : node.ownerDocument;
1542
+ const walker = createNodeIterator.call(doc || node, node,
1417
1543
  // eslint-disable-next-line no-bitwise
1418
1544
  NodeFilter.SHOW_TEXT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_CDATA_SECTION | NodeFilter.SHOW_PROCESSING_INSTRUCTION, null);
1419
1545
  let currentNode = walker.nextNode();
@@ -1549,8 +1675,16 @@ function createDOMPurify() {
1549
1675
  if (SAFE_FOR_XML && currentNode.hasChildNodes() && !_isNode(currentNode.firstElementChild) && regExpTest(ELEMENT_MARKUP_PROBE, currentNode.textContent) && regExpTest(ELEMENT_MARKUP_PROBE, currentNode.innerHTML)) {
1550
1676
  return true;
1551
1677
  }
1552
- /* Remove risky CSS construction leading to mXSS */
1553
- if (SAFE_FOR_XML && currentNode.namespaceURI === HTML_NAMESPACE && tagName === 'style' && _isNode(currentNode.firstElementChild)) {
1678
+ /* Remove rawtext/literal-text elements whose literal serialization
1679
+ re-opens markup on an HTML reparse - shapes (a) and (b) documented at
1680
+ LITERAL_TEXT_ELEMENTS. Both are invisible to rule 1 above (which
1681
+ self-disables once there is an element child, and whose second probe
1682
+ reads the innerHTML an XML/XHTML document serializes escaped), which
1683
+ is why both probes here read textContent instead. Previously only
1684
+ `style`-with-element-child was covered; every element in
1685
+ LITERAL_TEXT_ELEMENTS shares this literal serialization and is
1686
+ equally affected. */
1687
+ 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))) {
1554
1688
  return true;
1555
1689
  }
1556
1690
  /* Remove any occurrence of processing instructions */
@@ -1563,6 +1697,30 @@ function createDOMPurify() {
1563
1697
  }
1564
1698
  return false;
1565
1699
  };
1700
+ /**
1701
+ * Evaluate a CUSTOM_ELEMENT_HANDLING check (a RegExp or a predicate
1702
+ * function, per the validation in _parseConfig) against a name.
1703
+ * Additional arguments are forwarded to predicate functions - the
1704
+ * attributeNameCheck predicate receives the tag name as its second
1705
+ * argument. A null/absent check never matches.
1706
+ *
1707
+ * @param check the configured tagNameCheck / attributeNameCheck value
1708
+ * @param name the name to test
1709
+ * @param args extra arguments forwarded to a predicate function
1710
+ * @return true if the check matches the name
1711
+ */
1712
+ const _matchesNameCheck = function _matchesNameCheck(check, name) {
1713
+ if (check instanceof RegExp) {
1714
+ return regExpTest(check, name);
1715
+ }
1716
+ if (check instanceof Function) {
1717
+ for (var _len = arguments.length, args = new Array(_len > 2 ? _len - 2 : 0), _key = 2; _key < _len; _key++) {
1718
+ args[_key - 2] = arguments[_key];
1719
+ }
1720
+ return Boolean(check(name, ...args));
1721
+ }
1722
+ return false;
1723
+ };
1566
1724
  /**
1567
1725
  * Handle a node whose tag is forbidden or not allowlisted: keep
1568
1726
  * allowed custom elements (false return exits _sanitizeElements
@@ -1580,15 +1738,10 @@ function createDOMPurify() {
1580
1738
  * @param tagName the node's transformCaseFunc'd tag name
1581
1739
  * @return true if the node was removed, false if kept
1582
1740
  */
1583
- const _sanitizeDisallowedNode = function _sanitizeDisallowedNode(currentNode, tagName) {
1741
+ const _sanitizeDisallowedNode = function _sanitizeDisallowedNode(currentNode, tagName, root) {
1584
1742
  /* Check if we have a custom element to handle */
1585
- if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName)) {
1586
- if (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)) {
1587
- return false;
1588
- }
1589
- if (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.tagNameCheck(tagName)) {
1590
- return false;
1591
- }
1743
+ if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName) && _matchesNameCheck(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)) {
1744
+ return false;
1592
1745
  }
1593
1746
  /* Keep content except for bad-listed elements.
1594
1747
  Use the cached prototype getters exclusively — the previous code
@@ -1603,27 +1756,26 @@ function createDOMPurify() {
1603
1756
  const childNodes = getChildNodes(currentNode);
1604
1757
  if (childNodes && parentNode) {
1605
1758
  const childCount = childNodes.length;
1606
- /* In-place: hoist the *original* children so the iterator visits
1607
- and sanitises them through the same allowlist pass as every other
1608
- node. The caller built the tree in the live document, so the
1609
- originals carry already-queued resource events (`<img onerror>`,
1610
- `<video>`/`<audio>` error, lazy/`onload`, …); cloning would leave
1611
- those originals detached but still armed, firing in page scope
1612
- while the returned tree looked clean. Moving is safe in-place: the
1613
- root is pre-validated as an allowed tag and so is never the node
1614
- being removed, which keeps `parentNode` inside the iterator root
1615
- and the relocated child inside the serialised tree.
1616
- Otherwise (string / DOM-copy paths): clone. The iterator is rooted
1617
- at and the result serialised from `body`, so a restrictive
1618
- ALLOWED_TAGS that removes `body` itself must leave its content in
1619
- place, which only cloning does; and those paths parse into an
1620
- inert document, so their discarded originals never had a queued
1621
- event to neutralise.
1759
+ /* Hoist by moving each child up one level rather than deep-cloning
1760
+ it. Moving transfers every descendant exactly once, so a chain of
1761
+ nested disallowed elements costs O(n) instead of the O(n^2) that
1762
+ re-cloning the shrinking subtree at each level produced; it also
1763
+ empties the removed original, so `DOMPurify.removed` no longer
1764
+ pins whole subtrees. Moving preserves the in-place guarantee too:
1765
+ an original carrying already-queued resource events (`<img
1766
+ onerror>`, `<video>`/`<audio>` error, lazy/`onload`, …) is
1767
+ relocated and sanitised rather than left detached but still armed.
1768
+ The sole case that must clone is removing the walk root itself.
1769
+ The result is serialised from the root's subtree, so a restrictive
1770
+ ALLOWED_TAGS that strips the root (`body` on the string path) must
1771
+ leave the content inside it, which only cloning does. In IN_PLACE
1772
+ the root is pre-validated as an allowed tag and so is never removed
1773
+ here, so that path always takes the move branch.
1622
1774
  `childNodes` is live; a tail-to-head walk keeps `childNodes[i]`
1623
1775
  valid whether we move (drops the trailing entry) or clone (leaves
1624
1776
  the list intact). */
1625
1777
  for (let i = childCount - 1; i >= 0; --i) {
1626
- const hoisted = IN_PLACE ? childNodes[i] : cloneNode(childNodes[i], true);
1778
+ const hoisted = currentNode === root ? cloneNode(childNodes[i], true) : childNodes[i];
1627
1779
  parentNode.insertBefore(hoisted, getNextSibling(currentNode));
1628
1780
  }
1629
1781
  }
@@ -1631,6 +1783,66 @@ function createDOMPurify() {
1631
1783
  _forceRemove(currentNode);
1632
1784
  return true;
1633
1785
  };
1786
+ /**
1787
+ * Fork a hook-mutable allowlist off its shared binding the first time a
1788
+ * (possibly lazily-installed) uponSanitize* hook is about to see it, so the
1789
+ * hook cannot widen the per-instance default or the setConfig binding by
1790
+ * reference and leak past the call. Returns the set unchanged once it is
1791
+ * already call-local, so repeated calls across elements are idempotent.
1792
+ *
1793
+ * @param hookList the uponSanitize* hook array for this event
1794
+ * @param set the current ALLOWED_TAGS / ALLOWED_ATTR binding
1795
+ * @param defaultSet the per-instance DEFAULT_ALLOWED_* constant
1796
+ * @param setConfigSet the captured setConfig() binding, or null
1797
+ * @return a call-local clone if a hook is present and set is still shared,
1798
+ * else set unchanged
1799
+ */
1800
+ const _forkSharedAllowlist = function _forkSharedAllowlist(hookList, set, defaultSet, setConfigSet) {
1801
+ if (hookList.length === 0) {
1802
+ return set;
1803
+ }
1804
+ return set === defaultSet || set === setConfigSet ? clone(set) : set;
1805
+ };
1806
+ /**
1807
+ * Shared guard for a node that a hook has detached from the walk tree,
1808
+ * used after each element-hook site in _sanitizeElements. Detaching is a
1809
+ * long-standing user pattern (issue #469; draw.io-style foreignObject
1810
+ * filtering). Per the cached, unclobberable parentNode getter the node is
1811
+ * genuinely out of the tree, so it can reach neither the serialized
1812
+ * output nor an IN_PLACE live tree; treat it as removed and stop
1813
+ * processing it. Without this guard, the unsafe-node / namespace checks
1814
+ * would call _forceRemove on a parentless node and hit the REPORT-3
1815
+ * fail-closed throw — which exists for nodes DOMPurify wants gone but
1816
+ * *cannot* detach (clobbered / parentless roots), the opposite of a node
1817
+ * that is already safely gone. The walk root is exempt: a detached
1818
+ * IN_PLACE root is legitimate input and must still be fully sanitized,
1819
+ * and a kill-decision on it must keep hitting the REPORT-3 throw.
1820
+ *
1821
+ * Nodes detached by hooks stay the hook's responsibility for placement:
1822
+ * they are not recorded in DOMPurify.removed, so the post-walk IN_PLACE
1823
+ * pass (which iterates DOMPurify.removed) does not reach them. But a
1824
+ * hook-detached subtree can still hold a queued resource-event handler -
1825
+ * e.g. an <img onload> that began loading when the caller built the live
1826
+ * tree - which fires in page scope after sanitize returns even though the
1827
+ * handler never reached the returned tree. That is the audit-5 F1 hazard,
1828
+ * and the documented node.remove() hook pattern walks straight into it.
1829
+ * So on the IN_PLACE path we neutralize the detached subtree inline,
1830
+ * stripping its non-allow-listed attributes before returning, exactly as
1831
+ * the post-walk pass does for _forceRemove'd subtrees.
1832
+ *
1833
+ * @param currentNode the node a hook may have detached
1834
+ * @param root the current walk root
1835
+ * @return true if the node is detached and now handled, false otherwise
1836
+ */
1837
+ const _handleHookDetachedNode = function _handleHookDetachedNode(currentNode, root) {
1838
+ if (currentNode === root || getParentNode(currentNode) !== null) {
1839
+ return false;
1840
+ }
1841
+ if (IN_PLACE) {
1842
+ _neutralizeSubtree(currentNode);
1843
+ }
1844
+ return true;
1845
+ };
1634
1846
  /**
1635
1847
  * _sanitizeElements
1636
1848
  *
@@ -1640,13 +1852,12 @@ function createDOMPurify() {
1640
1852
  * @param currentNode to check for permission to exist
1641
1853
  * @return true if node was killed, false if left alive
1642
1854
  */
1643
- // eslint-disable-next-line complexity
1644
1855
  const _sanitizeElements = function _sanitizeElements(currentNode, root) {
1645
1856
  /* Execute a hook if present */
1646
1857
  _executeHooks(hooks.beforeSanitizeElements, currentNode, null);
1647
- /* A hook may have detached the node treat it as removed (see the
1648
- detached-node comment after the uponSanitizeElement hook below). */
1649
- if (currentNode !== root && getParentNode(currentNode) === null) {
1858
+ /* A hook may have detached the node - treat it as removed (see
1859
+ _handleHookDetachedNode for the full rationale). */
1860
+ if (_handleHookDetachedNode(currentNode, root)) {
1650
1861
  return true;
1651
1862
  }
1652
1863
  /* Check if element is clobbered or can clobber */
@@ -1655,28 +1866,21 @@ function createDOMPurify() {
1655
1866
  return true;
1656
1867
  }
1657
1868
  /* Now let's check the element's type and name */
1658
- const tagName = transformCaseFunc(getNodeName ? getNodeName(currentNode) : currentNode.nodeName);
1869
+ const tagName = transformCaseFunc(_readNodeName(currentNode));
1870
+ /* Close the pre-walk clone-guard's timing gap: an uponSanitizeElement
1871
+ hook may have been installed after that guard sampled the hook arrays
1872
+ (e.g. lazily from beforeSanitizeElements), leaving ALLOWED_TAGS still
1873
+ aliasing a shared binding that a widening hook would mutate by
1874
+ reference. Fork it before exposing it to the hook. */
1875
+ ALLOWED_TAGS = _forkSharedAllowlist(hooks.uponSanitizeElement, ALLOWED_TAGS, DEFAULT_ALLOWED_TAGS, SET_CONFIG_ALLOWED_TAGS);
1659
1876
  /* Execute a hook if present */
1660
1877
  _executeHooks(hooks.uponSanitizeElement, currentNode, {
1661
1878
  tagName,
1662
1879
  allowedTags: ALLOWED_TAGS
1663
1880
  });
1664
- /* A hook may have detached the node from the tree — a long-standing
1665
- user pattern (issue #469; draw.io-style foreignObject filtering).
1666
- Per the cached, unclobberable parentNode getter the node is
1667
- genuinely out of the tree, so it can reach neither the serialized
1668
- output nor an IN_PLACE live tree; treat it as removed and stop
1669
- processing it. Without this guard, the unsafe-node / namespace
1670
- checks below would call _forceRemove on a parentless node and hit
1671
- the REPORT-3 fail-closed throw — which exists for nodes DOMPurify
1672
- wants gone but *cannot* detach (clobbered / parentless roots), the
1673
- opposite of a node that is already safely gone. The walk root is
1674
- exempt: a detached IN_PLACE root is legitimate input and must still
1675
- be fully sanitized, and a kill-decision on it must keep hitting the
1676
- REPORT-3 throw. Nodes detached by hooks are the hook's
1677
- responsibility: they are not recorded in DOMPurify.removed and are
1678
- not neutralized by the post-walk IN_PLACE pass. */
1679
- if (currentNode !== root && getParentNode(currentNode) === null) {
1881
+ /* The uponSanitizeElement hook may have detached the node, exactly as
1882
+ above (see _handleHookDetachedNode for the full rationale). */
1883
+ if (_handleHookDetachedNode(currentNode, root)) {
1680
1884
  return true;
1681
1885
  }
1682
1886
  /* Remove mXSS vectors, processing instructions and risky comments */
@@ -1686,7 +1890,7 @@ function createDOMPurify() {
1686
1890
  }
1687
1891
  /* Remove element if anything forbids its presence */
1688
1892
  if (FORBID_TAGS[tagName] || !(EXTRA_ELEMENT_HANDLING.tagCheck instanceof Function && EXTRA_ELEMENT_HANDLING.tagCheck(tagName)) && !ALLOWED_TAGS[tagName]) {
1689
- const removed = _sanitizeDisallowedNode(currentNode, tagName);
1893
+ const removed = _sanitizeDisallowedNode(currentNode, tagName, root);
1690
1894
  /* A false return means the node is a custom element kept via
1691
1895
  CUSTOM_ELEMENT_HANDLING - the only keep path through
1692
1896
  _sanitizeDisallowedNode. Run afterSanitizeElements on it so the
@@ -1709,7 +1913,7 @@ function createDOMPurify() {
1709
1913
  bound and short-circuits to false for any node minted in a different
1710
1914
  realm — letting a foreign-realm element with a forbidden namespace
1711
1915
  slip past the namespace check entirely. */
1712
- const nt = getNodeType ? getNodeType(currentNode) : currentNode.nodeType;
1916
+ const nt = _readNodeType(currentNode);
1713
1917
  if (nt === NODE_TYPE.element && !_checkValidNamespace(currentNode)) {
1714
1918
  _forceRemove(currentNode);
1715
1919
  return true;
@@ -1769,10 +1973,7 @@ function createDOMPurify() {
1769
1973
  other structural-threat checks and stays overridable, consistent with
1770
1974
  the rest of the codebase. PI range markers are already removed by
1771
1975
  _isUnsafeNode. */
1772
- if (SAFE_FOR_XML && lcName === 'patchsrc') {
1773
- return false;
1774
- }
1775
- if (SAFE_FOR_XML && lcName === 'for' && lcTag !== 'label' && lcTag !== 'output') {
1976
+ if (_isPatchLinkageAttribute(lcName, lcTag)) {
1776
1977
  return false;
1777
1978
  }
1778
1979
  /* Make sure attribute cannot clobber */
@@ -1784,22 +1985,54 @@ function createDOMPurify() {
1784
1985
  (https://html.spec.whatwg.org/multipage/dom.html#embedding-custom-non-visible-data-with-the-data-*-attributes)
1785
1986
  XML-compatible (https://html.spec.whatwg.org/multipage/infrastructure.html#xml-compatible and http://www.w3.org/TR/xml/#d0e804)
1786
1987
  We don't need to check the value; it's always URI safe. */
1787
- if (ALLOW_DATA_ATTR && regExpTest(DATA_ATTR$1, lcName)) ; else if (ALLOW_ARIA_ATTR && regExpTest(ARIA_ATTR$1, lcName)) ; else if (!nameIsPermitted) {
1788
- if (
1789
- // First condition does a very basic check if a) it's basically a valid custom element tagname AND
1790
- // b) if the tagName passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck
1791
- // and c) if the attribute name passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.attributeNameCheck
1792
- _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)) ||
1793
- // Alternative, second condition checks if it's an `is`-attribute, AND
1794
- // the value passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck
1795
- 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 {
1796
- return false;
1797
- }
1798
- /* Check value is safe. First, is attr inert? If so, is safe */
1799
- } 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) {
1800
- return false;
1801
- } else ;
1802
- return true;
1988
+ if (ALLOW_DATA_ATTR && regExpTest(DATA_ATTR$1, lcName)) {
1989
+ return true;
1990
+ }
1991
+ /* Allow valid aria-* attributes, the value is always URI safe */
1992
+ if (ALLOW_ARIA_ATTR && regExpTest(ARIA_ATTR$1, lcName)) {
1993
+ return true;
1994
+ }
1995
+ /* A name outside the allowlist is acceptable on custom-element terms
1996
+ only. The value checks below are intentionally skipped in that case:
1997
+ if the user supplied a tagNameCheck we also allow derived custom
1998
+ elements using the same test, and attributes passing the configured
1999
+ attributeNameCheck are allowed as custom elements define these at
2000
+ their own discretion. */
2001
+ if (!nameIsPermitted) {
2002
+ return (
2003
+ // Condition a) covers a basically valid custom element tag name whose
2004
+ // tag passes the configured tagNameCheck and whose attribute name
2005
+ // passes the configured attributeNameCheck ...
2006
+ _isBasicCustomElement(lcTag) && _matchesNameCheck(CUSTOM_ELEMENT_HANDLING.tagNameCheck, lcTag) && _matchesNameCheck(CUSTOM_ELEMENT_HANDLING.attributeNameCheck, lcName, lcTag) ||
2007
+ // Condition b) covers an `is` attribute whose value passes the
2008
+ // configured tagNameCheck while customized built-in elements are
2009
+ // allowed.
2010
+ lcName === 'is' && CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements && _matchesNameCheck(CUSTOM_ELEMENT_HANDLING.tagNameCheck, value)
2011
+ );
2012
+ }
2013
+ /* Check value is safe. First, is attr inert? If so, is safe */
2014
+ if (URI_SAFE_ATTRIBUTES[lcName]) {
2015
+ return true;
2016
+ }
2017
+ /* Check no script, data or unknown possibly unsafe URI
2018
+ unless we know URI values are safe for that attribute */
2019
+ if (regExpTest(IS_ALLOWED_URI$1, stringReplace(value, ATTR_WHITESPACE$1, ''))) {
2020
+ return true;
2021
+ }
2022
+ /* Keep image data URIs alive if src/xlink:href is allowed */
2023
+ /* Further prevent gadget XSS for dynamically built script tags */
2024
+ if ((lcName === 'src' || lcName === 'xlink:href' || lcName === 'href') && lcTag !== 'script' && stringIndexOf(value, 'data:') === 0 && DATA_URI_TAGS[lcTag]) {
2025
+ return true;
2026
+ }
2027
+ /* Allow unknown protocols: This provides support for links that
2028
+ are handled by protocol handlers which may be unknown ahead of
2029
+ time, e.g. fb:, spotify: */
2030
+ if (ALLOW_UNKNOWN_PROTOCOLS && !regExpTest(IS_SCRIPT_OR_DATA$1, stringReplace(value, ATTR_WHITESPACE$1, ''))) {
2031
+ return true;
2032
+ }
2033
+ /* Only an empty (binary) value remains safe at this point;
2034
+ anything else is presumed unsafe, do not add it back */
2035
+ return !value;
1803
2036
  };
1804
2037
  /* Names the HTML spec reserves from valid-custom-element-name; these must
1805
2038
  * never be treated as basic custom elements even when a permissive
@@ -1891,6 +2124,9 @@ function createDOMPurify() {
1891
2124
  if (!attributes || _isClobbered(currentNode)) {
1892
2125
  return;
1893
2126
  }
2127
+ /* Same lazy-install guard as uponSanitizeElement (see there): fork the
2128
+ attribute allowlist off its shared binding before a hook can see it. */
2129
+ ALLOWED_ATTR = _forkSharedAllowlist(hooks.uponSanitizeAttribute, ALLOWED_ATTR, DEFAULT_ALLOWED_ATTR, SET_CONFIG_ALLOWED_ATTR);
1894
2130
  const hookEvent = {
1895
2131
  attrName: '',
1896
2132
  attrValue: '',
@@ -1921,7 +2157,7 @@ function createDOMPurify() {
1921
2157
  */
1922
2158
  if (SANITIZE_NAMED_PROPS && (lcName === 'id' || lcName === 'name') && stringIndexOf(value, SANITIZE_NAMED_PROPS_PREFIX) !== 0) {
1923
2159
  // Remove the attribute with this value
1924
- _removeAttribute(name, currentNode);
2160
+ _removeAttribute(name, currentNode, attr);
1925
2161
  // Prefix the value and later re-create the attribute with the sanitized value
1926
2162
  value = SANITIZE_NAMED_PROPS_PREFIX + value;
1927
2163
  }
@@ -1929,12 +2165,12 @@ function createDOMPurify() {
1929
2165
  // itself the clobbering protection, and re-applying it is incorrect.
1930
2166
  /* Work around a security issue with comments inside attributes */
1931
2167
  if (SAFE_FOR_XML && regExpTest(/((--!?|])>)|<\/(style|script|title|xmp|textarea|noscript|iframe|noembed|noframes)/i, value)) {
1932
- _removeAttribute(name, currentNode);
2168
+ _removeAttribute(name, currentNode, attr);
1933
2169
  continue;
1934
2170
  }
1935
2171
  /* Make sure we cannot easily use animated hrefs, even if animations are allowed */
1936
2172
  if (lcName === 'attributename' && stringMatch(value, 'href')) {
1937
- _removeAttribute(name, currentNode);
2173
+ _removeAttribute(name, currentNode, attr);
1938
2174
  continue;
1939
2175
  }
1940
2176
  /* Did the hooks force-keep the attribute? */
@@ -1943,12 +2179,12 @@ function createDOMPurify() {
1943
2179
  }
1944
2180
  /* Did the hooks approve of the attribute? */
1945
2181
  if (!hookEvent.keepAttr) {
1946
- _removeAttribute(name, currentNode);
2182
+ _removeAttribute(name, currentNode, attr);
1947
2183
  continue;
1948
2184
  }
1949
2185
  /* Work around a security issue in jQuery 3.0 */
1950
2186
  if (!ALLOW_SELF_CLOSE_IN_ATTR && regExpTest(SELF_CLOSING_TAG, value)) {
1951
- _removeAttribute(name, currentNode);
2187
+ _removeAttribute(name, currentNode, attr);
1952
2188
  continue;
1953
2189
  }
1954
2190
  /* Sanitize attribute content to be template-safe */
@@ -1957,7 +2193,7 @@ function createDOMPurify() {
1957
2193
  }
1958
2194
  /* Is `value` valid for this attribute? */
1959
2195
  if (!_isValidAttribute(lcTag, lcName, value)) {
1960
- _removeAttribute(name, currentNode);
2196
+ _removeAttribute(name, currentNode, attr);
1961
2197
  continue;
1962
2198
  }
1963
2199
  /* Handle attributes that require Trusted Types */
@@ -2004,8 +2240,7 @@ function createDOMPurify() {
2004
2240
  Walk it explicitly. The nodeType guard avoids reading
2005
2241
  shadowRoot off text / comment / CDATA / PI nodes that the
2006
2242
  iterator also surfaces. */
2007
- const shadowNodeType = getNodeType ? getNodeType(shadowNode) : shadowNode.nodeType;
2008
- if (shadowNodeType === NODE_TYPE.element) {
2243
+ if (_readNodeType(shadowNode) === NODE_TYPE.element) {
2009
2244
  const innerSr = getShadowRoot(shadowNode);
2010
2245
  if (_isDocumentFragment(innerSr)) {
2011
2246
  _sanitizeAttachedShadowRoots(innerSr);
@@ -2063,7 +2298,7 @@ function createDOMPurify() {
2063
2298
  continue;
2064
2299
  }
2065
2300
  const node = item.node;
2066
- const nodeType = getNodeType ? getNodeType(node) : node.nodeType;
2301
+ const nodeType = _readNodeType(node);
2067
2302
  const isElement = nodeType === NODE_TYPE.element;
2068
2303
  /* (pushed last → processed first) Children, snapshotted in reverse so
2069
2304
  the first child is processed first. Snapshotting matters because a
@@ -2185,7 +2420,7 @@ function createDOMPurify() {
2185
2420
  child named "nodeName" on the form root would otherwise shadow
2186
2421
  the property and let this check skip the root-allowlist
2187
2422
  validation entirely. */
2188
- const nn = getNodeName ? getNodeName(dirty) : dirty.nodeName;
2423
+ const nn = _readNodeName(dirty);
2189
2424
  if (typeof nn === 'string') {
2190
2425
  const tagName = transformCaseFunc(nn);
2191
2426
  if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) {
@@ -2263,17 +2498,20 @@ function createDOMPurify() {
2263
2498
  }
2264
2499
  /* Get node iterator */
2265
2500
  const walkRoot = inPlace ? dirty : body;
2266
- const nodeIterator = _createNodeIterator(walkRoot);
2267
2501
  /* Now start iterating over the created document.
2268
2502
  The walk runs inside an exception barrier (campaign-3 F2): a re-entrant
2269
2503
  engine/custom-element mutation can detach a node mid-walk so
2270
2504
  `_forceRemove`'s parentless guard throws, aborting the loop. Without the
2271
2505
  barrier the caller's in-place tree would be left half-sanitized with the
2272
- unvisited tail still armed. On any throw we fail closed — strip the
2273
- in-place root bare then rethrow so the existing throw contract is
2274
- preserved. (String/DOM-copy paths never return the partial body, so the
2275
- propagating throw is already fail-closed there.) */
2506
+ unvisited tail still armed. _createNodeIterator itself is inside the
2507
+ barrier too: constructing the iterator dereferences the root's document,
2508
+ and any failure there (e.g. an exotic/clobbered root) must still fail
2509
+ closed rather than skip the neutralize. On any throw we fail closed -
2510
+ strip the in-place root bare - then rethrow so the existing throw
2511
+ contract is preserved. (String/DOM-copy paths never return the partial
2512
+ body, so the propagating throw is already fail-closed there.) */
2276
2513
  try {
2514
+ const nodeIterator = _createNodeIterator(walkRoot);
2277
2515
  while (currentNode = nodeIterator.nextNode()) {
2278
2516
  /* Sanitize tags and elements */
2279
2517
  _sanitizeElements(currentNode, walkRoot);