dompurify 3.4.8 → 3.4.10

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/src/purify.ts CHANGED
@@ -1,10 +1,7 @@
1
1
  /* eslint-disable @typescript-eslint/indent */
2
2
 
3
- import type {
4
- TrustedHTML,
5
- TrustedTypesWindow,
6
- } from 'trusted-types/lib/index.js';
7
3
  import type { Config, UseProfilesConfig } from './config';
4
+ import type { DOMPurify, HooksMap, HookFunction, WindowLike } from './types';
8
5
  import * as TAGS from './tags.js';
9
6
  import * as ATTRS from './attrs.js';
10
7
  import * as EXPRESSIONS from './regexp.js';
@@ -13,6 +10,7 @@ import {
13
10
  clone,
14
11
  entries,
15
12
  freeze,
13
+ seal,
16
14
  arrayForEach,
17
15
  arrayIsArray,
18
16
  arrayLastIndexOf,
@@ -36,6 +34,21 @@ import {
36
34
 
37
35
  export type { Config } from './config';
38
36
 
37
+ export type {
38
+ DOMPurify,
39
+ RemovedElement,
40
+ RemovedAttribute,
41
+ HookName,
42
+ NodeHook,
43
+ ElementHook,
44
+ DocumentFragmentHook,
45
+ UponSanitizeElementHook,
46
+ UponSanitizeAttributeHook,
47
+ UponSanitizeElementHookEvent,
48
+ UponSanitizeAttributeHookEvent,
49
+ WindowLike,
50
+ } from './types';
51
+
39
52
  declare const VERSION: string;
40
53
 
41
54
  // https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType
@@ -46,7 +59,7 @@ const NODE_TYPE = {
46
59
  cdataSection: 4,
47
60
  entityReference: 5, // Deprecated
48
61
  entityNode: 6, // Deprecated
49
- progressingInstruction: 7,
62
+ processingInstruction: 7,
50
63
  comment: 8,
51
64
  document: 9,
52
65
  documentType: 10,
@@ -122,6 +135,36 @@ const _createHooksMap = function (): HooksMap {
122
135
  };
123
136
  };
124
137
 
138
+ /**
139
+ * Resolve a set-valued configuration option: a fresh set built from
140
+ * cfg[key] when it is an own array property (seeded with a clone of
141
+ * options.base when given, case-normalized via options.transform),
142
+ * the fallback set otherwise.
143
+ *
144
+ * @param cfg the cloned, prototype-free configuration object
145
+ * @param key the configuration property to read
146
+ * @param fallback the set to use when the option is absent or not an array
147
+ * @param options transform and optional base set to merge into
148
+ * @returns the resolved set
149
+ */
150
+ const _resolveSetOption = function (
151
+ cfg: Config,
152
+ key: keyof Config,
153
+ fallback: Record<string, boolean>,
154
+ options: {
155
+ transform: Parameters<typeof addToSet>[2];
156
+ base?: Record<string, boolean>;
157
+ }
158
+ ): Record<string, boolean> {
159
+ return objectHasOwnProperty(cfg, key) && arrayIsArray(cfg[key])
160
+ ? addToSet(
161
+ options.base ? clone(options.base) : {},
162
+ cfg[key] as readonly unknown[],
163
+ options.transform
164
+ )
165
+ : fallback;
166
+ };
167
+
125
168
  function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
126
169
  const DOMPurify: DOMPurify = (root: WindowLike) => createDOMPurify(root);
127
170
 
@@ -189,30 +232,71 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
189
232
  let trustedTypesPolicy;
190
233
  let emptyHTML = '';
191
234
 
235
+ // The instance's own internal Trusted Types policy. Unlike a caller-supplied
236
+ // `TRUSTED_TYPES_POLICY`, this is created at most once — Trusted Types throws
237
+ // on duplicate policy names — and is the only policy allowed to persist
238
+ // across configurations and survive `clearConfig()`.
239
+ let defaultTrustedTypesPolicy;
240
+ let defaultTrustedTypesPolicyResolved = false;
241
+
192
242
  // Tracks whether we are already inside a call to the configured Trusted Types
193
- // policy's `createHTML`. If the supplied `TRUSTED_TYPES_POLICY.createHTML`
243
+ // policy (`createHTML` or `createScriptURL`). If a supplied policy callback
194
244
  // itself calls `DOMPurify.sanitize` (the cause of #1422), `sanitize` would
195
245
  // re-enter the policy and recurse until the stack overflows. We detect that
196
- // re-entry and throw a clear, actionable error instead.
197
- let IN_POLICY_CREATE_HTML = 0;
198
- const _createTrustedHTML = function (html: string): string {
199
- if (IN_POLICY_CREATE_HTML > 0) {
246
+ // re-entry and throw a clear, actionable error instead. The guard is shared
247
+ // across both callbacks, because either one re-entering `sanitize` triggers
248
+ // the same unbounded recursion.
249
+ let IN_TRUSTED_TYPES_POLICY = 0;
250
+ const _assertNotInTrustedTypesPolicy = function (): void {
251
+ if (IN_TRUSTED_TYPES_POLICY > 0) {
200
252
  throw typeErrorCreate(
201
- 'The configured TRUSTED_TYPES_POLICY.createHTML must not call ' +
202
- 'DOMPurify.sanitize, as that causes infinite recursion. Do not pass ' +
203
- 'a policy whose createHTML wraps DOMPurify as TRUSTED_TYPES_POLICY; ' +
204
- 'see the "DOMPurify and Trusted Types" section of the README.'
253
+ 'A configured TRUSTED_TYPES_POLICY callback (createHTML or ' +
254
+ 'createScriptURL) must not call DOMPurify.sanitize, as that causes ' +
255
+ 'infinite recursion. Do not pass a policy whose callbacks wrap ' +
256
+ 'DOMPurify as TRUSTED_TYPES_POLICY; see the "DOMPurify and Trusted ' +
257
+ 'Types" section of the README.'
205
258
  );
206
259
  }
260
+ };
261
+
262
+ const _createTrustedHTML = function (html: string): string {
263
+ _assertNotInTrustedTypesPolicy();
207
264
 
208
- IN_POLICY_CREATE_HTML++;
265
+ IN_TRUSTED_TYPES_POLICY++;
209
266
  try {
210
267
  return trustedTypesPolicy.createHTML(html);
211
268
  } finally {
212
- IN_POLICY_CREATE_HTML--;
269
+ IN_TRUSTED_TYPES_POLICY--;
213
270
  }
214
271
  };
215
272
 
273
+ const _createTrustedScriptURL = function (scriptUrl: string): string {
274
+ _assertNotInTrustedTypesPolicy();
275
+
276
+ IN_TRUSTED_TYPES_POLICY++;
277
+ try {
278
+ return trustedTypesPolicy.createScriptURL(scriptUrl);
279
+ } finally {
280
+ IN_TRUSTED_TYPES_POLICY--;
281
+ }
282
+ };
283
+
284
+ // Lazily resolve (and cache) the instance's internal default policy.
285
+ // Resolution is attempted at most once: a successful `createPolicy` cannot be
286
+ // repeated (Trusted Types throws on duplicate names), and a failed or
287
+ // unsupported attempt must not be retried on every parse.
288
+ const _getDefaultTrustedTypesPolicy = function () {
289
+ if (!defaultTrustedTypesPolicyResolved) {
290
+ defaultTrustedTypesPolicy = _createTrustedTypesPolicy(
291
+ trustedTypes,
292
+ currentScript
293
+ );
294
+ defaultTrustedTypesPolicyResolved = true;
295
+ }
296
+
297
+ return defaultTrustedTypesPolicy;
298
+ };
299
+
216
300
  const {
217
301
  implementation,
218
302
  createNodeIterator,
@@ -421,6 +505,16 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
421
505
  'noscript',
422
506
  'plaintext',
423
507
  'script',
508
+ // <selectedcontent> mirrors the selected <option>'s subtree, cloned by
509
+ // the UA (customizable <select>) — including any on* handlers — and the
510
+ // engine re-mirrors synchronously whenever a removal changes which
511
+ // option/selectedcontent is current, even inside DOMPurify's inert
512
+ // DOMParser document. Hoisting its children on removal re-inserts a fresh
513
+ // mirror target ahead of the walk, which the engine refills, looping
514
+ // forever (DoS) and amplifying output. Dropping its content on removal
515
+ // (rather than hoisting) breaks that cascade; the content is a duplicate
516
+ // of the option, which is sanitized on its own. See campaign-3 F1/F6.
517
+ 'selectedcontent',
424
518
  'style',
425
519
  'svg',
426
520
  'template',
@@ -475,15 +569,20 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
475
569
  stringToString
476
570
  );
477
571
 
478
- let MATHML_TEXT_INTEGRATION_POINTS = addToSet({}, [
572
+ const DEFAULT_MATHML_TEXT_INTEGRATION_POINTS = freeze([
479
573
  'mi',
480
574
  'mo',
481
575
  'mn',
482
576
  'ms',
483
577
  'mtext',
484
578
  ]);
579
+ let MATHML_TEXT_INTEGRATION_POINTS = addToSet(
580
+ {},
581
+ DEFAULT_MATHML_TEXT_INTEGRATION_POINTS
582
+ );
485
583
 
486
- let HTML_INTEGRATION_POINTS = addToSet({}, ['annotation-xml']);
584
+ const DEFAULT_HTML_INTEGRATION_POINTS = freeze(['annotation-xml']);
585
+ let HTML_INTEGRATION_POINTS = addToSet({}, DEFAULT_HTML_INTEGRATION_POINTS);
487
586
 
488
587
  // Certain elements are allowed in both SVG and HTML
489
588
  // namespace. We need to specify them explicitly
@@ -549,52 +648,48 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
549
648
  : stringToLowerCase;
550
649
 
551
650
  /* Set configuration parameters */
552
- ALLOWED_TAGS =
553
- objectHasOwnProperty(cfg, 'ALLOWED_TAGS') &&
554
- arrayIsArray(cfg.ALLOWED_TAGS)
555
- ? addToSet({}, cfg.ALLOWED_TAGS, transformCaseFunc)
556
- : DEFAULT_ALLOWED_TAGS;
557
- ALLOWED_ATTR =
558
- objectHasOwnProperty(cfg, 'ALLOWED_ATTR') &&
559
- arrayIsArray(cfg.ALLOWED_ATTR)
560
- ? addToSet({}, cfg.ALLOWED_ATTR, transformCaseFunc)
561
- : DEFAULT_ALLOWED_ATTR;
562
- ALLOWED_NAMESPACES =
563
- objectHasOwnProperty(cfg, 'ALLOWED_NAMESPACES') &&
564
- arrayIsArray(cfg.ALLOWED_NAMESPACES)
565
- ? addToSet({}, cfg.ALLOWED_NAMESPACES, stringToString)
566
- : DEFAULT_ALLOWED_NAMESPACES;
567
- URI_SAFE_ATTRIBUTES =
568
- objectHasOwnProperty(cfg, 'ADD_URI_SAFE_ATTR') &&
569
- arrayIsArray(cfg.ADD_URI_SAFE_ATTR)
570
- ? addToSet(
571
- clone(DEFAULT_URI_SAFE_ATTRIBUTES),
572
- cfg.ADD_URI_SAFE_ATTR,
573
- transformCaseFunc
574
- )
575
- : DEFAULT_URI_SAFE_ATTRIBUTES;
576
- DATA_URI_TAGS =
577
- objectHasOwnProperty(cfg, 'ADD_DATA_URI_TAGS') &&
578
- arrayIsArray(cfg.ADD_DATA_URI_TAGS)
579
- ? addToSet(
580
- clone(DEFAULT_DATA_URI_TAGS),
581
- cfg.ADD_DATA_URI_TAGS,
582
- transformCaseFunc
583
- )
584
- : DEFAULT_DATA_URI_TAGS;
585
- FORBID_CONTENTS =
586
- objectHasOwnProperty(cfg, 'FORBID_CONTENTS') &&
587
- arrayIsArray(cfg.FORBID_CONTENTS)
588
- ? addToSet({}, cfg.FORBID_CONTENTS, transformCaseFunc)
589
- : DEFAULT_FORBID_CONTENTS;
590
- FORBID_TAGS =
591
- objectHasOwnProperty(cfg, 'FORBID_TAGS') && arrayIsArray(cfg.FORBID_TAGS)
592
- ? addToSet({}, cfg.FORBID_TAGS, transformCaseFunc)
593
- : clone({});
594
- FORBID_ATTR =
595
- objectHasOwnProperty(cfg, 'FORBID_ATTR') && arrayIsArray(cfg.FORBID_ATTR)
596
- ? addToSet({}, cfg.FORBID_ATTR, transformCaseFunc)
597
- : clone({});
651
+ ALLOWED_TAGS = _resolveSetOption(
652
+ cfg,
653
+ 'ALLOWED_TAGS',
654
+ DEFAULT_ALLOWED_TAGS,
655
+ { transform: transformCaseFunc }
656
+ );
657
+ ALLOWED_ATTR = _resolveSetOption(
658
+ cfg,
659
+ 'ALLOWED_ATTR',
660
+ DEFAULT_ALLOWED_ATTR,
661
+ { transform: transformCaseFunc }
662
+ );
663
+ ALLOWED_NAMESPACES = _resolveSetOption(
664
+ cfg,
665
+ 'ALLOWED_NAMESPACES',
666
+ DEFAULT_ALLOWED_NAMESPACES,
667
+ { transform: stringToString }
668
+ );
669
+ URI_SAFE_ATTRIBUTES = _resolveSetOption(
670
+ cfg,
671
+ 'ADD_URI_SAFE_ATTR',
672
+ DEFAULT_URI_SAFE_ATTRIBUTES,
673
+ { transform: transformCaseFunc, base: DEFAULT_URI_SAFE_ATTRIBUTES }
674
+ );
675
+ DATA_URI_TAGS = _resolveSetOption(
676
+ cfg,
677
+ 'ADD_DATA_URI_TAGS',
678
+ DEFAULT_DATA_URI_TAGS,
679
+ { transform: transformCaseFunc, base: DEFAULT_DATA_URI_TAGS }
680
+ );
681
+ FORBID_CONTENTS = _resolveSetOption(
682
+ cfg,
683
+ 'FORBID_CONTENTS',
684
+ DEFAULT_FORBID_CONTENTS,
685
+ { transform: transformCaseFunc }
686
+ );
687
+ FORBID_TAGS = _resolveSetOption(cfg, 'FORBID_TAGS', clone({}), {
688
+ transform: transformCaseFunc,
689
+ });
690
+ FORBID_ATTR = _resolveSetOption(cfg, 'FORBID_ATTR', clone({}), {
691
+ transform: transformCaseFunc,
692
+ });
598
693
  USE_PROFILES = objectHasOwnProperty(cfg, 'USE_PROFILES')
599
694
  ? cfg.USE_PROFILES && typeof cfg.USE_PROFILES === 'object'
600
695
  ? clone(cfg.USE_PROFILES)
@@ -628,14 +723,14 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
628
723
  cfg.MATHML_TEXT_INTEGRATION_POINTS &&
629
724
  typeof cfg.MATHML_TEXT_INTEGRATION_POINTS === 'object'
630
725
  ? clone(cfg.MATHML_TEXT_INTEGRATION_POINTS)
631
- : addToSet({}, ['mi', 'mo', 'mn', 'ms', 'mtext']); // Default built-in map
726
+ : addToSet({}, DEFAULT_MATHML_TEXT_INTEGRATION_POINTS); // Default built-in map
632
727
 
633
728
  HTML_INTEGRATION_POINTS =
634
729
  objectHasOwnProperty(cfg, 'HTML_INTEGRATION_POINTS') &&
635
730
  cfg.HTML_INTEGRATION_POINTS &&
636
731
  typeof cfg.HTML_INTEGRATION_POINTS === 'object'
637
732
  ? clone(cfg.HTML_INTEGRATION_POINTS)
638
- : addToSet({}, ['annotation-xml']); // Default built-in map
733
+ : addToSet({}, DEFAULT_HTML_INTEGRATION_POINTS); // Default built-in map
639
734
 
640
735
  const customElementHandling =
641
736
  objectHasOwnProperty(cfg, 'CUSTOM_ELEMENT_HANDLING') &&
@@ -672,6 +767,8 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
672
767
  customElementHandling.allowCustomizedBuiltInElements; // Default undefined
673
768
  }
674
769
 
770
+ seal(CUSTOM_ELEMENT_HANDLING);
771
+
675
772
  if (SAFE_FOR_TEMPLATES) {
676
773
  ALLOW_DATA_ATTR = false;
677
774
  }
@@ -783,6 +880,13 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
783
880
  delete FORBID_TAGS.tbody;
784
881
  }
785
882
 
883
+ // Re-derive the active Trusted Types policy from this configuration on
884
+ // every parse. The active policy must never be sticky closure state that
885
+ // outlives the config that set it: a caller-supplied policy left in place
886
+ // after `clearConfig()` — or after a later call that supplied none, or
887
+ // `TRUSTED_TYPES_POLICY: null` — could sign a subsequent "default"
888
+ // `RETURN_TRUSTED_TYPE` result with a foreign, possibly unsafe policy.
889
+ // See GHSA-vxr8-fq34-vvx9.
786
890
  if (cfg.TRUSTED_TYPES_POLICY) {
787
891
  if (typeof cfg.TRUSTED_TYPES_POLICY.createHTML !== 'function') {
788
892
  throw typeErrorCreate(
@@ -796,7 +900,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
796
900
  );
797
901
  }
798
902
 
799
- // Overwrite existing TrustedTypes policy.
903
+ // A caller-supplied policy applies to this configuration only.
800
904
  const previousTrustedTypesPolicy = trustedTypesPolicy;
801
905
  trustedTypesPolicy = cfg.TRUSTED_TYPES_POLICY;
802
906
 
@@ -810,23 +914,31 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
810
914
  trustedTypesPolicy = previousTrustedTypesPolicy;
811
915
  throw error;
812
916
  }
917
+ } else if (cfg.TRUSTED_TYPES_POLICY === null) {
918
+ // Explicit opt-out for this call: perform no Trusted Types signing and
919
+ // create nothing (so a strict `trusted-types` CSP that disallows a
920
+ // `dompurify` policy can still call `sanitize` from inside its own
921
+ // policy — see #1422). Resetting to `undefined` rather than a sticky
922
+ // `null` also drops any previously retained caller policy, so it cannot
923
+ // resurface on a later call, while still allowing the next config-less
924
+ // call to restore the internal default policy. See GHSA-vxr8-fq34-vvx9.
925
+ trustedTypesPolicy = undefined;
926
+ emptyHTML = '';
813
927
  } else {
814
- // Uninitialized policy, attempt to initialize the internal dompurify policy.
815
- if (
816
- trustedTypesPolicy === undefined &&
817
- cfg.TRUSTED_TYPES_POLICY !== null
818
- ) {
819
- trustedTypesPolicy = _createTrustedTypesPolicy(
820
- trustedTypes,
821
- currentScript
822
- );
928
+ // No policy supplied: keep the currently active policy if one is set — a
929
+ // previously supplied policy is intentionally sticky across config-less
930
+ // calls otherwise fall back to the instance's own internal policy,
931
+ // created at most once. (A policy supplied for a *single* call still
932
+ // lingers by design; what must not linger is a policy whose configuration
933
+ // has been torn down via `clearConfig()`, which restores the default.)
934
+ if (trustedTypesPolicy === undefined) {
935
+ trustedTypesPolicy = _getDefaultTrustedTypesPolicy();
823
936
  }
824
937
 
825
- // If creating the internal policy succeeded sign internal variables.
826
- // Note: a falsy `trustedTypesPolicy` (null when policy creation failed or
827
- // was skipped via `TRUSTED_TYPES_POLICY: null`, or undefined when no
828
- // policy has been initialized yet) must be excluded here, otherwise we
829
- // would call `.createHTML` on a non-policy and throw. See #1422.
938
+ // Sign internal variables only when a policy is active. A falsy policy
939
+ // (Trusted Types unsupported, creation failed, or an explicit opt-out)
940
+ // leaves `emptyHTML` as a plain string, so we never call `.createHTML` on
941
+ // a non-policy and throw. See #1422.
830
942
  if (trustedTypesPolicy && typeof emptyHTML === 'string') {
831
943
  emptyHTML = _createTrustedHTML('');
832
944
  }
@@ -878,6 +990,111 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
878
990
  ...TAGS.mathMlDisallowed,
879
991
  ]);
880
992
 
993
+ /**
994
+ * Namespace rules for an element in the SVG namespace.
995
+ *
996
+ * @param tagName the element's lowercase tag name
997
+ * @param parent the (possibly simulated) parent node
998
+ * @param parentTagName the parent's lowercase tag name
999
+ * @returns true if a spec-compliant parser could produce this element
1000
+ */
1001
+ const _checkSvgNamespace = function (
1002
+ tagName: string,
1003
+ parent: { namespaceURI?: string },
1004
+ parentTagName: string
1005
+ ): boolean {
1006
+ // The only way to switch from HTML namespace to SVG
1007
+ // is via <svg>. If it happens via any other tag, then
1008
+ // it should be killed.
1009
+ if (parent.namespaceURI === HTML_NAMESPACE) {
1010
+ return tagName === 'svg';
1011
+ }
1012
+
1013
+ // The only way to switch from MathML to SVG is via <svg>
1014
+ // if the parent is either <annotation-xml> or a MathML
1015
+ // text integration point.
1016
+ if (parent.namespaceURI === MATHML_NAMESPACE) {
1017
+ return (
1018
+ tagName === 'svg' &&
1019
+ (parentTagName === 'annotation-xml' ||
1020
+ MATHML_TEXT_INTEGRATION_POINTS[parentTagName])
1021
+ );
1022
+ }
1023
+
1024
+ // We only allow elements that are defined in SVG
1025
+ // spec. All others are disallowed in SVG namespace.
1026
+ return Boolean(ALL_SVG_TAGS[tagName]);
1027
+ };
1028
+
1029
+ /**
1030
+ * Namespace rules for an element in the MathML namespace.
1031
+ *
1032
+ * @param tagName the element's lowercase tag name
1033
+ * @param parent the (possibly simulated) parent node
1034
+ * @param parentTagName the parent's lowercase tag name
1035
+ * @returns true if a spec-compliant parser could produce this element
1036
+ */
1037
+ const _checkMathMlNamespace = function (
1038
+ tagName: string,
1039
+ parent: { namespaceURI?: string },
1040
+ parentTagName: string
1041
+ ): boolean {
1042
+ // The only way to switch from HTML namespace to MathML
1043
+ // is via <math>. If it happens via any other tag, then
1044
+ // it should be killed.
1045
+ if (parent.namespaceURI === HTML_NAMESPACE) {
1046
+ return tagName === 'math';
1047
+ }
1048
+
1049
+ // The only way to switch from SVG to MathML is via
1050
+ // <math> and HTML integration points
1051
+ if (parent.namespaceURI === SVG_NAMESPACE) {
1052
+ return tagName === 'math' && HTML_INTEGRATION_POINTS[parentTagName];
1053
+ }
1054
+
1055
+ // We only allow elements that are defined in MathML
1056
+ // spec. All others are disallowed in MathML namespace.
1057
+ return Boolean(ALL_MATHML_TAGS[tagName]);
1058
+ };
1059
+
1060
+ /**
1061
+ * Namespace rules for an element in the HTML namespace.
1062
+ *
1063
+ * @param tagName the element's lowercase tag name
1064
+ * @param parent the (possibly simulated) parent node
1065
+ * @param parentTagName the parent's lowercase tag name
1066
+ * @returns true if a spec-compliant parser could produce this element
1067
+ */
1068
+ const _checkHtmlNamespace = function (
1069
+ tagName: string,
1070
+ parent: { namespaceURI?: string },
1071
+ parentTagName: string
1072
+ ): boolean {
1073
+ // The only way to switch from SVG to HTML is via
1074
+ // HTML integration points, and from MathML to HTML
1075
+ // is via MathML text integration points
1076
+ if (
1077
+ parent.namespaceURI === SVG_NAMESPACE &&
1078
+ !HTML_INTEGRATION_POINTS[parentTagName]
1079
+ ) {
1080
+ return false;
1081
+ }
1082
+
1083
+ if (
1084
+ parent.namespaceURI === MATHML_NAMESPACE &&
1085
+ !MATHML_TEXT_INTEGRATION_POINTS[parentTagName]
1086
+ ) {
1087
+ return false;
1088
+ }
1089
+
1090
+ // We disallow tags that are specific for MathML
1091
+ // or SVG and should never appear in HTML namespace
1092
+ return (
1093
+ !ALL_MATHML_TAGS[tagName] &&
1094
+ (COMMON_SVG_AND_HTML_ELEMENTS[tagName] || !ALL_SVG_TAGS[tagName])
1095
+ );
1096
+ };
1097
+
881
1098
  /**
882
1099
  * @param element a DOM element whose namespace is being checked
883
1100
  * @returns Return false if the element has a
@@ -904,72 +1121,15 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
904
1121
  }
905
1122
 
906
1123
  if (element.namespaceURI === SVG_NAMESPACE) {
907
- // The only way to switch from HTML namespace to SVG
908
- // is via <svg>. If it happens via any other tag, then
909
- // it should be killed.
910
- if (parent.namespaceURI === HTML_NAMESPACE) {
911
- return tagName === 'svg';
912
- }
913
-
914
- // The only way to switch from MathML to SVG is via`
915
- // svg if parent is either <annotation-xml> or MathML
916
- // text integration points.
917
- if (parent.namespaceURI === MATHML_NAMESPACE) {
918
- return (
919
- tagName === 'svg' &&
920
- (parentTagName === 'annotation-xml' ||
921
- MATHML_TEXT_INTEGRATION_POINTS[parentTagName])
922
- );
923
- }
924
-
925
- // We only allow elements that are defined in SVG
926
- // spec. All others are disallowed in SVG namespace.
927
- return Boolean(ALL_SVG_TAGS[tagName]);
1124
+ return _checkSvgNamespace(tagName, parent, parentTagName);
928
1125
  }
929
1126
 
930
1127
  if (element.namespaceURI === MATHML_NAMESPACE) {
931
- // The only way to switch from HTML namespace to MathML
932
- // is via <math>. If it happens via any other tag, then
933
- // it should be killed.
934
- if (parent.namespaceURI === HTML_NAMESPACE) {
935
- return tagName === 'math';
936
- }
937
-
938
- // The only way to switch from SVG to MathML is via
939
- // <math> and HTML integration points
940
- if (parent.namespaceURI === SVG_NAMESPACE) {
941
- return tagName === 'math' && HTML_INTEGRATION_POINTS[parentTagName];
942
- }
943
-
944
- // We only allow elements that are defined in MathML
945
- // spec. All others are disallowed in MathML namespace.
946
- return Boolean(ALL_MATHML_TAGS[tagName]);
1128
+ return _checkMathMlNamespace(tagName, parent, parentTagName);
947
1129
  }
948
1130
 
949
1131
  if (element.namespaceURI === HTML_NAMESPACE) {
950
- // The only way to switch from SVG to HTML is via
951
- // HTML integration points, and from MathML to HTML
952
- // is via MathML text integration points
953
- if (
954
- parent.namespaceURI === SVG_NAMESPACE &&
955
- !HTML_INTEGRATION_POINTS[parentTagName]
956
- ) {
957
- return false;
958
- }
959
-
960
- if (
961
- parent.namespaceURI === MATHML_NAMESPACE &&
962
- !MATHML_TEXT_INTEGRATION_POINTS[parentTagName]
963
- ) {
964
- return false;
965
- }
966
-
967
- // We disallow tags that are specific for MathML
968
- // or SVG and should never appear in HTML namespace
969
- return (
970
- !ALL_MATHML_TAGS[tagName] &&
971
- (COMMON_SVG_AND_HTML_ELEMENTS[tagName] || !ALL_SVG_TAGS[tagName])
972
- );
1132
+ return _checkHtmlNamespace(tagName, parent, parentTagName);
973
1133
  }
974
1134
 
975
1135
  // For XHTML and XML documents that support custom namespaces
@@ -999,7 +1159,81 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
999
1159
  // eslint-disable-next-line unicorn/prefer-dom-node-remove
1000
1160
  getParentNode(node).removeChild(node);
1001
1161
  } catch (_) {
1162
+ /* The normal detach failed — this is reached for a parentless node
1163
+ (getParentNode() is null, so .removeChild throws). Element.prototype
1164
+ .remove() is itself a spec no-op on a parentless node, so a recorded
1165
+ "removal" would otherwise hand the caller back an intact,
1166
+ payload-bearing node (e.g. a detached IN_PLACE root the mXSS canary or
1167
+ the style-with-element-child rule decided to kill). Fail closed by
1168
+ throwing — exactly as a clobbered root does at the IN_PLACE entry —
1169
+ rather than trying to "neutralize" the node via its own methods.
1170
+ Neutralizing would mean calling getAttributeNames()/removeAttribute()
1171
+ on the node, both of which a <form> root can clobber via a named child
1172
+ (and _isClobbered does not even probe getAttributeNames), so the
1173
+ neutralize step could itself be silently defeated, leaving the payload
1174
+ intact. A throw touches only the cached, clobber-safe remove() and
1175
+ getParentNode(). Generalizes GHSA-r47g-fvhr-h676 (clobbered-form root)
1176
+ to every root-kill reason. REPORT-3.
1177
+
1178
+ This lives inside the catch, so it never fires for a normally-removed
1179
+ in-tree node: those have a parent, removeChild() succeeds, and the
1180
+ catch is not entered. Only a kept (parentless) root reaches here. */
1002
1181
  remove(node);
1182
+
1183
+ if (!getParentNode(node)) {
1184
+ throw typeErrorCreate(
1185
+ 'a node selected for removal could not be detached from its tree ' +
1186
+ 'and cannot be safely returned; refusing to sanitize in place'
1187
+ );
1188
+ }
1189
+ }
1190
+ };
1191
+
1192
+ /**
1193
+ * _neutralizeRoot
1194
+ *
1195
+ * Fail-closed teardown of an in-place root after the sanitize walk aborts
1196
+ * (campaign-3 F2). An internal throw mid-walk — e.g. a page-registered
1197
+ * custom element's reaction detaches a node so `_forceRemove`'s deliberate
1198
+ * parentless guard throws, or any other re-entrant engine mutation — would
1199
+ * otherwise leave the caller's *live* tree half-sanitized, with everything
1200
+ * after the abort point still carrying its handlers. There is no safe way
1201
+ * to resume the walk (the tree mutated under us), so we strip the root bare:
1202
+ * remove every child and every attribute, then let the caller's catch see
1203
+ * the original error. Clobber-safe (cached `remove`/`childNodes`/`attributes`
1204
+ * getters; the root was already clobber-pre-flighted at the IN_PLACE entry).
1205
+ *
1206
+ * @param root the in-place root to empty
1207
+ */
1208
+ const _neutralizeRoot = function (root: Node): void {
1209
+ const childNodes = getChildNodes(root);
1210
+ if (childNodes) {
1211
+ const snapshot: Node[] = [];
1212
+ arrayForEach(childNodes, (child) => {
1213
+ arrayPush(snapshot, child);
1214
+ });
1215
+ arrayForEach(snapshot, (child) => {
1216
+ try {
1217
+ remove(child);
1218
+ } catch (_) {
1219
+ /* Best-effort teardown; a still-attached child is handled below */
1220
+ }
1221
+ });
1222
+ }
1223
+
1224
+ const attributes = getAttributes(root);
1225
+ if (attributes) {
1226
+ for (let i = attributes.length - 1; i >= 0; --i) {
1227
+ const attribute = attributes[i];
1228
+ const name = attribute && attribute.name;
1229
+ if (typeof name === 'string') {
1230
+ try {
1231
+ (root as Element).removeAttribute(name);
1232
+ } catch (_) {
1233
+ /* Clobbered removeAttribute — ignore (fail-closed best effort) */
1234
+ }
1235
+ }
1236
+ }
1003
1237
  }
1004
1238
  };
1005
1239
 
@@ -1038,6 +1272,79 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1038
1272
  }
1039
1273
  };
1040
1274
 
1275
+ /**
1276
+ * _stripDisallowedAttributes
1277
+ *
1278
+ * Removes every attribute the active configuration does not allow from a
1279
+ * single element, using the same allowlist as the main attribute pass (so
1280
+ * `on*` handlers go, but no `/^on/` blocklist is introduced). Used only to
1281
+ * neutralise nodes that are being discarded from an in-place tree.
1282
+ *
1283
+ * @param element the element to strip
1284
+ */
1285
+ const _stripDisallowedAttributes = function (element: Element): void {
1286
+ const attributes = getAttributes(element);
1287
+ if (!attributes) {
1288
+ return;
1289
+ }
1290
+
1291
+ for (let i = attributes.length - 1; i >= 0; --i) {
1292
+ const attribute = attributes[i];
1293
+ const name = attribute && attribute.name;
1294
+ if (typeof name !== 'string' || ALLOWED_ATTR[transformCaseFunc(name)]) {
1295
+ continue;
1296
+ }
1297
+
1298
+ try {
1299
+ element.removeAttribute(name);
1300
+ } catch (_) {
1301
+ /* Clobbered removeAttribute on a doomed node — ignore */
1302
+ }
1303
+ }
1304
+ };
1305
+
1306
+ /**
1307
+ * _neutralizeSubtree
1308
+ *
1309
+ * Completes the audit-5 F1 fix across every removal path. The KEEP_CONTENT
1310
+ * move-hoist neutralises only disallowed-tag removals; clobber, mXSS-canary,
1311
+ * namespace, comment, processing-instruction and KEEP_CONTENT:false removals
1312
+ * all drop their subtree wholesale via `_forceRemove`. On the IN_PLACE path
1313
+ * those dropped nodes are detached from the caller's LIVE tree but a
1314
+ * handler-bearing original among them (an `<img onerror>`/`<video>` that was
1315
+ * loading) keeps its queued resource event, which fires in page scope after
1316
+ * sanitize returns. This walks a removed subtree and strips every attribute
1317
+ * the active configuration does not allow — so `on*` handlers are cancelled
1318
+ * through the SAME allowlist that governs kept nodes, not a separate `/^on/`
1319
+ * blocklist. Run synchronously before sanitize returns, i.e. before any
1320
+ * queued event can fire. Hook-free by design: these nodes leave the output,
1321
+ * so firing attribute hooks for them would be surprising. Clobber-safe reads;
1322
+ * a doomed clobbered node may shadow `removeAttribute` (its own attributes are
1323
+ * irrelevant — it is discarded — while its non-clobbered descendants, e.g.
1324
+ * the `<img>`, are reached and scrubbed).
1325
+ *
1326
+ * @param root the root of a removed subtree to neutralise
1327
+ */
1328
+ const _neutralizeSubtree = function (root: Node): void {
1329
+ const stack: Node[] = [root];
1330
+
1331
+ while (stack.length > 0) {
1332
+ const node = stack.pop();
1333
+ const nodeType = getNodeType ? getNodeType(node) : (node as any).nodeType;
1334
+
1335
+ if (nodeType === NODE_TYPE.element) {
1336
+ _stripDisallowedAttributes(node as Element);
1337
+ }
1338
+
1339
+ const childNodes = getChildNodes(node);
1340
+ if (childNodes) {
1341
+ for (let i = childNodes.length - 1; i >= 0; --i) {
1342
+ stack.push(childNodes[i]);
1343
+ }
1344
+ }
1345
+ }
1346
+ };
1347
+
1041
1348
  /**
1042
1349
  * _initDocument
1043
1350
  *
@@ -1131,6 +1438,21 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1131
1438
  );
1132
1439
  };
1133
1440
 
1441
+ /**
1442
+ * Replace template expression syntax (mustache, ERB, template
1443
+ * literal) with a space; shared by all SAFE_FOR_TEMPLATES scrub
1444
+ * sites. Order matters: mustache, then ERB, then template literal.
1445
+ *
1446
+ * @param value the string to scrub
1447
+ * @returns the scrubbed string
1448
+ */
1449
+ const _stripTemplateExpressions = function (value: string): string {
1450
+ value = stringReplace(value, MUSTACHE_EXPR, ' ');
1451
+ value = stringReplace(value, ERB_EXPR, ' ');
1452
+ value = stringReplace(value, TMPLIT_EXPR, ' ');
1453
+ return value;
1454
+ };
1455
+
1134
1456
  /**
1135
1457
  * Strip template-engine expressions ({{...}}, ${...}, <%...%>) from the
1136
1458
  * character data of an element subtree. Used as the final safety net for
@@ -1165,23 +1487,21 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1165
1487
 
1166
1488
  let currentNode = walker.nextNode() as CharacterData | null;
1167
1489
  while (currentNode) {
1168
- let data = currentNode.data;
1169
- arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], (expr: RegExp) => {
1170
- data = stringReplace(data, expr, ' ');
1171
- });
1172
- currentNode.data = data;
1490
+ currentNode.data = _stripTemplateExpressions(currentNode.data);
1173
1491
  currentNode = walker.nextNode() as CharacterData | null;
1174
1492
  }
1175
1493
 
1176
1494
  // NodeIterator does not descend into <template>.content per the DOM spec,
1177
1495
  // so we must explicitly recurse into each template's content fragment,
1178
1496
  // mirroring the approach used by _sanitizeShadowDOM.
1179
- const templates = node.querySelectorAll?.('template') ?? [];
1180
- arrayForEach(Array.from(templates), (tmpl: HTMLTemplateElement) => {
1181
- if (_isDocumentFragment(tmpl.content)) {
1182
- _scrubTemplateExpressions(tmpl.content as unknown as Element);
1183
- }
1184
- });
1497
+ const templates = node.querySelectorAll?.('template');
1498
+ if (templates) {
1499
+ arrayForEach(templates, (tmpl: HTMLTemplateElement) => {
1500
+ if (_isDocumentFragment(tmpl.content)) {
1501
+ _scrubTemplateExpressions(tmpl.content as unknown as Element);
1502
+ }
1503
+ });
1504
+ }
1185
1505
  };
1186
1506
 
1187
1507
  /**
@@ -1298,52 +1618,34 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1298
1618
  currentNode: Parameters<T>[0],
1299
1619
  data: Parameters<T>[1]
1300
1620
  ): void {
1621
+ if (hooks.length === 0) {
1622
+ return;
1623
+ }
1624
+
1301
1625
  arrayForEach(hooks, (hook: T) => {
1302
1626
  hook.call(DOMPurify, currentNode, data, CONFIG);
1303
1627
  });
1304
1628
  }
1305
1629
 
1306
1630
  /**
1307
- * _sanitizeElements
1631
+ * Structural-threat checks that condemn a node regardless of the
1632
+ * allowlists: mXSS via namespace confusion, risky CSS construction,
1633
+ * processing instructions, markup-bearing comments. Pure predicate;
1634
+ * the caller removes. Check order is load-bearing.
1308
1635
  *
1309
- * @protect nodeName
1310
- * @protect textContent
1311
- * @protect removeChild
1312
- * @param currentNode to check for permission to exist
1313
- * @return true if node was killed, false if left alive
1636
+ * @param currentNode the node to inspect
1637
+ * @param tagName the node's transformCaseFunc'd tag name
1638
+ * @return true if the node must be removed
1314
1639
  */
1315
- const _sanitizeElements = function (currentNode: any): boolean {
1316
- let content = null;
1317
-
1318
- /* Execute a hook if present */
1319
- _executeHooks(hooks.beforeSanitizeElements, currentNode, null);
1320
-
1321
- /* Check if element is clobbered or can clobber */
1322
- if (_isClobbered(currentNode)) {
1323
- _forceRemove(currentNode);
1324
- return true;
1325
- }
1326
-
1327
- /* Now let's check the element's type and name */
1328
- const tagName = transformCaseFunc(
1329
- getNodeName ? getNodeName(currentNode) : currentNode.nodeName
1330
- );
1331
-
1332
- /* Execute a hook if present */
1333
- _executeHooks(hooks.uponSanitizeElement, currentNode, {
1334
- tagName,
1335
- allowedTags: ALLOWED_TAGS,
1336
- });
1337
-
1640
+ const _isUnsafeNode = function (currentNode: any, tagName: string): boolean {
1338
1641
  /* Detect mXSS attempts abusing namespace confusion */
1339
1642
  if (
1340
1643
  SAFE_FOR_XML &&
1341
1644
  currentNode.hasChildNodes() &&
1342
1645
  !_isNode(currentNode.firstElementChild) &&
1343
- regExpTest(/<[/\w!]/g, currentNode.innerHTML) &&
1344
- regExpTest(/<[/\w!]/g, currentNode.textContent)
1646
+ regExpTest(EXPRESSIONS.ELEMENT_MARKUP_PROBE, currentNode.textContent) &&
1647
+ regExpTest(EXPRESSIONS.ELEMENT_MARKUP_PROBE, currentNode.innerHTML)
1345
1648
  ) {
1346
- _forceRemove(currentNode);
1347
1649
  return true;
1348
1650
  }
1349
1651
 
@@ -1354,13 +1656,11 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1354
1656
  tagName === 'style' &&
1355
1657
  _isNode(currentNode.firstElementChild)
1356
1658
  ) {
1357
- _forceRemove(currentNode);
1358
1659
  return true;
1359
1660
  }
1360
1661
 
1361
1662
  /* Remove any occurrence of processing instructions */
1362
- if (currentNode.nodeType === NODE_TYPE.progressingInstruction) {
1363
- _forceRemove(currentNode);
1663
+ if (currentNode.nodeType === NODE_TYPE.processingInstruction) {
1364
1664
  return true;
1365
1665
  }
1366
1666
 
@@ -1368,39 +1668,47 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1368
1668
  if (
1369
1669
  SAFE_FOR_XML &&
1370
1670
  currentNode.nodeType === NODE_TYPE.comment &&
1371
- regExpTest(/<[/\w]/g, currentNode.data)
1671
+ regExpTest(EXPRESSIONS.COMMENT_MARKUP_PROBE, currentNode.data)
1372
1672
  ) {
1373
- _forceRemove(currentNode);
1374
1673
  return true;
1375
1674
  }
1376
1675
 
1377
- /* Remove element if anything forbids its presence */
1378
- if (
1379
- FORBID_TAGS[tagName] ||
1380
- (!(
1381
- EXTRA_ELEMENT_HANDLING.tagCheck instanceof Function &&
1382
- EXTRA_ELEMENT_HANDLING.tagCheck(tagName)
1383
- ) &&
1384
- !ALLOWED_TAGS[tagName])
1385
- ) {
1386
- /* Check if we have a custom element to handle */
1387
- if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName)) {
1388
- if (
1389
- CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp &&
1390
- regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)
1391
- ) {
1392
- return false;
1393
- }
1676
+ return false;
1677
+ };
1394
1678
 
1395
- if (
1396
- CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function &&
1397
- CUSTOM_ELEMENT_HANDLING.tagNameCheck(tagName)
1398
- ) {
1399
- return false;
1400
- }
1679
+ /**
1680
+ * Handle a node whose tag is forbidden or not allowlisted: keep
1681
+ * allowed custom elements (false return exits _sanitizeElements
1682
+ * early - namespace/fallback checks and the afterSanitizeElements
1683
+ * hook are intentionally skipped for kept custom elements), else
1684
+ * hoist content per KEEP_CONTENT and remove.
1685
+ *
1686
+ * @param currentNode the disallowed node
1687
+ * @param tagName the node's transformCaseFunc'd tag name
1688
+ * @return true if the node was removed, false if kept
1689
+ */
1690
+ const _sanitizeDisallowedNode = function (
1691
+ currentNode: any,
1692
+ tagName: string
1693
+ ): boolean {
1694
+ /* Check if we have a custom element to handle */
1695
+ if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName)) {
1696
+ if (
1697
+ CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp &&
1698
+ regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)
1699
+ ) {
1700
+ return false;
1701
+ }
1702
+
1703
+ if (
1704
+ CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function &&
1705
+ CUSTOM_ELEMENT_HANDLING.tagNameCheck(tagName)
1706
+ ) {
1707
+ return false;
1401
1708
  }
1709
+ }
1402
1710
 
1403
- /* Keep content except for bad-listed elements.
1711
+ /* Keep content except for bad-listed elements.
1404
1712
  Use the cached prototype getters exclusively — the previous code
1405
1713
  had `|| currentNode.parentNode` / `|| currentNode.childNodes`
1406
1714
  fallbacks, but the cached getters always return the canonical
@@ -1408,24 +1716,95 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1408
1716
  path was dead in safe cases and a clobbering surface in unsafe
1409
1717
  ones. Falsy cached results stay falsy; the `if (childNodes &&
1410
1718
  parentNode)` check already gates correctly. */
1411
- if (KEEP_CONTENT && !FORBID_CONTENTS[tagName]) {
1412
- const parentNode = getParentNode(currentNode);
1413
- const childNodes = getChildNodes(currentNode);
1414
-
1415
- if (childNodes && parentNode) {
1416
- const childCount = childNodes.length;
1417
-
1418
- for (let i = childCount - 1; i >= 0; --i) {
1419
- const childClone = cloneNode(childNodes[i], true);
1420
- parentNode.insertBefore(childClone, getNextSibling(currentNode));
1421
- }
1719
+ if (KEEP_CONTENT && !FORBID_CONTENTS[tagName]) {
1720
+ const parentNode = getParentNode(currentNode);
1721
+ const childNodes = getChildNodes(currentNode);
1722
+
1723
+ if (childNodes && parentNode) {
1724
+ const childCount = childNodes.length;
1725
+
1726
+ /* In-place: hoist the *original* children so the iterator visits
1727
+ and sanitises them through the same allowlist pass as every other
1728
+ node. The caller built the tree in the live document, so the
1729
+ originals carry already-queued resource events (`<img onerror>`,
1730
+ `<video>`/`<audio>` error, lazy/`onload`, …); cloning would leave
1731
+ those originals detached but still armed, firing in page scope
1732
+ while the returned tree looked clean. Moving is safe in-place: the
1733
+ root is pre-validated as an allowed tag and so is never the node
1734
+ being removed, which keeps `parentNode` inside the iterator root
1735
+ and the relocated child inside the serialised tree.
1736
+
1737
+ Otherwise (string / DOM-copy paths): clone. The iterator is rooted
1738
+ at — and the result serialised from — `body`, so a restrictive
1739
+ ALLOWED_TAGS that removes `body` itself must leave its content in
1740
+ place, which only cloning does; and those paths parse into an
1741
+ inert document, so their discarded originals never had a queued
1742
+ event to neutralise.
1743
+
1744
+ `childNodes` is live; a tail-to-head walk keeps `childNodes[i]`
1745
+ valid whether we move (drops the trailing entry) or clone (leaves
1746
+ the list intact). */
1747
+ for (let i = childCount - 1; i >= 0; --i) {
1748
+ const hoisted = IN_PLACE
1749
+ ? childNodes[i]
1750
+ : cloneNode(childNodes[i], true);
1751
+ parentNode.insertBefore(hoisted, getNextSibling(currentNode));
1422
1752
  }
1423
1753
  }
1754
+ }
1755
+
1756
+ _forceRemove(currentNode);
1757
+ return true;
1758
+ };
1424
1759
 
1760
+ /**
1761
+ * _sanitizeElements
1762
+ *
1763
+ * @protect nodeName
1764
+ * @protect textContent
1765
+ * @protect removeChild
1766
+ * @param currentNode to check for permission to exist
1767
+ * @return true if node was killed, false if left alive
1768
+ */
1769
+ const _sanitizeElements = function (currentNode: any): boolean {
1770
+ /* Execute a hook if present */
1771
+ _executeHooks(hooks.beforeSanitizeElements, currentNode, null);
1772
+
1773
+ /* Check if element is clobbered or can clobber */
1774
+ if (_isClobbered(currentNode)) {
1775
+ _forceRemove(currentNode);
1776
+ return true;
1777
+ }
1778
+
1779
+ /* Now let's check the element's type and name */
1780
+ const tagName = transformCaseFunc(
1781
+ getNodeName ? getNodeName(currentNode) : currentNode.nodeName
1782
+ );
1783
+
1784
+ /* Execute a hook if present */
1785
+ _executeHooks(hooks.uponSanitizeElement, currentNode, {
1786
+ tagName,
1787
+ allowedTags: ALLOWED_TAGS,
1788
+ });
1789
+
1790
+ /* Remove mXSS vectors, processing instructions and risky comments */
1791
+ if (_isUnsafeNode(currentNode, tagName)) {
1425
1792
  _forceRemove(currentNode);
1426
1793
  return true;
1427
1794
  }
1428
1795
 
1796
+ /* Remove element if anything forbids its presence */
1797
+ if (
1798
+ FORBID_TAGS[tagName] ||
1799
+ (!(
1800
+ EXTRA_ELEMENT_HANDLING.tagCheck instanceof Function &&
1801
+ EXTRA_ELEMENT_HANDLING.tagCheck(tagName)
1802
+ ) &&
1803
+ !ALLOWED_TAGS[tagName])
1804
+ ) {
1805
+ return _sanitizeDisallowedNode(currentNode, tagName);
1806
+ }
1807
+
1429
1808
  /* Check whether element has a valid namespace.
1430
1809
  Realm-safe check (GHSA-hpcv-96wg-7vj8): use the cached Node.prototype
1431
1810
  nodeType getter rather than `instanceof Element`, which is realm-
@@ -1443,7 +1822,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1443
1822
  (tagName === 'noscript' ||
1444
1823
  tagName === 'noembed' ||
1445
1824
  tagName === 'noframes') &&
1446
- regExpTest(/<\/no(script|embed|frames)/i, currentNode.innerHTML)
1825
+ regExpTest(EXPRESSIONS.FALLBACK_TAG_CLOSE, currentNode.innerHTML)
1447
1826
  ) {
1448
1827
  _forceRemove(currentNode);
1449
1828
  return true;
@@ -1452,11 +1831,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1452
1831
  /* Sanitize element content to be template-safe */
1453
1832
  if (SAFE_FOR_TEMPLATES && currentNode.nodeType === NODE_TYPE.text) {
1454
1833
  /* Get the element's text content */
1455
- content = currentNode.textContent;
1456
-
1457
- arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], (expr: RegExp) => {
1458
- content = stringReplace(content, expr, ' ');
1459
- });
1834
+ const content = _stripTemplateExpressions(currentNode.textContent);
1460
1835
 
1461
1836
  if (currentNode.textContent !== content) {
1462
1837
  arrayPush(DOMPurify.removed, { element: currentNode.cloneNode() });
@@ -1507,16 +1882,12 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1507
1882
  (https://html.spec.whatwg.org/multipage/dom.html#embedding-custom-non-visible-data-with-the-data-*-attributes)
1508
1883
  XML-compatible (https://html.spec.whatwg.org/multipage/infrastructure.html#xml-compatible and http://www.w3.org/TR/xml/#d0e804)
1509
1884
  We don't need to check the value; it's always URI safe. */
1510
- if (
1511
- ALLOW_DATA_ATTR &&
1512
- !FORBID_ATTR[lcName] &&
1513
- regExpTest(DATA_ATTR, lcName)
1514
- ) {
1885
+ if (ALLOW_DATA_ATTR && regExpTest(DATA_ATTR, lcName)) {
1515
1886
  // This attribute is safe
1516
1887
  } else if (ALLOW_ARIA_ATTR && regExpTest(ARIA_ATTR, lcName)) {
1517
1888
  // This attribute is safe
1518
1889
  /* Otherwise, check the name is permitted */
1519
- } else if (!nameIsPermitted || FORBID_ATTR[lcName]) {
1890
+ } else if (!nameIsPermitted) {
1520
1891
  if (
1521
1892
  // First condition does a very basic check if a) it's basically a valid custom element tagname AND
1522
1893
  // b) if the tagName passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck
@@ -1610,6 +1981,85 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1610
1981
  );
1611
1982
  };
1612
1983
 
1984
+ /**
1985
+ * Wrap an attribute value in the matching Trusted Types object when
1986
+ * the active policy requires it. Namespaced attributes pass through
1987
+ * unchanged (no TT support yet, see
1988
+ * https://bugs.chromium.org/p/chromium/issues/detail?id=1305293).
1989
+ *
1990
+ * @param lcTag lowercase tag name of the containing element
1991
+ * @param lcName lowercase attribute name
1992
+ * @param namespaceURI the attribute's namespace, if any
1993
+ * @param value the attribute value to wrap
1994
+ * @return the value, wrapped when Trusted Types demand it
1995
+ */
1996
+ const _applyTrustedTypesToAttribute = function (
1997
+ lcTag: string,
1998
+ lcName: string,
1999
+ namespaceURI: string | null,
2000
+ value: string
2001
+ ): string {
2002
+ if (
2003
+ trustedTypesPolicy &&
2004
+ typeof trustedTypes === 'object' &&
2005
+ typeof trustedTypes.getAttributeType === 'function' &&
2006
+ !namespaceURI
2007
+ ) {
2008
+ switch (trustedTypes.getAttributeType(lcTag, lcName)) {
2009
+ case 'TrustedHTML': {
2010
+ return _createTrustedHTML(value);
2011
+ }
2012
+
2013
+ case 'TrustedScriptURL': {
2014
+ return _createTrustedScriptURL(value);
2015
+ }
2016
+
2017
+ default: {
2018
+ break;
2019
+ }
2020
+ }
2021
+ }
2022
+
2023
+ return value;
2024
+ };
2025
+
2026
+ /**
2027
+ * Write a modified attribute value back onto the element. On
2028
+ * success, re-probe for clobbering introduced by the new value and
2029
+ * remove the element when found; otherwise pop the removal entry
2030
+ * recorded by the earlier _removeAttribute (long-standing pairing
2031
+ * with the SANITIZE_NAMED_PROPS path - do not "fix" casually). On
2032
+ * failure, remove the attribute instead.
2033
+ *
2034
+ * @param currentNode the element carrying the attribute
2035
+ * @param name the attribute name as present on the element
2036
+ * @param namespaceURI the attribute's namespace, if any
2037
+ * @param value the new attribute value
2038
+ */
2039
+ const _setAttributeValue = function (
2040
+ currentNode: Element,
2041
+ name: string,
2042
+ namespaceURI: string | null,
2043
+ value: string
2044
+ ): void {
2045
+ try {
2046
+ if (namespaceURI) {
2047
+ currentNode.setAttributeNS(namespaceURI, name, value);
2048
+ } else {
2049
+ /* Fallback to setAttribute() for browser-unrecognized namespaces e.g. "x-schema". */
2050
+ currentNode.setAttribute(name, value);
2051
+ }
2052
+
2053
+ if (_isClobbered(currentNode)) {
2054
+ _forceRemove(currentNode);
2055
+ } else {
2056
+ arrayPop(DOMPurify.removed);
2057
+ }
2058
+ } catch (_) {
2059
+ _removeAttribute(name, currentNode);
2060
+ }
2061
+ };
2062
+
1613
2063
  /**
1614
2064
  * _sanitizeAttributes
1615
2065
  *
@@ -1639,6 +2089,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1639
2089
  forceKeepAttr: undefined,
1640
2090
  };
1641
2091
  let l = attributes.length;
2092
+ const lcTag = transformCaseFunc(currentNode.nodeName);
1642
2093
 
1643
2094
  /* Go backwards over all attributes; safely remove bad ones */
1644
2095
  while (l--) {
@@ -1691,7 +2142,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1691
2142
  continue;
1692
2143
  }
1693
2144
 
1694
- /* Did the hooks approve of the attribute? */
2145
+ /* Did the hooks force-keep the attribute? */
1695
2146
  if (hookEvent.forceKeepAttr) {
1696
2147
  continue;
1697
2148
  }
@@ -1703,70 +2154,31 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1703
2154
  }
1704
2155
 
1705
2156
  /* Work around a security issue in jQuery 3.0 */
1706
- if (!ALLOW_SELF_CLOSE_IN_ATTR && regExpTest(/\/>/i, value)) {
2157
+ if (
2158
+ !ALLOW_SELF_CLOSE_IN_ATTR &&
2159
+ regExpTest(EXPRESSIONS.SELF_CLOSING_TAG, value)
2160
+ ) {
1707
2161
  _removeAttribute(name, currentNode);
1708
2162
  continue;
1709
2163
  }
1710
2164
 
1711
2165
  /* Sanitize attribute content to be template-safe */
1712
2166
  if (SAFE_FOR_TEMPLATES) {
1713
- arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], (expr: RegExp) => {
1714
- value = stringReplace(value, expr, ' ');
1715
- });
2167
+ value = _stripTemplateExpressions(value);
1716
2168
  }
1717
2169
 
1718
2170
  /* Is `value` valid for this attribute? */
1719
- const lcTag = transformCaseFunc(currentNode.nodeName);
1720
2171
  if (!_isValidAttribute(lcTag, lcName, value)) {
1721
2172
  _removeAttribute(name, currentNode);
1722
2173
  continue;
1723
2174
  }
1724
2175
 
1725
2176
  /* Handle attributes that require Trusted Types */
1726
- if (
1727
- trustedTypesPolicy &&
1728
- typeof trustedTypes === 'object' &&
1729
- typeof trustedTypes.getAttributeType === 'function'
1730
- ) {
1731
- if (namespaceURI) {
1732
- /* Namespaces are not yet supported, see https://bugs.chromium.org/p/chromium/issues/detail?id=1305293 */
1733
- } else {
1734
- switch (trustedTypes.getAttributeType(lcTag, lcName)) {
1735
- case 'TrustedHTML': {
1736
- value = _createTrustedHTML(value);
1737
- break;
1738
- }
1739
-
1740
- case 'TrustedScriptURL': {
1741
- value = trustedTypesPolicy.createScriptURL(value);
1742
- break;
1743
- }
1744
-
1745
- default: {
1746
- break;
1747
- }
1748
- }
1749
- }
1750
- }
2177
+ value = _applyTrustedTypesToAttribute(lcTag, lcName, namespaceURI, value);
1751
2178
 
1752
2179
  /* Handle invalid data-* attribute set by try-catching it */
1753
2180
  if (value !== initValue) {
1754
- try {
1755
- if (namespaceURI) {
1756
- currentNode.setAttributeNS(namespaceURI, name, value);
1757
- } else {
1758
- /* Fallback to setAttribute() for browser-unrecognized namespaces e.g. "x-schema". */
1759
- currentNode.setAttribute(name, value);
1760
- }
1761
-
1762
- if (_isClobbered(currentNode)) {
1763
- _forceRemove(currentNode);
1764
- } else {
1765
- arrayPop(DOMPurify.removed);
1766
- }
1767
- } catch (_) {
1768
- _removeAttribute(name, currentNode);
1769
- }
2181
+ _setAttributeValue(currentNode, name, namespaceURI, value);
1770
2182
  }
1771
2183
  }
1772
2184
 
@@ -1818,9 +2230,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1818
2230
  ? getNodeType(shadowNode)
1819
2231
  : shadowNode.nodeType;
1820
2232
  if (shadowNodeType === NODE_TYPE.element) {
1821
- const innerSr = getShadowRoot
1822
- ? getShadowRoot(shadowNode)
1823
- : (shadowNode as Element).shadowRoot;
2233
+ const innerSr = getShadowRoot(shadowNode);
1824
2234
  if (_isDocumentFragment(innerSr)) {
1825
2235
  _sanitizeAttachedShadowRoots(innerSr);
1826
2236
  _sanitizeShadowDOM(innerSr);
@@ -1852,57 +2262,76 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1852
2262
  * @param root the subtree root to walk for attached shadow roots
1853
2263
  */
1854
2264
  const _sanitizeAttachedShadowRoots = function (root: Node): void {
1855
- const nodeType = getNodeType ? getNodeType(root) : (root as any).nodeType;
1856
-
1857
- if (nodeType === NODE_TYPE.element) {
1858
- const sr = getShadowRoot
1859
- ? getShadowRoot(root)
1860
- : (root as Element).shadowRoot;
1861
- // Realm-safe check (GHSA-hpcv-96wg-7vj8): use nodeType-based
1862
- // detection rather than `instanceof DocumentFragment`, which is
1863
- // realm-bound and silently skipped shadow roots whose host element
1864
- // belonged to a foreign realm (e.g. iframe.contentDocument
1865
- // attachShadow). A foreign-realm ShadowRoot extends the foreign
1866
- // realm's DocumentFragment, not ours, so the old instanceof check
1867
- // returned false and the shadow subtree was never walked.
1868
- if (_isDocumentFragment(sr)) {
1869
- // Recurse first so that nested shadow roots are reached even if
1870
- // _sanitizeShadowDOM removes hosts at this level.
1871
- _sanitizeAttachedShadowRoots(sr);
1872
- _sanitizeShadowDOM(sr);
2265
+ /* Iterative (explicit stack) rather than per-child recursion. DOM APIs
2266
+ impose no depth cap, so an attacker-shaped tree (JSON/CRDT/editor data
2267
+ built straight into the DOM — the IN_PLACE surface) deeper than the JS
2268
+ call-stack budget would otherwise overflow native recursion here and
2269
+ throw at the IN_PLACE entry pre-pass, before a single node is
2270
+ sanitized, leaving the caller's live tree untouched (fail-open). See
2271
+ campaign-3 F4. A heap stack keeps depth off the call stack.
2272
+
2273
+ Each work item is either a node to descend into, or a deferred
2274
+ `_sanitizeShadowDOM` for an already-walked shadow root. The deferred
2275
+ form preserves the original post-order discipline: a shadow root's
2276
+ nested shadow roots are discovered before the outer shadow is
2277
+ sanitized (which may remove hosts). Pushes are in reverse of the
2278
+ desired processing order (LIFO): template content, then children, then
2279
+ the shadow-sanitize, then the shadow walk so the order matches the
2280
+ previous recursion exactly. */
2281
+ const stack: Array<{ node: Node | null; shadow: DocumentFragment | null }> =
2282
+ [{ node: root, shadow: null }];
2283
+
2284
+ while (stack.length > 0) {
2285
+ const item = stack.pop();
2286
+
2287
+ /* Deferred shadow-DOM sanitisation: runs after its subtree was walked. */
2288
+ if (item.shadow) {
2289
+ _sanitizeShadowDOM(item.shadow);
2290
+ continue;
1873
2291
  }
1874
- }
1875
-
1876
- // Snapshot children before recursing. Sanitization of one subtree
1877
- // (e.g. via an uponSanitizeShadowNode hook) may detach siblings,
1878
- // and naive nextSibling traversal would silently skip the rest of
1879
- // the list once a node is detached.
1880
- const childNodes = getChildNodes
1881
- ? getChildNodes(root)
1882
- : (root as Element).childNodes;
1883
- if (!childNodes) {
1884
- return;
1885
- }
1886
2292
 
1887
- const snapshot: Node[] = [];
1888
- arrayForEach(childNodes, (child) => {
1889
- arrayPush(snapshot, child);
1890
- });
2293
+ const node = item.node;
2294
+ const nodeType = getNodeType ? getNodeType(node) : (node as any).nodeType;
2295
+ const isElement = nodeType === NODE_TYPE.element;
2296
+
2297
+ /* (pushed last → processed first) Children, snapshotted in reverse so
2298
+ the first child is processed first. Snapshotting matters because a
2299
+ hook may detach siblings mid-walk. */
2300
+ const childNodes = getChildNodes(node);
2301
+ if (childNodes) {
2302
+ for (let i = childNodes.length - 1; i >= 0; --i) {
2303
+ stack.push({ node: childNodes[i], shadow: null });
2304
+ }
2305
+ }
1891
2306
 
1892
- for (const child of snapshot) {
1893
- _sanitizeAttachedShadowRoots(child);
1894
- }
2307
+ /* (pushed before children processed after them, matching the old
2308
+ "template content last" order) When the node is a <template>,
2309
+ descend into its content. */
2310
+ if (isElement) {
2311
+ const rootName = getNodeName ? getNodeName(node) : null;
2312
+ if (
2313
+ typeof rootName === 'string' &&
2314
+ transformCaseFunc(rootName) === 'template'
2315
+ ) {
2316
+ const content = (node as HTMLTemplateElement).content;
2317
+ if (_isDocumentFragment(content)) {
2318
+ stack.push({ node: content, shadow: null });
2319
+ }
2320
+ }
2321
+ }
1895
2322
 
1896
- /* When the root is a <template>, also descend into root.content */
1897
- if (nodeType === NODE_TYPE.element) {
1898
- const rootName = getNodeName ? getNodeName(root) : null;
1899
- if (
1900
- typeof rootName === 'string' &&
1901
- transformCaseFunc(rootName) === 'template'
1902
- ) {
1903
- const content = (root as HTMLTemplateElement).content;
1904
- if (_isDocumentFragment(content)) {
1905
- _sanitizeAttachedShadowRoots(content);
2323
+ /* Shadow root (processed first): walk its subtree, then sanitise it.
2324
+ Realm-safe check (GHSA-hpcv-96wg-7vj8): nodeType-based detection
2325
+ rather than `instanceof DocumentFragment`, which is realm-bound and
2326
+ silently skipped foreign-realm shadow roots (e.g.
2327
+ iframe.contentDocument attachShadow). */
2328
+ if (isElement) {
2329
+ const sr = getShadowRoot(node);
2330
+ if (_isDocumentFragment(sr)) {
2331
+ /* Push the deferred sanitise first so it pops after the shadow
2332
+ walk we push next, i.e. nested shadow roots are discovered
2333
+ before this one is sanitised. */
2334
+ stack.push({ node: null, shadow: sr }, { node: sr, shadow: null });
1906
2335
  }
1907
2336
  }
1908
2337
  }
@@ -1944,12 +2373,15 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1944
2373
  /* Clean up removed elements */
1945
2374
  DOMPurify.removed = [];
1946
2375
 
1947
- /* Check if dirty is correctly typed for IN_PLACE */
1948
- if (typeof dirty === 'string') {
1949
- IN_PLACE = false;
1950
- }
2376
+ /* Resolve IN_PLACE for this call without mutating persistent config.
2377
+ Writing the IN_PLACE closure variable here leaks under setConfig(),
2378
+ where _parseConfig is skipped on later calls: a single string call would
2379
+ disable in-place mode for every subsequent node call, returning a
2380
+ sanitized copy while leaving the caller's node — which in-place callers
2381
+ keep using and whose return value they ignore — unsanitized. REPORT-2. */
2382
+ const inPlace = IN_PLACE && typeof dirty !== 'string' && _isNode(dirty);
1951
2383
 
1952
- if (IN_PLACE) {
2384
+ if (inPlace) {
1953
2385
  /* Do some early pre-sanitization to avoid unsafe root nodes.
1954
2386
  Read nodeName through the cached prototype getter — a clobbering
1955
2387
  child named "nodeName" on the form root would otherwise shadow
@@ -1984,8 +2416,17 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
1984
2416
  }
1985
2417
 
1986
2418
  /* Sanitize attached shadow roots before the main iterator runs.
1987
- The iterator does not descend into shadow trees. */
1988
- _sanitizeAttachedShadowRoots(dirty as Node);
2419
+ The iterator does not descend into shadow trees. Same fail-closed
2420
+ barrier as the main walk (campaign-3 F2): a custom-element reaction
2421
+ inside a shadow root could abort this pre-pass before the walk runs,
2422
+ which would otherwise leave the entire live tree unsanitized. */
2423
+ try {
2424
+ _sanitizeAttachedShadowRoots(dirty as Node);
2425
+ } catch (error) {
2426
+ _neutralizeRoot(dirty as Node);
2427
+
2428
+ throw error;
2429
+ }
1989
2430
  } else if (_isNode(dirty)) {
1990
2431
  /* If dirty is a DOM element, append to an empty document to avoid
1991
2432
  elements being stripped by the parser */
@@ -2039,27 +2480,56 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2039
2480
  }
2040
2481
 
2041
2482
  /* Get node iterator */
2042
- const nodeIterator = _createNodeIterator(IN_PLACE ? dirty : body);
2043
-
2044
- /* Now start iterating over the created document */
2045
- while ((currentNode = nodeIterator.nextNode())) {
2046
- /* Sanitize tags and elements */
2047
- _sanitizeElements(currentNode);
2048
-
2049
- /* Check attributes next */
2050
- _sanitizeAttributes(currentNode);
2051
-
2052
- /* Shadow DOM detected, sanitize it.
2053
- Realm-safe check (GHSA-hpcv-96wg-7vj8): nodeType-based detection
2054
- instead of instanceof, so foreign-realm <template>.content is
2055
- walked correctly. */
2056
- if (_isDocumentFragment(currentNode.content)) {
2057
- _sanitizeShadowDOM(currentNode.content);
2483
+ const nodeIterator = _createNodeIterator(inPlace ? dirty : body);
2484
+
2485
+ /* Now start iterating over the created document.
2486
+ The walk runs inside an exception barrier (campaign-3 F2): a re-entrant
2487
+ engine/custom-element mutation can detach a node mid-walk so
2488
+ `_forceRemove`'s parentless guard throws, aborting the loop. Without the
2489
+ barrier the caller's in-place tree would be left half-sanitized with the
2490
+ unvisited tail still armed. On any throw we fail closed — strip the
2491
+ in-place root bare — then rethrow so the existing throw contract is
2492
+ preserved. (String/DOM-copy paths never return the partial body, so the
2493
+ propagating throw is already fail-closed there.) */
2494
+ try {
2495
+ while ((currentNode = nodeIterator.nextNode())) {
2496
+ /* Sanitize tags and elements */
2497
+ _sanitizeElements(currentNode);
2498
+
2499
+ /* Check attributes next */
2500
+ _sanitizeAttributes(currentNode);
2501
+
2502
+ /* Shadow DOM detected, sanitize it.
2503
+ Realm-safe check (GHSA-hpcv-96wg-7vj8): nodeType-based detection
2504
+ instead of instanceof, so foreign-realm <template>.content is
2505
+ walked correctly. */
2506
+ if (_isDocumentFragment(currentNode.content)) {
2507
+ _sanitizeShadowDOM(currentNode.content);
2508
+ }
2509
+ }
2510
+ } catch (error) {
2511
+ if (inPlace) {
2512
+ _neutralizeRoot(dirty as Node);
2058
2513
  }
2514
+
2515
+ throw error;
2059
2516
  }
2060
2517
 
2061
2518
  /* If we sanitized `dirty` in-place, return it. */
2062
- if (IN_PLACE) {
2519
+ if (inPlace) {
2520
+ /* Fail-closed completion of the audit-5 F1 fix: every node removed from
2521
+ the caller's live tree is detached but may still hold a queued
2522
+ resource-event handler that fires in page scope after we return. The
2523
+ move-hoist covers only disallowed-tag KEEP_CONTENT removals; strip the
2524
+ non-allow-listed attributes off every other removed subtree (clobber,
2525
+ mXSS, namespace, comments, KEEP_CONTENT:false, …) so those handlers are
2526
+ cancelled before any event can fire. Runs synchronously, pre-return. */
2527
+ arrayForEach(DOMPurify.removed, (entry) => {
2528
+ if (entry.element) {
2529
+ _neutralizeSubtree(entry.element as Node);
2530
+ }
2531
+ });
2532
+
2063
2533
  if (SAFE_FOR_TEMPLATES) {
2064
2534
  _scrubTemplateExpressions(dirty as Element);
2065
2535
  }
@@ -2115,9 +2585,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2115
2585
 
2116
2586
  /* Sanitize final string template-safe */
2117
2587
  if (SAFE_FOR_TEMPLATES) {
2118
- arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], (expr: RegExp) => {
2119
- serializedHTML = stringReplace(serializedHTML, expr, ' ');
2120
- });
2588
+ serializedHTML = _stripTemplateExpressions(serializedHTML);
2121
2589
  }
2122
2590
 
2123
2591
  return trustedTypesPolicy && RETURN_TRUSTED_TYPE
@@ -2133,6 +2601,13 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2133
2601
  DOMPurify.clearConfig = function () {
2134
2602
  CONFIG = null;
2135
2603
  SET_CONFIG = false;
2604
+
2605
+ // Drop any caller-supplied Trusted Types policy so it cannot poison later
2606
+ // `RETURN_TRUSTED_TYPE` output. The internal default policy (cached, and
2607
+ // never recreated — Trusted Types throws on duplicate names) is restored by
2608
+ // the next `_parseConfig`. See GHSA-vxr8-fq34-vvx9.
2609
+ trustedTypesPolicy = defaultTrustedTypesPolicy;
2610
+ emptyHTML = '';
2136
2611
  };
2137
2612
 
2138
2613
  DOMPurify.isValidAttribute = function (tag, attr, value) {
@@ -2184,352 +2659,3 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
2184
2659
  }
2185
2660
 
2186
2661
  export default createDOMPurify();
2187
-
2188
- export interface DOMPurify {
2189
- /**
2190
- * Creates a DOMPurify instance using the given window-like object. Defaults to `window`.
2191
- */
2192
- (root?: WindowLike): DOMPurify;
2193
-
2194
- /**
2195
- * Version label, exposed for easier checks
2196
- * if DOMPurify is up to date or not
2197
- */
2198
- version: string;
2199
-
2200
- /**
2201
- * Array of elements that DOMPurify removed during sanitation.
2202
- * Empty if nothing was removed.
2203
- */
2204
- removed: Array<RemovedElement | RemovedAttribute>;
2205
-
2206
- /**
2207
- * Expose whether this browser supports running the full DOMPurify.
2208
- */
2209
- isSupported: boolean;
2210
-
2211
- /**
2212
- * Set the configuration once.
2213
- *
2214
- * @param cfg configuration object
2215
- */
2216
- setConfig(cfg?: Config): void;
2217
-
2218
- /**
2219
- * Removes the configuration.
2220
- */
2221
- clearConfig(): void;
2222
-
2223
- /**
2224
- * Provides core sanitation functionality.
2225
- *
2226
- * @param dirty string or DOM node
2227
- * @param cfg object
2228
- * @returns Sanitized TrustedHTML.
2229
- */
2230
- sanitize(
2231
- dirty: string | Node,
2232
- cfg: Config & { RETURN_TRUSTED_TYPE: true }
2233
- ): TrustedHTML;
2234
-
2235
- /**
2236
- * Provides core sanitation functionality.
2237
- *
2238
- * @param dirty DOM node
2239
- * @param cfg object
2240
- * @returns Sanitized DOM node.
2241
- */
2242
- sanitize(dirty: Node, cfg: Config & { IN_PLACE: true }): Node;
2243
-
2244
- /**
2245
- * Provides core sanitation functionality.
2246
- *
2247
- * @param dirty string or DOM node
2248
- * @param cfg object
2249
- * @returns Sanitized DOM node.
2250
- */
2251
- sanitize(dirty: string | Node, cfg: Config & { RETURN_DOM: true }): Node;
2252
-
2253
- /**
2254
- * Provides core sanitation functionality.
2255
- *
2256
- * @param dirty string or DOM node
2257
- * @param cfg object
2258
- * @returns Sanitized document fragment.
2259
- */
2260
- sanitize(
2261
- dirty: string | Node,
2262
- cfg: Config & { RETURN_DOM_FRAGMENT: true }
2263
- ): DocumentFragment;
2264
-
2265
- /**
2266
- * Provides core sanitation functionality.
2267
- *
2268
- * @param dirty string or DOM node
2269
- * @param cfg object
2270
- * @returns Sanitized string.
2271
- */
2272
- sanitize(dirty: string | Node, cfg?: Config): string;
2273
-
2274
- /**
2275
- * Checks if an attribute value is valid.
2276
- * Uses last set config, if any. Otherwise, uses config defaults.
2277
- *
2278
- * @param tag Tag name of containing element.
2279
- * @param attr Attribute name.
2280
- * @param value Attribute value.
2281
- * @returns Returns true if `value` is valid. Otherwise, returns false.
2282
- */
2283
- isValidAttribute(tag: string, attr: string, value: string): boolean;
2284
-
2285
- /**
2286
- * Adds a DOMPurify hook.
2287
- *
2288
- * @param entryPoint entry point for the hook to add
2289
- * @param hookFunction function to execute
2290
- */
2291
- addHook(entryPoint: BasicHookName, hookFunction: NodeHook): void;
2292
-
2293
- /**
2294
- * Adds a DOMPurify hook.
2295
- *
2296
- * @param entryPoint entry point for the hook to add
2297
- * @param hookFunction function to execute
2298
- */
2299
- addHook(entryPoint: ElementHookName, hookFunction: ElementHook): void;
2300
-
2301
- /**
2302
- * Adds a DOMPurify hook.
2303
- *
2304
- * @param entryPoint entry point for the hook to add
2305
- * @param hookFunction function to execute
2306
- */
2307
- addHook(
2308
- entryPoint: DocumentFragmentHookName,
2309
- hookFunction: DocumentFragmentHook
2310
- ): void;
2311
-
2312
- /**
2313
- * Adds a DOMPurify hook.
2314
- *
2315
- * @param entryPoint entry point for the hook to add
2316
- * @param hookFunction function to execute
2317
- */
2318
- addHook(
2319
- entryPoint: 'uponSanitizeElement',
2320
- hookFunction: UponSanitizeElementHook
2321
- ): void;
2322
-
2323
- /**
2324
- * Adds a DOMPurify hook.
2325
- *
2326
- * @param entryPoint entry point for the hook to add
2327
- * @param hookFunction function to execute
2328
- */
2329
- addHook(
2330
- entryPoint: 'uponSanitizeAttribute',
2331
- hookFunction: UponSanitizeAttributeHook
2332
- ): void;
2333
-
2334
- /**
2335
- * Remove a DOMPurify hook at a given entryPoint
2336
- * (pops it from the stack of hooks if hook not specified)
2337
- *
2338
- * @param entryPoint entry point for the hook to remove
2339
- * @param hookFunction optional specific hook to remove
2340
- * @returns removed hook
2341
- */
2342
- removeHook(
2343
- entryPoint: BasicHookName,
2344
- hookFunction?: NodeHook
2345
- ): NodeHook | undefined;
2346
-
2347
- /**
2348
- * Remove a DOMPurify hook at a given entryPoint
2349
- * (pops it from the stack of hooks if hook not specified)
2350
- *
2351
- * @param entryPoint entry point for the hook to remove
2352
- * @param hookFunction optional specific hook to remove
2353
- * @returns removed hook
2354
- */
2355
- removeHook(
2356
- entryPoint: ElementHookName,
2357
- hookFunction?: ElementHook
2358
- ): ElementHook | undefined;
2359
-
2360
- /**
2361
- * Remove a DOMPurify hook at a given entryPoint
2362
- * (pops it from the stack of hooks if hook not specified)
2363
- *
2364
- * @param entryPoint entry point for the hook to remove
2365
- * @param hookFunction optional specific hook to remove
2366
- * @returns removed hook
2367
- */
2368
- removeHook(
2369
- entryPoint: DocumentFragmentHookName,
2370
- hookFunction?: DocumentFragmentHook
2371
- ): DocumentFragmentHook | undefined;
2372
-
2373
- /**
2374
- * Remove a DOMPurify hook at a given entryPoint
2375
- * (pops it from the stack of hooks if hook not specified)
2376
- *
2377
- * @param entryPoint entry point for the hook to remove
2378
- * @param hookFunction optional specific hook to remove
2379
- * @returns removed hook
2380
- */
2381
- removeHook(
2382
- entryPoint: 'uponSanitizeElement',
2383
- hookFunction?: UponSanitizeElementHook
2384
- ): UponSanitizeElementHook | undefined;
2385
-
2386
- /**
2387
- * Remove a DOMPurify hook at a given entryPoint
2388
- * (pops it from the stack of hooks if hook not specified)
2389
- *
2390
- * @param entryPoint entry point for the hook to remove
2391
- * @param hookFunction optional specific hook to remove
2392
- * @returns removed hook
2393
- */
2394
- removeHook(
2395
- entryPoint: 'uponSanitizeAttribute',
2396
- hookFunction?: UponSanitizeAttributeHook
2397
- ): UponSanitizeAttributeHook | undefined;
2398
-
2399
- /**
2400
- * Removes all DOMPurify hooks at a given entryPoint
2401
- *
2402
- * @param entryPoint entry point for the hooks to remove
2403
- */
2404
- removeHooks(entryPoint: HookName): void;
2405
-
2406
- /**
2407
- * Removes all DOMPurify hooks.
2408
- */
2409
- removeAllHooks(): void;
2410
- }
2411
-
2412
- /**
2413
- * An element removed by DOMPurify.
2414
- */
2415
- export interface RemovedElement {
2416
- /**
2417
- * The element that was removed.
2418
- */
2419
- element: Node;
2420
- }
2421
-
2422
- /**
2423
- * An element removed by DOMPurify.
2424
- */
2425
- export interface RemovedAttribute {
2426
- /**
2427
- * The attribute that was removed.
2428
- */
2429
- attribute: Attr | null;
2430
-
2431
- /**
2432
- * The element that the attribute was removed.
2433
- */
2434
- from: Node;
2435
- }
2436
-
2437
- type BasicHookName =
2438
- | 'beforeSanitizeElements'
2439
- | 'afterSanitizeElements'
2440
- | 'uponSanitizeShadowNode';
2441
- type ElementHookName = 'beforeSanitizeAttributes' | 'afterSanitizeAttributes';
2442
- type DocumentFragmentHookName =
2443
- | 'beforeSanitizeShadowDOM'
2444
- | 'afterSanitizeShadowDOM';
2445
- type UponSanitizeElementHookName = 'uponSanitizeElement';
2446
- type UponSanitizeAttributeHookName = 'uponSanitizeAttribute';
2447
-
2448
- interface HooksMap {
2449
- beforeSanitizeElements: NodeHook[];
2450
- afterSanitizeElements: NodeHook[];
2451
- beforeSanitizeShadowDOM: DocumentFragmentHook[];
2452
- uponSanitizeShadowNode: NodeHook[];
2453
- afterSanitizeShadowDOM: DocumentFragmentHook[];
2454
- beforeSanitizeAttributes: ElementHook[];
2455
- afterSanitizeAttributes: ElementHook[];
2456
- uponSanitizeElement: UponSanitizeElementHook[];
2457
- uponSanitizeAttribute: UponSanitizeAttributeHook[];
2458
- }
2459
-
2460
- type ArrayElement<T> = T extends Array<infer U> ? U : never;
2461
-
2462
- type HookFunction = ArrayElement<HooksMap[keyof HooksMap]>;
2463
-
2464
- export type HookName =
2465
- | BasicHookName
2466
- | ElementHookName
2467
- | DocumentFragmentHookName
2468
- | UponSanitizeElementHookName
2469
- | UponSanitizeAttributeHookName;
2470
-
2471
- export type NodeHook = (
2472
- this: DOMPurify,
2473
- currentNode: Node,
2474
- hookEvent: null,
2475
- config: Config
2476
- ) => void;
2477
-
2478
- export type ElementHook = (
2479
- this: DOMPurify,
2480
- currentNode: Element,
2481
- hookEvent: null,
2482
- config: Config
2483
- ) => void;
2484
-
2485
- export type DocumentFragmentHook = (
2486
- this: DOMPurify,
2487
- currentNode: DocumentFragment,
2488
- hookEvent: null,
2489
- config: Config
2490
- ) => void;
2491
-
2492
- export type UponSanitizeElementHook = (
2493
- this: DOMPurify,
2494
- currentNode: Node,
2495
- hookEvent: UponSanitizeElementHookEvent,
2496
- config: Config
2497
- ) => void;
2498
-
2499
- export type UponSanitizeAttributeHook = (
2500
- this: DOMPurify,
2501
- currentNode: Element,
2502
- hookEvent: UponSanitizeAttributeHookEvent,
2503
- config: Config
2504
- ) => void;
2505
-
2506
- export interface UponSanitizeElementHookEvent {
2507
- tagName: string;
2508
- allowedTags: Record<string, boolean>;
2509
- }
2510
-
2511
- export interface UponSanitizeAttributeHookEvent {
2512
- attrName: string;
2513
- attrValue: string;
2514
- keepAttr: boolean;
2515
- allowedAttributes: Record<string, boolean>;
2516
- forceKeepAttr: boolean | undefined;
2517
- }
2518
-
2519
- /**
2520
- * A `Window`-like object containing the properties and types that DOMPurify requires.
2521
- */
2522
- export type WindowLike = Pick<
2523
- typeof globalThis,
2524
- | 'DocumentFragment'
2525
- | 'HTMLTemplateElement'
2526
- | 'Node'
2527
- | 'Element'
2528
- | 'NodeFilter'
2529
- | 'NamedNodeMap'
2530
- | 'HTMLFormElement'
2531
- | 'DOMParser'
2532
- > & {
2533
- document?: Document;
2534
- MozNamedAttrMap?: typeof window.NamedNodeMap;
2535
- } & Pick<TrustedTypesWindow, 'trustedTypes'>;