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.
package/dist/purify.js CHANGED
@@ -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 (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.12';
479
+ DOMPurify.version = '3.4.14';
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
@@ -461,6 +507,17 @@
461
507
  const getAttributes = lookupGetter(ElementPrototype, 'attributes');
462
508
  const getNodeType = Node && Node.prototype ? lookupGetter(Node.prototype, 'nodeType') : null;
463
509
  const getNodeName = Node && Node.prototype ? lookupGetter(Node.prototype, 'nodeName') : null;
510
+ const getOwnerDocument = Node && Node.prototype ? lookupGetter(Node.prototype, 'ownerDocument') : null;
511
+ /* Clobber-safe nodeType / nodeName reads through the cached Node.prototype
512
+ getters, with a direct-property fallback for environments that lack
513
+ Node.prototype. Sites that need a different fallback (e.g. _isClobbered
514
+ returns early on a null name) intentionally keep their own reads. */
515
+ const _readNodeType = function _readNodeType(node) {
516
+ return getNodeType ? getNodeType(node) : node.nodeType;
517
+ };
518
+ const _readNodeName = function _readNodeName(node) {
519
+ return getNodeName ? getNodeName(node) : node.nodeName;
520
+ };
464
521
  // As per issue #47, the web-components registry is inherited by a
465
522
  // new document created via createHTMLDocument. As per the spec
466
523
  // (http://w3c.github.io/webcomponents/spec/custom/#creating-and-passing-registries)
@@ -783,9 +840,11 @@
783
840
  IN_PLACE = cfg.IN_PLACE || false; // Default false
784
841
  IS_ALLOWED_URI$1 = isRegex(cfg.ALLOWED_URI_REGEXP) ? cfg.ALLOWED_URI_REGEXP : IS_ALLOWED_URI; // Default regexp
785
842
  NAMESPACE = typeof cfg.NAMESPACE === 'string' ? cfg.NAMESPACE : HTML_NAMESPACE; // Default HTML namespace
786
- 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
787
- 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
788
- const customElementHandling = objectHasOwnProperty(cfg, 'CUSTOM_ELEMENT_HANDLING') && cfg.CUSTOM_ELEMENT_HANDLING && typeof cfg.CUSTOM_ELEMENT_HANDLING === 'object' ? clone(cfg.CUSTOM_ELEMENT_HANDLING) : create(null);
843
+ MATHML_TEXT_INTEGRATION_POINTS = _resolveObjectOption(cfg, 'MATHML_TEXT_INTEGRATION_POINTS', () => addToSet({}, DEFAULT_MATHML_TEXT_INTEGRATION_POINTS) // Default built-in map
844
+ );
845
+ HTML_INTEGRATION_POINTS = _resolveObjectOption(cfg, 'HTML_INTEGRATION_POINTS', () => addToSet({}, DEFAULT_HTML_INTEGRATION_POINTS) // Default built-in map
846
+ );
847
+ const customElementHandling = _resolveObjectOption(cfg, 'CUSTOM_ELEMENT_HANDLING', () => create(null));
789
848
  CUSTOM_ELEMENT_HANDLING = create(null);
790
849
  if (objectHasOwnProperty(customElementHandling, 'tagNameCheck') && isRegexOrFunction(customElementHandling.tagNameCheck)) {
791
850
  CUSTOM_ELEMENT_HANDLING.tagNameCheck = customElementHandling.tagNameCheck; // Default undefined
@@ -852,15 +911,6 @@
852
911
  addToSet(ALLOWED_ATTR, cfg.ADD_ATTR, transformCaseFunc);
853
912
  }
854
913
  }
855
- if (objectHasOwnProperty(cfg, 'ADD_URI_SAFE_ATTR') && arrayIsArray(cfg.ADD_URI_SAFE_ATTR)) {
856
- addToSet(URI_SAFE_ATTRIBUTES, cfg.ADD_URI_SAFE_ATTR, transformCaseFunc);
857
- }
858
- if (objectHasOwnProperty(cfg, 'FORBID_CONTENTS') && arrayIsArray(cfg.FORBID_CONTENTS)) {
859
- if (FORBID_CONTENTS === DEFAULT_FORBID_CONTENTS) {
860
- FORBID_CONTENTS = clone(FORBID_CONTENTS);
861
- }
862
- addToSet(FORBID_CONTENTS, cfg.FORBID_CONTENTS, transformCaseFunc);
863
- }
864
914
  if (objectHasOwnProperty(cfg, 'ADD_FORBID_CONTENTS') && arrayIsArray(cfg.ADD_FORBID_CONTENTS)) {
865
915
  if (FORBID_CONTENTS === DEFAULT_FORBID_CONTENTS) {
866
916
  FORBID_CONTENTS = clone(FORBID_CONTENTS);
@@ -1095,6 +1145,32 @@
1095
1145
  }
1096
1146
  }
1097
1147
  };
1148
+ /**
1149
+ * _stripAttributeNode
1150
+ *
1151
+ * Remove a single Attr node case/namespace-exactly on an attribute-teardown
1152
+ * path. Name-based removeAttribute() ASCII-lowercases its lookup key for an
1153
+ * HTML element in an HTML document and so silently misses a case-preserved
1154
+ * handler (e.g. `ONERROR` off an XML/XHTML import) - the same defect
1155
+ * _removeAttribute() was fixed for, which a name-based call would reintroduce
1156
+ * on these IN_PLACE teardown paths. Unlike _removeAttribute this does not
1157
+ * record into DOMPurify.removed: the neutralize passes intentionally do not
1158
+ * book-keep. A clobbered/detached node falls back to best-effort name-based
1159
+ * removal.
1160
+ *
1161
+ * @param element the element to strip the attribute from
1162
+ * @param attribute the Attr node to remove
1163
+ * @param name the attribute's name, for the fallback path
1164
+ */
1165
+ const _stripAttributeNode = function _stripAttributeNode(element, attribute, name) {
1166
+ try {
1167
+ element.removeAttributeNode(attribute);
1168
+ } catch (_) {
1169
+ try {
1170
+ element.removeAttribute(name);
1171
+ } catch (_) {}
1172
+ }
1173
+ };
1098
1174
  /**
1099
1175
  * _neutralizeRoot
1100
1176
  *
@@ -1139,11 +1215,7 @@
1139
1215
  const attribute = attributes[i];
1140
1216
  const name = attribute && attribute.name;
1141
1217
  if (typeof name === 'string') {
1142
- try {
1143
- root.removeAttribute(name);
1144
- } catch (_) {
1145
- /* Clobbered removeAttribute — ignore (fail-closed best effort) */
1146
- }
1218
+ _stripAttributeNode(root, attribute, name);
1147
1219
  }
1148
1220
  }
1149
1221
  }
@@ -1151,22 +1223,46 @@
1151
1223
  /**
1152
1224
  * _removeAttribute
1153
1225
  *
1226
+ * Name-based getAttributeNode()/removeAttribute() ASCII-lowercase their
1227
+ * lookup key for HTML elements in an HTML document, so they silently miss an
1228
+ * attribute whose stored qualified name still contains uppercase ASCII
1229
+ * letters. That happens when the node came from a case-preserving source
1230
+ * (an XML/XHTML document imported via importNode(), or createAttributeNS()),
1231
+ * where e.g. `ONERROR` survives the walk: the policy check lowercases to
1232
+ * `onerror` and rejects it, but `removeAttribute('ONERROR')` looks up
1233
+ * `onerror` and finds nothing. Remove the exact Attr node instead, which is
1234
+ * case- and namespace-exact, and fall back to name-based removal only when
1235
+ * the caller could not supply the node.
1236
+ *
1154
1237
  * @param name an Attribute name
1155
1238
  * @param element a DOM node
1239
+ * @param attr the exact Attr node to remove, when the caller has it
1156
1240
  */
1157
- const _removeAttribute = function _removeAttribute(name, element) {
1241
+ const _removeAttribute = function _removeAttribute(name, element, attr) {
1242
+ if (!attr) {
1243
+ try {
1244
+ attr = element.getAttributeNode(name);
1245
+ } catch (_) {
1246
+ attr = null;
1247
+ }
1248
+ }
1249
+ arrayPush(DOMPurify.removed, {
1250
+ attribute: attr || null,
1251
+ from: element
1252
+ });
1158
1253
  try {
1159
- arrayPush(DOMPurify.removed, {
1160
- attribute: element.getAttributeNode(name),
1161
- from: element
1162
- });
1254
+ if (attr) {
1255
+ element.removeAttributeNode(attr);
1256
+ } else {
1257
+ element.removeAttribute(name);
1258
+ }
1163
1259
  } catch (_) {
1164
- arrayPush(DOMPurify.removed, {
1165
- attribute: null,
1166
- from: element
1167
- });
1260
+ /* Clobbered or already-detached node - best-effort fall back to a
1261
+ name-based removal so the "is" handling below still runs. */
1262
+ try {
1263
+ element.removeAttribute(name);
1264
+ } catch (_) {}
1168
1265
  }
1169
- element.removeAttribute(name);
1170
1266
  // We void attribute values for unremovable "is" attributes
1171
1267
  if (name === 'is') {
1172
1268
  if (RETURN_DOM || RETURN_DOM_FRAGMENT) {
@@ -1201,11 +1297,7 @@
1201
1297
  if (typeof name !== 'string' || ALLOWED_ATTR[transformCaseFunc(name)]) {
1202
1298
  continue;
1203
1299
  }
1204
- try {
1205
- element.removeAttribute(name);
1206
- } catch (_) {
1207
- /* Clobbered removeAttribute on a doomed node — ignore */
1208
- }
1300
+ _stripAttributeNode(element, attribute, name);
1209
1301
  }
1210
1302
  };
1211
1303
  /**
@@ -1234,7 +1326,7 @@
1234
1326
  const stack = [root];
1235
1327
  while (stack.length > 0) {
1236
1328
  const node = stack.pop();
1237
- const nodeType = getNodeType ? getNodeType(node) : node.nodeType;
1329
+ const nodeType = _readNodeType(node);
1238
1330
  if (nodeType === NODE_TYPE.element) {
1239
1331
  _stripDisallowedAttributes(node);
1240
1332
  }
@@ -1281,6 +1373,26 @@
1281
1373
  *
1282
1374
  * @param root the in-place root to sweep
1283
1375
  */
1376
+ /**
1377
+ * Central policy for declarative-partial-updates patch-linkage attributes,
1378
+ * shared by the _neutralizePatchLinkage pre-pass and _isValidAttribute so
1379
+ * the two sites cannot drift: `patchsrc` always links, `for` links
1380
+ * everywhere except on <label>/<output>, and the whole policy is gated on
1381
+ * SAFE_FOR_XML (see the rationale block in _isValidAttribute).
1382
+ *
1383
+ * @param lcName the transformCaseFunc'd attribute name
1384
+ * @param lcTag the transformCaseFunc'd tag name of the carrying element
1385
+ * @return true if the attribute is patch linkage and must be dropped
1386
+ */
1387
+ const _isPatchLinkageAttribute = function _isPatchLinkageAttribute(lcName, lcTag) {
1388
+ if (!SAFE_FOR_XML) {
1389
+ return false;
1390
+ }
1391
+ if (lcName === 'patchsrc') {
1392
+ return true;
1393
+ }
1394
+ return lcName === 'for' && lcTag !== 'label' && lcTag !== 'output';
1395
+ };
1284
1396
  const _neutralizePatchLinkage = function _neutralizePatchLinkage(root) {
1285
1397
  if (!SAFE_FOR_XML) {
1286
1398
  return;
@@ -1288,7 +1400,7 @@
1288
1400
  const stack = [root];
1289
1401
  while (stack.length > 0) {
1290
1402
  const node = stack.pop();
1291
- const nodeType = getNodeType ? getNodeType(node) : node.nodeType;
1403
+ const nodeType = _readNodeType(node);
1292
1404
  /* Remove range markers (the target side of a patch linkage): every
1293
1405
  processing instruction, and any markup-bearing comment. */
1294
1406
  if (nodeType === NODE_TYPE.processingInstruction || nodeType === NODE_TYPE.comment && regExpTest(COMMENT_MARKUP_PROBE, node.data)) {
@@ -1302,12 +1414,12 @@
1302
1414
  /* Strip patch-source attributes (the source side) off elements. */
1303
1415
  if (nodeType === NODE_TYPE.element) {
1304
1416
  const element = node;
1305
- const lcTag = transformCaseFunc(getNodeName ? getNodeName(node) : node.nodeName);
1417
+ const lcTag = transformCaseFunc(_readNodeName(node));
1306
1418
  try {
1307
1419
  if (element.hasAttribute && element.hasAttribute('patchsrc')) {
1308
1420
  element.removeAttribute('patchsrc');
1309
1421
  }
1310
- if (element.hasAttribute && element.hasAttribute('for') && lcTag !== 'label' && lcTag !== 'output') {
1422
+ if (element.hasAttribute && element.hasAttribute('for') && _isPatchLinkageAttribute('for', lcTag)) {
1311
1423
  element.removeAttribute('for');
1312
1424
  }
1313
1425
  } catch (_) {
@@ -1379,7 +1491,17 @@
1379
1491
  * @return The created NodeIterator
1380
1492
  */
1381
1493
  const _createNodeIterator = function _createNodeIterator(root) {
1382
- return createNodeIterator.call(root.ownerDocument || root, root,
1494
+ /* Read ownerDocument through the cached Node.prototype getter, never the
1495
+ direct property. HTMLFormElement has [LegacyOverrideBuiltIns], so a
1496
+ clobbering child (<input name="ownerDocument"> or a form-associated
1497
+ external input) shadows the prototype getter and makes a direct read
1498
+ return that <input>. createNodeIterator.call(<input>, ...) then throws
1499
+ "Illegal invocation", and on the IN_PLACE path that throw lands before
1500
+ the walk's fail-closed barrier - leaving the caller's live tree, with
1501
+ any already-armed handler in it, un-neutralized. The cached getter
1502
+ returns the real Document regardless of the clobber. */
1503
+ const doc = getOwnerDocument ? getOwnerDocument(root) : root.ownerDocument;
1504
+ return createNodeIterator.call(doc || root, root,
1383
1505
  // eslint-disable-next-line no-bitwise
1384
1506
  NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT | NodeFilter.SHOW_PROCESSING_INSTRUCTION | NodeFilter.SHOW_CDATA_SECTION, null);
1385
1507
  };
@@ -1419,7 +1541,11 @@
1419
1541
  const _scrubTemplateExpressions2 = function _scrubTemplateExpressions(node) {
1420
1542
  var _node$querySelectorAl;
1421
1543
  node.normalize();
1422
- const walker = createNodeIterator.call(node.ownerDocument || node, node,
1544
+ /* Clobber-safe ownerDocument read, same reasoning as _createNodeIterator:
1545
+ under SAFE_FOR_TEMPLATES this runs on the live IN_PLACE root, which may
1546
+ carry a form-named-getter override of ownerDocument. */
1547
+ const doc = getOwnerDocument ? getOwnerDocument(node) : node.ownerDocument;
1548
+ const walker = createNodeIterator.call(doc || node, node,
1423
1549
  // eslint-disable-next-line no-bitwise
1424
1550
  NodeFilter.SHOW_TEXT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_CDATA_SECTION | NodeFilter.SHOW_PROCESSING_INSTRUCTION, null);
1425
1551
  let currentNode = walker.nextNode();
@@ -1555,8 +1681,16 @@
1555
1681
  if (SAFE_FOR_XML && currentNode.hasChildNodes() && !_isNode(currentNode.firstElementChild) && regExpTest(ELEMENT_MARKUP_PROBE, currentNode.textContent) && regExpTest(ELEMENT_MARKUP_PROBE, currentNode.innerHTML)) {
1556
1682
  return true;
1557
1683
  }
1558
- /* Remove risky CSS construction leading to mXSS */
1559
- if (SAFE_FOR_XML && currentNode.namespaceURI === HTML_NAMESPACE && tagName === 'style' && _isNode(currentNode.firstElementChild)) {
1684
+ /* Remove rawtext/literal-text elements whose literal serialization
1685
+ re-opens markup on an HTML reparse - shapes (a) and (b) documented at
1686
+ LITERAL_TEXT_ELEMENTS. Both are invisible to rule 1 above (which
1687
+ self-disables once there is an element child, and whose second probe
1688
+ reads the innerHTML an XML/XHTML document serializes escaped), which
1689
+ is why both probes here read textContent instead. Previously only
1690
+ `style`-with-element-child was covered; every element in
1691
+ LITERAL_TEXT_ELEMENTS shares this literal serialization and is
1692
+ equally affected. */
1693
+ 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))) {
1560
1694
  return true;
1561
1695
  }
1562
1696
  /* Remove any occurrence of processing instructions */
@@ -1569,6 +1703,30 @@
1569
1703
  }
1570
1704
  return false;
1571
1705
  };
1706
+ /**
1707
+ * Evaluate a CUSTOM_ELEMENT_HANDLING check (a RegExp or a predicate
1708
+ * function, per the validation in _parseConfig) against a name.
1709
+ * Additional arguments are forwarded to predicate functions - the
1710
+ * attributeNameCheck predicate receives the tag name as its second
1711
+ * argument. A null/absent check never matches.
1712
+ *
1713
+ * @param check the configured tagNameCheck / attributeNameCheck value
1714
+ * @param name the name to test
1715
+ * @param args extra arguments forwarded to a predicate function
1716
+ * @return true if the check matches the name
1717
+ */
1718
+ const _matchesNameCheck = function _matchesNameCheck(check, name) {
1719
+ if (check instanceof RegExp) {
1720
+ return regExpTest(check, name);
1721
+ }
1722
+ if (check instanceof Function) {
1723
+ for (var _len = arguments.length, args = new Array(_len > 2 ? _len - 2 : 0), _key = 2; _key < _len; _key++) {
1724
+ args[_key - 2] = arguments[_key];
1725
+ }
1726
+ return Boolean(check(name, ...args));
1727
+ }
1728
+ return false;
1729
+ };
1572
1730
  /**
1573
1731
  * Handle a node whose tag is forbidden or not allowlisted: keep
1574
1732
  * allowed custom elements (false return exits _sanitizeElements
@@ -1586,15 +1744,10 @@
1586
1744
  * @param tagName the node's transformCaseFunc'd tag name
1587
1745
  * @return true if the node was removed, false if kept
1588
1746
  */
1589
- const _sanitizeDisallowedNode = function _sanitizeDisallowedNode(currentNode, tagName) {
1747
+ const _sanitizeDisallowedNode = function _sanitizeDisallowedNode(currentNode, tagName, root) {
1590
1748
  /* Check if we have a custom element to handle */
1591
- if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName)) {
1592
- if (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)) {
1593
- return false;
1594
- }
1595
- if (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.tagNameCheck(tagName)) {
1596
- return false;
1597
- }
1749
+ if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName) && _matchesNameCheck(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)) {
1750
+ return false;
1598
1751
  }
1599
1752
  /* Keep content except for bad-listed elements.
1600
1753
  Use the cached prototype getters exclusively — the previous code
@@ -1609,27 +1762,26 @@
1609
1762
  const childNodes = getChildNodes(currentNode);
1610
1763
  if (childNodes && parentNode) {
1611
1764
  const childCount = childNodes.length;
1612
- /* In-place: hoist the *original* children so the iterator visits
1613
- and sanitises them through the same allowlist pass as every other
1614
- node. The caller built the tree in the live document, so the
1615
- originals carry already-queued resource events (`<img onerror>`,
1616
- `<video>`/`<audio>` error, lazy/`onload`, …); cloning would leave
1617
- those originals detached but still armed, firing in page scope
1618
- while the returned tree looked clean. Moving is safe in-place: the
1619
- root is pre-validated as an allowed tag and so is never the node
1620
- being removed, which keeps `parentNode` inside the iterator root
1621
- and the relocated child inside the serialised tree.
1622
- Otherwise (string / DOM-copy paths): clone. The iterator is rooted
1623
- at and the result serialised from `body`, so a restrictive
1624
- ALLOWED_TAGS that removes `body` itself must leave its content in
1625
- place, which only cloning does; and those paths parse into an
1626
- inert document, so their discarded originals never had a queued
1627
- event to neutralise.
1765
+ /* Hoist by moving each child up one level rather than deep-cloning
1766
+ it. Moving transfers every descendant exactly once, so a chain of
1767
+ nested disallowed elements costs O(n) instead of the O(n^2) that
1768
+ re-cloning the shrinking subtree at each level produced; it also
1769
+ empties the removed original, so `DOMPurify.removed` no longer
1770
+ pins whole subtrees. Moving preserves the in-place guarantee too:
1771
+ an original carrying already-queued resource events (`<img
1772
+ onerror>`, `<video>`/`<audio>` error, lazy/`onload`, …) is
1773
+ relocated and sanitised rather than left detached but still armed.
1774
+ The sole case that must clone is removing the walk root itself.
1775
+ The result is serialised from the root's subtree, so a restrictive
1776
+ ALLOWED_TAGS that strips the root (`body` on the string path) must
1777
+ leave the content inside it, which only cloning does. In IN_PLACE
1778
+ the root is pre-validated as an allowed tag and so is never removed
1779
+ here, so that path always takes the move branch.
1628
1780
  `childNodes` is live; a tail-to-head walk keeps `childNodes[i]`
1629
1781
  valid whether we move (drops the trailing entry) or clone (leaves
1630
1782
  the list intact). */
1631
1783
  for (let i = childCount - 1; i >= 0; --i) {
1632
- const hoisted = IN_PLACE ? childNodes[i] : cloneNode(childNodes[i], true);
1784
+ const hoisted = currentNode === root ? cloneNode(childNodes[i], true) : childNodes[i];
1633
1785
  parentNode.insertBefore(hoisted, getNextSibling(currentNode));
1634
1786
  }
1635
1787
  }
@@ -1637,6 +1789,66 @@
1637
1789
  _forceRemove(currentNode);
1638
1790
  return true;
1639
1791
  };
1792
+ /**
1793
+ * Fork a hook-mutable allowlist off its shared binding the first time a
1794
+ * (possibly lazily-installed) uponSanitize* hook is about to see it, so the
1795
+ * hook cannot widen the per-instance default or the setConfig binding by
1796
+ * reference and leak past the call. Returns the set unchanged once it is
1797
+ * already call-local, so repeated calls across elements are idempotent.
1798
+ *
1799
+ * @param hookList the uponSanitize* hook array for this event
1800
+ * @param set the current ALLOWED_TAGS / ALLOWED_ATTR binding
1801
+ * @param defaultSet the per-instance DEFAULT_ALLOWED_* constant
1802
+ * @param setConfigSet the captured setConfig() binding, or null
1803
+ * @return a call-local clone if a hook is present and set is still shared,
1804
+ * else set unchanged
1805
+ */
1806
+ const _forkSharedAllowlist = function _forkSharedAllowlist(hookList, set, defaultSet, setConfigSet) {
1807
+ if (hookList.length === 0) {
1808
+ return set;
1809
+ }
1810
+ return set === defaultSet || set === setConfigSet ? clone(set) : set;
1811
+ };
1812
+ /**
1813
+ * Shared guard for a node that a hook has detached from the walk tree,
1814
+ * used after each element-hook site in _sanitizeElements. Detaching is a
1815
+ * long-standing user pattern (issue #469; draw.io-style foreignObject
1816
+ * filtering). Per the cached, unclobberable parentNode getter the node is
1817
+ * genuinely out of the tree, so it can reach neither the serialized
1818
+ * output nor an IN_PLACE live tree; treat it as removed and stop
1819
+ * processing it. Without this guard, the unsafe-node / namespace checks
1820
+ * would call _forceRemove on a parentless node and hit the REPORT-3
1821
+ * fail-closed throw — which exists for nodes DOMPurify wants gone but
1822
+ * *cannot* detach (clobbered / parentless roots), the opposite of a node
1823
+ * that is already safely gone. The walk root is exempt: a detached
1824
+ * IN_PLACE root is legitimate input and must still be fully sanitized,
1825
+ * and a kill-decision on it must keep hitting the REPORT-3 throw.
1826
+ *
1827
+ * Nodes detached by hooks stay the hook's responsibility for placement:
1828
+ * they are not recorded in DOMPurify.removed, so the post-walk IN_PLACE
1829
+ * pass (which iterates DOMPurify.removed) does not reach them. But a
1830
+ * hook-detached subtree can still hold a queued resource-event handler -
1831
+ * e.g. an <img onload> that began loading when the caller built the live
1832
+ * tree - which fires in page scope after sanitize returns even though the
1833
+ * handler never reached the returned tree. That is the audit-5 F1 hazard,
1834
+ * and the documented node.remove() hook pattern walks straight into it.
1835
+ * So on the IN_PLACE path we neutralize the detached subtree inline,
1836
+ * stripping its non-allow-listed attributes before returning, exactly as
1837
+ * the post-walk pass does for _forceRemove'd subtrees.
1838
+ *
1839
+ * @param currentNode the node a hook may have detached
1840
+ * @param root the current walk root
1841
+ * @return true if the node is detached and now handled, false otherwise
1842
+ */
1843
+ const _handleHookDetachedNode = function _handleHookDetachedNode(currentNode, root) {
1844
+ if (currentNode === root || getParentNode(currentNode) !== null) {
1845
+ return false;
1846
+ }
1847
+ if (IN_PLACE) {
1848
+ _neutralizeSubtree(currentNode);
1849
+ }
1850
+ return true;
1851
+ };
1640
1852
  /**
1641
1853
  * _sanitizeElements
1642
1854
  *
@@ -1646,13 +1858,12 @@
1646
1858
  * @param currentNode to check for permission to exist
1647
1859
  * @return true if node was killed, false if left alive
1648
1860
  */
1649
- // eslint-disable-next-line complexity
1650
1861
  const _sanitizeElements = function _sanitizeElements(currentNode, root) {
1651
1862
  /* Execute a hook if present */
1652
1863
  _executeHooks(hooks.beforeSanitizeElements, currentNode, null);
1653
- /* A hook may have detached the node treat it as removed (see the
1654
- detached-node comment after the uponSanitizeElement hook below). */
1655
- if (currentNode !== root && getParentNode(currentNode) === null) {
1864
+ /* A hook may have detached the node - treat it as removed (see
1865
+ _handleHookDetachedNode for the full rationale). */
1866
+ if (_handleHookDetachedNode(currentNode, root)) {
1656
1867
  return true;
1657
1868
  }
1658
1869
  /* Check if element is clobbered or can clobber */
@@ -1661,28 +1872,21 @@
1661
1872
  return true;
1662
1873
  }
1663
1874
  /* Now let's check the element's type and name */
1664
- const tagName = transformCaseFunc(getNodeName ? getNodeName(currentNode) : currentNode.nodeName);
1875
+ const tagName = transformCaseFunc(_readNodeName(currentNode));
1876
+ /* Close the pre-walk clone-guard's timing gap: an uponSanitizeElement
1877
+ hook may have been installed after that guard sampled the hook arrays
1878
+ (e.g. lazily from beforeSanitizeElements), leaving ALLOWED_TAGS still
1879
+ aliasing a shared binding that a widening hook would mutate by
1880
+ reference. Fork it before exposing it to the hook. */
1881
+ ALLOWED_TAGS = _forkSharedAllowlist(hooks.uponSanitizeElement, ALLOWED_TAGS, DEFAULT_ALLOWED_TAGS, SET_CONFIG_ALLOWED_TAGS);
1665
1882
  /* Execute a hook if present */
1666
1883
  _executeHooks(hooks.uponSanitizeElement, currentNode, {
1667
1884
  tagName,
1668
1885
  allowedTags: ALLOWED_TAGS
1669
1886
  });
1670
- /* A hook may have detached the node from the tree — a long-standing
1671
- user pattern (issue #469; draw.io-style foreignObject filtering).
1672
- Per the cached, unclobberable parentNode getter the node is
1673
- genuinely out of the tree, so it can reach neither the serialized
1674
- output nor an IN_PLACE live tree; treat it as removed and stop
1675
- processing it. Without this guard, the unsafe-node / namespace
1676
- checks below would call _forceRemove on a parentless node and hit
1677
- the REPORT-3 fail-closed throw — which exists for nodes DOMPurify
1678
- wants gone but *cannot* detach (clobbered / parentless roots), the
1679
- opposite of a node that is already safely gone. The walk root is
1680
- exempt: a detached IN_PLACE root is legitimate input and must still
1681
- be fully sanitized, and a kill-decision on it must keep hitting the
1682
- REPORT-3 throw. Nodes detached by hooks are the hook's
1683
- responsibility: they are not recorded in DOMPurify.removed and are
1684
- not neutralized by the post-walk IN_PLACE pass. */
1685
- if (currentNode !== root && getParentNode(currentNode) === null) {
1887
+ /* The uponSanitizeElement hook may have detached the node, exactly as
1888
+ above (see _handleHookDetachedNode for the full rationale). */
1889
+ if (_handleHookDetachedNode(currentNode, root)) {
1686
1890
  return true;
1687
1891
  }
1688
1892
  /* Remove mXSS vectors, processing instructions and risky comments */
@@ -1692,7 +1896,7 @@
1692
1896
  }
1693
1897
  /* Remove element if anything forbids its presence */
1694
1898
  if (FORBID_TAGS[tagName] || !(EXTRA_ELEMENT_HANDLING.tagCheck instanceof Function && EXTRA_ELEMENT_HANDLING.tagCheck(tagName)) && !ALLOWED_TAGS[tagName]) {
1695
- const removed = _sanitizeDisallowedNode(currentNode, tagName);
1899
+ const removed = _sanitizeDisallowedNode(currentNode, tagName, root);
1696
1900
  /* A false return means the node is a custom element kept via
1697
1901
  CUSTOM_ELEMENT_HANDLING - the only keep path through
1698
1902
  _sanitizeDisallowedNode. Run afterSanitizeElements on it so the
@@ -1715,7 +1919,7 @@
1715
1919
  bound and short-circuits to false for any node minted in a different
1716
1920
  realm — letting a foreign-realm element with a forbidden namespace
1717
1921
  slip past the namespace check entirely. */
1718
- const nt = getNodeType ? getNodeType(currentNode) : currentNode.nodeType;
1922
+ const nt = _readNodeType(currentNode);
1719
1923
  if (nt === NODE_TYPE.element && !_checkValidNamespace(currentNode)) {
1720
1924
  _forceRemove(currentNode);
1721
1925
  return true;
@@ -1775,10 +1979,7 @@
1775
1979
  other structural-threat checks and stays overridable, consistent with
1776
1980
  the rest of the codebase. PI range markers are already removed by
1777
1981
  _isUnsafeNode. */
1778
- if (SAFE_FOR_XML && lcName === 'patchsrc') {
1779
- return false;
1780
- }
1781
- if (SAFE_FOR_XML && lcName === 'for' && lcTag !== 'label' && lcTag !== 'output') {
1982
+ if (_isPatchLinkageAttribute(lcName, lcTag)) {
1782
1983
  return false;
1783
1984
  }
1784
1985
  /* Make sure attribute cannot clobber */
@@ -1790,22 +1991,54 @@
1790
1991
  (https://html.spec.whatwg.org/multipage/dom.html#embedding-custom-non-visible-data-with-the-data-*-attributes)
1791
1992
  XML-compatible (https://html.spec.whatwg.org/multipage/infrastructure.html#xml-compatible and http://www.w3.org/TR/xml/#d0e804)
1792
1993
  We don't need to check the value; it's always URI safe. */
1793
- if (ALLOW_DATA_ATTR && regExpTest(DATA_ATTR$1, lcName)) ; else if (ALLOW_ARIA_ATTR && regExpTest(ARIA_ATTR$1, lcName)) ; else if (!nameIsPermitted) {
1794
- if (
1795
- // First condition does a very basic check if a) it's basically a valid custom element tagname AND
1796
- // b) if the tagName passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck
1797
- // and c) if the attribute name passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.attributeNameCheck
1798
- _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)) ||
1799
- // Alternative, second condition checks if it's an `is`-attribute, AND
1800
- // the value passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck
1801
- 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 {
1802
- return false;
1803
- }
1804
- /* Check value is safe. First, is attr inert? If so, is safe */
1805
- } 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) {
1806
- return false;
1807
- } else ;
1808
- return true;
1994
+ if (ALLOW_DATA_ATTR && regExpTest(DATA_ATTR$1, lcName)) {
1995
+ return true;
1996
+ }
1997
+ /* Allow valid aria-* attributes, the value is always URI safe */
1998
+ if (ALLOW_ARIA_ATTR && regExpTest(ARIA_ATTR$1, lcName)) {
1999
+ return true;
2000
+ }
2001
+ /* A name outside the allowlist is acceptable on custom-element terms
2002
+ only. The value checks below are intentionally skipped in that case:
2003
+ if the user supplied a tagNameCheck we also allow derived custom
2004
+ elements using the same test, and attributes passing the configured
2005
+ attributeNameCheck are allowed as custom elements define these at
2006
+ their own discretion. */
2007
+ if (!nameIsPermitted) {
2008
+ return (
2009
+ // Condition a) covers a basically valid custom element tag name whose
2010
+ // tag passes the configured tagNameCheck and whose attribute name
2011
+ // passes the configured attributeNameCheck ...
2012
+ _isBasicCustomElement(lcTag) && _matchesNameCheck(CUSTOM_ELEMENT_HANDLING.tagNameCheck, lcTag) && _matchesNameCheck(CUSTOM_ELEMENT_HANDLING.attributeNameCheck, lcName, lcTag) ||
2013
+ // Condition b) covers an `is` attribute whose value passes the
2014
+ // configured tagNameCheck while customized built-in elements are
2015
+ // allowed.
2016
+ lcName === 'is' && CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements && _matchesNameCheck(CUSTOM_ELEMENT_HANDLING.tagNameCheck, value)
2017
+ );
2018
+ }
2019
+ /* Check value is safe. First, is attr inert? If so, is safe */
2020
+ if (URI_SAFE_ATTRIBUTES[lcName]) {
2021
+ return true;
2022
+ }
2023
+ /* Check no script, data or unknown possibly unsafe URI
2024
+ unless we know URI values are safe for that attribute */
2025
+ if (regExpTest(IS_ALLOWED_URI$1, stringReplace(value, ATTR_WHITESPACE$1, ''))) {
2026
+ return true;
2027
+ }
2028
+ /* Keep image data URIs alive if src/xlink:href is allowed */
2029
+ /* Further prevent gadget XSS for dynamically built script tags */
2030
+ if ((lcName === 'src' || lcName === 'xlink:href' || lcName === 'href') && lcTag !== 'script' && stringIndexOf(value, 'data:') === 0 && DATA_URI_TAGS[lcTag]) {
2031
+ return true;
2032
+ }
2033
+ /* Allow unknown protocols: This provides support for links that
2034
+ are handled by protocol handlers which may be unknown ahead of
2035
+ time, e.g. fb:, spotify: */
2036
+ if (ALLOW_UNKNOWN_PROTOCOLS && !regExpTest(IS_SCRIPT_OR_DATA$1, stringReplace(value, ATTR_WHITESPACE$1, ''))) {
2037
+ return true;
2038
+ }
2039
+ /* Only an empty (binary) value remains safe at this point;
2040
+ anything else is presumed unsafe, do not add it back */
2041
+ return !value;
1809
2042
  };
1810
2043
  /* Names the HTML spec reserves from valid-custom-element-name; these must
1811
2044
  * never be treated as basic custom elements even when a permissive
@@ -1897,6 +2130,9 @@
1897
2130
  if (!attributes || _isClobbered(currentNode)) {
1898
2131
  return;
1899
2132
  }
2133
+ /* Same lazy-install guard as uponSanitizeElement (see there): fork the
2134
+ attribute allowlist off its shared binding before a hook can see it. */
2135
+ ALLOWED_ATTR = _forkSharedAllowlist(hooks.uponSanitizeAttribute, ALLOWED_ATTR, DEFAULT_ALLOWED_ATTR, SET_CONFIG_ALLOWED_ATTR);
1900
2136
  const hookEvent = {
1901
2137
  attrName: '',
1902
2138
  attrValue: '',
@@ -1927,7 +2163,7 @@
1927
2163
  */
1928
2164
  if (SANITIZE_NAMED_PROPS && (lcName === 'id' || lcName === 'name') && stringIndexOf(value, SANITIZE_NAMED_PROPS_PREFIX) !== 0) {
1929
2165
  // Remove the attribute with this value
1930
- _removeAttribute(name, currentNode);
2166
+ _removeAttribute(name, currentNode, attr);
1931
2167
  // Prefix the value and later re-create the attribute with the sanitized value
1932
2168
  value = SANITIZE_NAMED_PROPS_PREFIX + value;
1933
2169
  }
@@ -1935,12 +2171,12 @@
1935
2171
  // itself the clobbering protection, and re-applying it is incorrect.
1936
2172
  /* Work around a security issue with comments inside attributes */
1937
2173
  if (SAFE_FOR_XML && regExpTest(/((--!?|])>)|<\/(style|script|title|xmp|textarea|noscript|iframe|noembed|noframes)/i, value)) {
1938
- _removeAttribute(name, currentNode);
2174
+ _removeAttribute(name, currentNode, attr);
1939
2175
  continue;
1940
2176
  }
1941
2177
  /* Make sure we cannot easily use animated hrefs, even if animations are allowed */
1942
2178
  if (lcName === 'attributename' && stringMatch(value, 'href')) {
1943
- _removeAttribute(name, currentNode);
2179
+ _removeAttribute(name, currentNode, attr);
1944
2180
  continue;
1945
2181
  }
1946
2182
  /* Did the hooks force-keep the attribute? */
@@ -1949,12 +2185,12 @@
1949
2185
  }
1950
2186
  /* Did the hooks approve of the attribute? */
1951
2187
  if (!hookEvent.keepAttr) {
1952
- _removeAttribute(name, currentNode);
2188
+ _removeAttribute(name, currentNode, attr);
1953
2189
  continue;
1954
2190
  }
1955
2191
  /* Work around a security issue in jQuery 3.0 */
1956
2192
  if (!ALLOW_SELF_CLOSE_IN_ATTR && regExpTest(SELF_CLOSING_TAG, value)) {
1957
- _removeAttribute(name, currentNode);
2193
+ _removeAttribute(name, currentNode, attr);
1958
2194
  continue;
1959
2195
  }
1960
2196
  /* Sanitize attribute content to be template-safe */
@@ -1963,7 +2199,7 @@
1963
2199
  }
1964
2200
  /* Is `value` valid for this attribute? */
1965
2201
  if (!_isValidAttribute(lcTag, lcName, value)) {
1966
- _removeAttribute(name, currentNode);
2202
+ _removeAttribute(name, currentNode, attr);
1967
2203
  continue;
1968
2204
  }
1969
2205
  /* Handle attributes that require Trusted Types */
@@ -2010,8 +2246,7 @@
2010
2246
  Walk it explicitly. The nodeType guard avoids reading
2011
2247
  shadowRoot off text / comment / CDATA / PI nodes that the
2012
2248
  iterator also surfaces. */
2013
- const shadowNodeType = getNodeType ? getNodeType(shadowNode) : shadowNode.nodeType;
2014
- if (shadowNodeType === NODE_TYPE.element) {
2249
+ if (_readNodeType(shadowNode) === NODE_TYPE.element) {
2015
2250
  const innerSr = getShadowRoot(shadowNode);
2016
2251
  if (_isDocumentFragment(innerSr)) {
2017
2252
  _sanitizeAttachedShadowRoots(innerSr);
@@ -2069,7 +2304,7 @@
2069
2304
  continue;
2070
2305
  }
2071
2306
  const node = item.node;
2072
- const nodeType = getNodeType ? getNodeType(node) : node.nodeType;
2307
+ const nodeType = _readNodeType(node);
2073
2308
  const isElement = nodeType === NODE_TYPE.element;
2074
2309
  /* (pushed last → processed first) Children, snapshotted in reverse so
2075
2310
  the first child is processed first. Snapshotting matters because a
@@ -2191,7 +2426,7 @@
2191
2426
  child named "nodeName" on the form root would otherwise shadow
2192
2427
  the property and let this check skip the root-allowlist
2193
2428
  validation entirely. */
2194
- const nn = getNodeName ? getNodeName(dirty) : dirty.nodeName;
2429
+ const nn = _readNodeName(dirty);
2195
2430
  if (typeof nn === 'string') {
2196
2431
  const tagName = transformCaseFunc(nn);
2197
2432
  if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) {
@@ -2269,17 +2504,20 @@
2269
2504
  }
2270
2505
  /* Get node iterator */
2271
2506
  const walkRoot = inPlace ? dirty : body;
2272
- const nodeIterator = _createNodeIterator(walkRoot);
2273
2507
  /* Now start iterating over the created document.
2274
2508
  The walk runs inside an exception barrier (campaign-3 F2): a re-entrant
2275
2509
  engine/custom-element mutation can detach a node mid-walk so
2276
2510
  `_forceRemove`'s parentless guard throws, aborting the loop. Without the
2277
2511
  barrier the caller's in-place tree would be left half-sanitized with the
2278
- unvisited tail still armed. On any throw we fail closed — strip the
2279
- in-place root bare then rethrow so the existing throw contract is
2280
- preserved. (String/DOM-copy paths never return the partial body, so the
2281
- propagating throw is already fail-closed there.) */
2512
+ unvisited tail still armed. _createNodeIterator itself is inside the
2513
+ barrier too: constructing the iterator dereferences the root's document,
2514
+ and any failure there (e.g. an exotic/clobbered root) must still fail
2515
+ closed rather than skip the neutralize. On any throw we fail closed -
2516
+ strip the in-place root bare - then rethrow so the existing throw
2517
+ contract is preserved. (String/DOM-copy paths never return the partial
2518
+ body, so the propagating throw is already fail-closed there.) */
2282
2519
  try {
2520
+ const nodeIterator = _createNodeIterator(walkRoot);
2283
2521
  while (currentNode = nodeIterator.nextNode()) {
2284
2522
  /* Sanitize tags and elements */
2285
2523
  _sanitizeElements(currentNode, walkRoot);