dompurify 3.4.7 → 3.4.9
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/LICENSE-MPL +373 -0
- package/README.md +29 -6
- package/dist/purify.cjs.d.ts +2 -2
- package/dist/purify.cjs.js +418 -85
- package/dist/purify.cjs.js.map +1 -1
- package/dist/purify.es.d.mts +2 -2
- package/dist/purify.es.mjs +418 -85
- package/dist/purify.es.mjs.map +1 -1
- package/dist/purify.js +418 -85
- package/dist/purify.js.map +1 -1
- package/dist/purify.min.js +2 -2
- package/dist/purify.min.js.map +1 -1
- package/package.json +18 -15
- package/src/config.ts +1 -1
- package/src/purify.ts +456 -93
package/src/purify.ts
CHANGED
|
@@ -189,6 +189,71 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
189
189
|
let trustedTypesPolicy;
|
|
190
190
|
let emptyHTML = '';
|
|
191
191
|
|
|
192
|
+
// The instance's own internal Trusted Types policy. Unlike a caller-supplied
|
|
193
|
+
// `TRUSTED_TYPES_POLICY`, this is created at most once — Trusted Types throws
|
|
194
|
+
// on duplicate policy names — and is the only policy allowed to persist
|
|
195
|
+
// across configurations and survive `clearConfig()`.
|
|
196
|
+
let defaultTrustedTypesPolicy;
|
|
197
|
+
let defaultTrustedTypesPolicyResolved = false;
|
|
198
|
+
|
|
199
|
+
// Tracks whether we are already inside a call to the configured Trusted Types
|
|
200
|
+
// policy (`createHTML` or `createScriptURL`). If a supplied policy callback
|
|
201
|
+
// itself calls `DOMPurify.sanitize` (the cause of #1422), `sanitize` would
|
|
202
|
+
// re-enter the policy and recurse until the stack overflows. We detect that
|
|
203
|
+
// re-entry and throw a clear, actionable error instead. The guard is shared
|
|
204
|
+
// across both callbacks, because either one re-entering `sanitize` triggers
|
|
205
|
+
// the same unbounded recursion.
|
|
206
|
+
let IN_TRUSTED_TYPES_POLICY = 0;
|
|
207
|
+
const _assertNotInTrustedTypesPolicy = function (): void {
|
|
208
|
+
if (IN_TRUSTED_TYPES_POLICY > 0) {
|
|
209
|
+
throw typeErrorCreate(
|
|
210
|
+
'A configured TRUSTED_TYPES_POLICY callback (createHTML or ' +
|
|
211
|
+
'createScriptURL) must not call DOMPurify.sanitize, as that causes ' +
|
|
212
|
+
'infinite recursion. Do not pass a policy whose callbacks wrap ' +
|
|
213
|
+
'DOMPurify as TRUSTED_TYPES_POLICY; see the "DOMPurify and Trusted ' +
|
|
214
|
+
'Types" section of the README.'
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const _createTrustedHTML = function (html: string): string {
|
|
220
|
+
_assertNotInTrustedTypesPolicy();
|
|
221
|
+
|
|
222
|
+
IN_TRUSTED_TYPES_POLICY++;
|
|
223
|
+
try {
|
|
224
|
+
return trustedTypesPolicy.createHTML(html);
|
|
225
|
+
} finally {
|
|
226
|
+
IN_TRUSTED_TYPES_POLICY--;
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const _createTrustedScriptURL = function (scriptUrl: string): string {
|
|
231
|
+
_assertNotInTrustedTypesPolicy();
|
|
232
|
+
|
|
233
|
+
IN_TRUSTED_TYPES_POLICY++;
|
|
234
|
+
try {
|
|
235
|
+
return trustedTypesPolicy.createScriptURL(scriptUrl);
|
|
236
|
+
} finally {
|
|
237
|
+
IN_TRUSTED_TYPES_POLICY--;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// Lazily resolve (and cache) the instance's internal default policy.
|
|
242
|
+
// Resolution is attempted at most once: a successful `createPolicy` cannot be
|
|
243
|
+
// repeated (Trusted Types throws on duplicate names), and a failed or
|
|
244
|
+
// unsupported attempt must not be retried on every parse.
|
|
245
|
+
const _getDefaultTrustedTypesPolicy = function () {
|
|
246
|
+
if (!defaultTrustedTypesPolicyResolved) {
|
|
247
|
+
defaultTrustedTypesPolicy = _createTrustedTypesPolicy(
|
|
248
|
+
trustedTypes,
|
|
249
|
+
currentScript
|
|
250
|
+
);
|
|
251
|
+
defaultTrustedTypesPolicyResolved = true;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return defaultTrustedTypesPolicy;
|
|
255
|
+
};
|
|
256
|
+
|
|
192
257
|
const {
|
|
193
258
|
implementation,
|
|
194
259
|
createNodeIterator,
|
|
@@ -397,6 +462,16 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
397
462
|
'noscript',
|
|
398
463
|
'plaintext',
|
|
399
464
|
'script',
|
|
465
|
+
// <selectedcontent> mirrors the selected <option>'s subtree, cloned by
|
|
466
|
+
// the UA (customizable <select>) — including any on* handlers — and the
|
|
467
|
+
// engine re-mirrors synchronously whenever a removal changes which
|
|
468
|
+
// option/selectedcontent is current, even inside DOMPurify's inert
|
|
469
|
+
// DOMParser document. Hoisting its children on removal re-inserts a fresh
|
|
470
|
+
// mirror target ahead of the walk, which the engine refills, looping
|
|
471
|
+
// forever (DoS) and amplifying output. Dropping its content on removal
|
|
472
|
+
// (rather than hoisting) breaks that cascade; the content is a duplicate
|
|
473
|
+
// of the option, which is sanitized on its own. See campaign-3 F1/F6.
|
|
474
|
+
'selectedcontent',
|
|
400
475
|
'style',
|
|
401
476
|
'svg',
|
|
402
477
|
'template',
|
|
@@ -759,6 +834,13 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
759
834
|
delete FORBID_TAGS.tbody;
|
|
760
835
|
}
|
|
761
836
|
|
|
837
|
+
// Re-derive the active Trusted Types policy from this configuration on
|
|
838
|
+
// every parse. The active policy must never be sticky closure state that
|
|
839
|
+
// outlives the config that set it: a caller-supplied policy left in place
|
|
840
|
+
// after `clearConfig()` — or after a later call that supplied none, or
|
|
841
|
+
// `TRUSTED_TYPES_POLICY: null` — could sign a subsequent "default"
|
|
842
|
+
// `RETURN_TRUSTED_TYPE` result with a foreign, possibly unsafe policy.
|
|
843
|
+
// See GHSA-vxr8-fq34-vvx9.
|
|
762
844
|
if (cfg.TRUSTED_TYPES_POLICY) {
|
|
763
845
|
if (typeof cfg.TRUSTED_TYPES_POLICY.createHTML !== 'function') {
|
|
764
846
|
throw typeErrorCreate(
|
|
@@ -772,23 +854,47 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
772
854
|
);
|
|
773
855
|
}
|
|
774
856
|
|
|
775
|
-
//
|
|
857
|
+
// A caller-supplied policy applies to this configuration only.
|
|
858
|
+
const previousTrustedTypesPolicy = trustedTypesPolicy;
|
|
776
859
|
trustedTypesPolicy = cfg.TRUSTED_TYPES_POLICY;
|
|
777
860
|
|
|
778
|
-
// Sign local variables required by `sanitize`.
|
|
779
|
-
|
|
861
|
+
// Sign local variables required by `sanitize`. If the supplied policy's
|
|
862
|
+
// `createHTML` is circular (i.e. it calls `DOMPurify.sanitize`), this
|
|
863
|
+
// throws via the re-entrancy guard. Restore the previous policy first so
|
|
864
|
+
// the instance is not left in a poisoned state. See #1422.
|
|
865
|
+
try {
|
|
866
|
+
emptyHTML = _createTrustedHTML('');
|
|
867
|
+
} catch (error) {
|
|
868
|
+
trustedTypesPolicy = previousTrustedTypesPolicy;
|
|
869
|
+
throw error;
|
|
870
|
+
}
|
|
871
|
+
} else if (cfg.TRUSTED_TYPES_POLICY === null) {
|
|
872
|
+
// Explicit opt-out for this call: perform no Trusted Types signing and
|
|
873
|
+
// create nothing (so a strict `trusted-types` CSP that disallows a
|
|
874
|
+
// `dompurify` policy can still call `sanitize` from inside its own
|
|
875
|
+
// policy — see #1422). Resetting to `undefined` rather than a sticky
|
|
876
|
+
// `null` also drops any previously retained caller policy, so it cannot
|
|
877
|
+
// resurface on a later call, while still allowing the next config-less
|
|
878
|
+
// call to restore the internal default policy. See GHSA-vxr8-fq34-vvx9.
|
|
879
|
+
trustedTypesPolicy = undefined;
|
|
880
|
+
emptyHTML = '';
|
|
780
881
|
} else {
|
|
781
|
-
//
|
|
882
|
+
// No policy supplied: keep the currently active policy if one is set — a
|
|
883
|
+
// previously supplied policy is intentionally sticky across config-less
|
|
884
|
+
// calls — otherwise fall back to the instance's own internal policy,
|
|
885
|
+
// created at most once. (A policy supplied for a *single* call still
|
|
886
|
+
// lingers by design; what must not linger is a policy whose configuration
|
|
887
|
+
// has been torn down via `clearConfig()`, which restores the default.)
|
|
782
888
|
if (trustedTypesPolicy === undefined) {
|
|
783
|
-
trustedTypesPolicy =
|
|
784
|
-
trustedTypes,
|
|
785
|
-
currentScript
|
|
786
|
-
);
|
|
889
|
+
trustedTypesPolicy = _getDefaultTrustedTypesPolicy();
|
|
787
890
|
}
|
|
788
891
|
|
|
789
|
-
//
|
|
790
|
-
|
|
791
|
-
|
|
892
|
+
// Sign internal variables only when a policy is active. A falsy policy
|
|
893
|
+
// (Trusted Types unsupported, creation failed, or an explicit opt-out)
|
|
894
|
+
// leaves `emptyHTML` as a plain string, so we never call `.createHTML` on
|
|
895
|
+
// a non-policy and throw. See #1422.
|
|
896
|
+
if (trustedTypesPolicy && typeof emptyHTML === 'string') {
|
|
897
|
+
emptyHTML = _createTrustedHTML('');
|
|
792
898
|
}
|
|
793
899
|
}
|
|
794
900
|
|
|
@@ -959,7 +1065,83 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
959
1065
|
// eslint-disable-next-line unicorn/prefer-dom-node-remove
|
|
960
1066
|
getParentNode(node).removeChild(node);
|
|
961
1067
|
} catch (_) {
|
|
1068
|
+
/* The normal detach failed — this is reached for a parentless node
|
|
1069
|
+
(getParentNode() is null, so .removeChild throws). Element.prototype
|
|
1070
|
+
.remove() is itself a spec no-op on a parentless node, so a recorded
|
|
1071
|
+
"removal" would otherwise hand the caller back an intact,
|
|
1072
|
+
payload-bearing node (e.g. a detached IN_PLACE root the mXSS canary or
|
|
1073
|
+
the style-with-element-child rule decided to kill). Fail closed by
|
|
1074
|
+
throwing — exactly as a clobbered root does at the IN_PLACE entry —
|
|
1075
|
+
rather than trying to "neutralize" the node via its own methods.
|
|
1076
|
+
Neutralizing would mean calling getAttributeNames()/removeAttribute()
|
|
1077
|
+
on the node, both of which a <form> root can clobber via a named child
|
|
1078
|
+
(and _isClobbered does not even probe getAttributeNames), so the
|
|
1079
|
+
neutralize step could itself be silently defeated, leaving the payload
|
|
1080
|
+
intact. A throw touches only the cached, clobber-safe remove() and
|
|
1081
|
+
getParentNode(). Generalizes GHSA-r47g-fvhr-h676 (clobbered-form root)
|
|
1082
|
+
to every root-kill reason. REPORT-3.
|
|
1083
|
+
|
|
1084
|
+
This lives inside the catch, so it never fires for a normally-removed
|
|
1085
|
+
in-tree node: those have a parent, removeChild() succeeds, and the
|
|
1086
|
+
catch is not entered. Only a kept (parentless) root reaches here. */
|
|
962
1087
|
remove(node);
|
|
1088
|
+
|
|
1089
|
+
if (!getParentNode(node)) {
|
|
1090
|
+
throw typeErrorCreate(
|
|
1091
|
+
'a node selected for removal could not be detached from its tree ' +
|
|
1092
|
+
'and cannot be safely returned; refusing to sanitize in place'
|
|
1093
|
+
);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
};
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* _neutralizeRoot
|
|
1100
|
+
*
|
|
1101
|
+
* Fail-closed teardown of an in-place root after the sanitize walk aborts
|
|
1102
|
+
* (campaign-3 F2). An internal throw mid-walk — e.g. a page-registered
|
|
1103
|
+
* custom element's reaction detaches a node so `_forceRemove`'s deliberate
|
|
1104
|
+
* parentless guard throws, or any other re-entrant engine mutation — would
|
|
1105
|
+
* otherwise leave the caller's *live* tree half-sanitized, with everything
|
|
1106
|
+
* after the abort point still carrying its handlers. There is no safe way
|
|
1107
|
+
* to resume the walk (the tree mutated under us), so we strip the root bare:
|
|
1108
|
+
* remove every child and every attribute, then let the caller's catch see
|
|
1109
|
+
* the original error. Clobber-safe (cached `remove`/`childNodes`/`attributes`
|
|
1110
|
+
* getters; the root was already clobber-pre-flighted at the IN_PLACE entry).
|
|
1111
|
+
*
|
|
1112
|
+
* @param root the in-place root to empty
|
|
1113
|
+
*/
|
|
1114
|
+
const _neutralizeRoot = function (root: Node): void {
|
|
1115
|
+
const childNodes = getChildNodes
|
|
1116
|
+
? getChildNodes(root)
|
|
1117
|
+
: (root as Element).childNodes;
|
|
1118
|
+
if (childNodes) {
|
|
1119
|
+
const snapshot: Node[] = [];
|
|
1120
|
+
arrayForEach(childNodes, (child) => {
|
|
1121
|
+
arrayPush(snapshot, child);
|
|
1122
|
+
});
|
|
1123
|
+
arrayForEach(snapshot, (child) => {
|
|
1124
|
+
try {
|
|
1125
|
+
remove(child);
|
|
1126
|
+
} catch (_) {
|
|
1127
|
+
/* Best-effort teardown; a still-attached child is handled below */
|
|
1128
|
+
}
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
const attributes = getAttributes ? getAttributes(root) : null;
|
|
1133
|
+
if (attributes) {
|
|
1134
|
+
for (let i = attributes.length - 1; i >= 0; --i) {
|
|
1135
|
+
const attribute = attributes[i];
|
|
1136
|
+
const name = attribute && attribute.name;
|
|
1137
|
+
if (typeof name === 'string') {
|
|
1138
|
+
try {
|
|
1139
|
+
(root as Element).removeAttribute(name);
|
|
1140
|
+
} catch (_) {
|
|
1141
|
+
/* Clobbered removeAttribute — ignore (fail-closed best effort) */
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
963
1145
|
}
|
|
964
1146
|
};
|
|
965
1147
|
|
|
@@ -998,6 +1180,83 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
998
1180
|
}
|
|
999
1181
|
};
|
|
1000
1182
|
|
|
1183
|
+
/**
|
|
1184
|
+
* _stripDisallowedAttributes
|
|
1185
|
+
*
|
|
1186
|
+
* Removes every attribute the active configuration does not allow from a
|
|
1187
|
+
* single element, using the same allowlist as the main attribute pass (so
|
|
1188
|
+
* `on*` handlers go, but no `/^on/` blocklist is introduced). Used only to
|
|
1189
|
+
* neutralise nodes that are being discarded from an in-place tree.
|
|
1190
|
+
*
|
|
1191
|
+
* @param element the element to strip
|
|
1192
|
+
*/
|
|
1193
|
+
const _stripDisallowedAttributes = function (element: Element): void {
|
|
1194
|
+
const attributes = getAttributes
|
|
1195
|
+
? getAttributes(element)
|
|
1196
|
+
: element.attributes;
|
|
1197
|
+
if (!attributes) {
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
for (let i = attributes.length - 1; i >= 0; --i) {
|
|
1202
|
+
const attribute = attributes[i];
|
|
1203
|
+
const name = attribute && attribute.name;
|
|
1204
|
+
if (typeof name !== 'string' || ALLOWED_ATTR[transformCaseFunc(name)]) {
|
|
1205
|
+
continue;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
try {
|
|
1209
|
+
element.removeAttribute(name);
|
|
1210
|
+
} catch (_) {
|
|
1211
|
+
/* Clobbered removeAttribute on a doomed node — ignore */
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
};
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* _neutralizeSubtree
|
|
1218
|
+
*
|
|
1219
|
+
* Completes the audit-5 F1 fix across every removal path. The KEEP_CONTENT
|
|
1220
|
+
* move-hoist neutralises only disallowed-tag removals; clobber, mXSS-canary,
|
|
1221
|
+
* namespace, comment, processing-instruction and KEEP_CONTENT:false removals
|
|
1222
|
+
* all drop their subtree wholesale via `_forceRemove`. On the IN_PLACE path
|
|
1223
|
+
* those dropped nodes are detached from the caller's LIVE tree but a
|
|
1224
|
+
* handler-bearing original among them (an `<img onerror>`/`<video>` that was
|
|
1225
|
+
* loading) keeps its queued resource event, which fires in page scope after
|
|
1226
|
+
* sanitize returns. This walks a removed subtree and strips every attribute
|
|
1227
|
+
* the active configuration does not allow — so `on*` handlers are cancelled
|
|
1228
|
+
* through the SAME allowlist that governs kept nodes, not a separate `/^on/`
|
|
1229
|
+
* blocklist. Run synchronously before sanitize returns, i.e. before any
|
|
1230
|
+
* queued event can fire. Hook-free by design: these nodes leave the output,
|
|
1231
|
+
* so firing attribute hooks for them would be surprising. Clobber-safe reads;
|
|
1232
|
+
* a doomed clobbered node may shadow `removeAttribute` (its own attributes are
|
|
1233
|
+
* irrelevant — it is discarded — while its non-clobbered descendants, e.g.
|
|
1234
|
+
* the `<img>`, are reached and scrubbed).
|
|
1235
|
+
*
|
|
1236
|
+
* @param root the root of a removed subtree to neutralise
|
|
1237
|
+
*/
|
|
1238
|
+
const _neutralizeSubtree = function (root: Node): void {
|
|
1239
|
+
const stack: Node[] = [root];
|
|
1240
|
+
|
|
1241
|
+
while (stack.length > 0) {
|
|
1242
|
+
const node = stack.pop();
|
|
1243
|
+
const nodeType = getNodeType ? getNodeType(node) : (node as any).nodeType;
|
|
1244
|
+
|
|
1245
|
+
if (nodeType === NODE_TYPE.element) {
|
|
1246
|
+
_stripDisallowedAttributes(node as Element);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
const childNodes = getChildNodes
|
|
1250
|
+
? getChildNodes(node)
|
|
1251
|
+
: (node as Element).childNodes;
|
|
1252
|
+
if (childNodes) {
|
|
1253
|
+
for (let i = childNodes.length - 1; i >= 0; --i) {
|
|
1254
|
+
stack.push(childNodes[i]);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
};
|
|
1259
|
+
|
|
1001
1260
|
/**
|
|
1002
1261
|
* _initDocument
|
|
1003
1262
|
*
|
|
@@ -1028,9 +1287,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1028
1287
|
'</body></html>';
|
|
1029
1288
|
}
|
|
1030
1289
|
|
|
1031
|
-
const dirtyPayload = trustedTypesPolicy
|
|
1032
|
-
? trustedTypesPolicy.createHTML(dirty)
|
|
1033
|
-
: dirty;
|
|
1290
|
+
const dirtyPayload = trustedTypesPolicy ? _createTrustedHTML(dirty) : dirty;
|
|
1034
1291
|
/*
|
|
1035
1292
|
* Use the DOMParser API by default, fallback later if needs be
|
|
1036
1293
|
* DOMParser not work for svg when has multiple root element.
|
|
@@ -1134,6 +1391,16 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1134
1391
|
currentNode.data = data;
|
|
1135
1392
|
currentNode = walker.nextNode() as CharacterData | null;
|
|
1136
1393
|
}
|
|
1394
|
+
|
|
1395
|
+
// NodeIterator does not descend into <template>.content per the DOM spec,
|
|
1396
|
+
// so we must explicitly recurse into each template's content fragment,
|
|
1397
|
+
// mirroring the approach used by _sanitizeShadowDOM.
|
|
1398
|
+
const templates = node.querySelectorAll?.('template') ?? [];
|
|
1399
|
+
arrayForEach(Array.from(templates), (tmpl: HTMLTemplateElement) => {
|
|
1400
|
+
if (_isDocumentFragment(tmpl.content)) {
|
|
1401
|
+
_scrubTemplateExpressions(tmpl.content as unknown as Element);
|
|
1402
|
+
}
|
|
1403
|
+
});
|
|
1137
1404
|
};
|
|
1138
1405
|
|
|
1139
1406
|
/**
|
|
@@ -1277,7 +1544,9 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1277
1544
|
}
|
|
1278
1545
|
|
|
1279
1546
|
/* Now let's check the element's type and name */
|
|
1280
|
-
const tagName = transformCaseFunc(
|
|
1547
|
+
const tagName = transformCaseFunc(
|
|
1548
|
+
getNodeName ? getNodeName(currentNode) : currentNode.nodeName
|
|
1549
|
+
);
|
|
1281
1550
|
|
|
1282
1551
|
/* Execute a hook if present */
|
|
1283
1552
|
_executeHooks(hooks.uponSanitizeElement, currentNode, {
|
|
@@ -1365,9 +1634,32 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1365
1634
|
if (childNodes && parentNode) {
|
|
1366
1635
|
const childCount = childNodes.length;
|
|
1367
1636
|
|
|
1637
|
+
/* In-place: hoist the *original* children so the iterator visits
|
|
1638
|
+
and sanitises them through the same allowlist pass as every other
|
|
1639
|
+
node. The caller built the tree in the live document, so the
|
|
1640
|
+
originals carry already-queued resource events (`<img onerror>`,
|
|
1641
|
+
`<video>`/`<audio>` error, lazy/`onload`, …); cloning would leave
|
|
1642
|
+
those originals detached but still armed, firing in page scope
|
|
1643
|
+
while the returned tree looked clean. Moving is safe in-place: the
|
|
1644
|
+
root is pre-validated as an allowed tag and so is never the node
|
|
1645
|
+
being removed, which keeps `parentNode` inside the iterator root
|
|
1646
|
+
and the relocated child inside the serialised tree.
|
|
1647
|
+
|
|
1648
|
+
Otherwise (string / DOM-copy paths): clone. The iterator is rooted
|
|
1649
|
+
at — and the result serialised from — `body`, so a restrictive
|
|
1650
|
+
ALLOWED_TAGS that removes `body` itself must leave its content in
|
|
1651
|
+
place, which only cloning does; and those paths parse into an
|
|
1652
|
+
inert document, so their discarded originals never had a queued
|
|
1653
|
+
event to neutralise.
|
|
1654
|
+
|
|
1655
|
+
`childNodes` is live; a tail-to-head walk keeps `childNodes[i]`
|
|
1656
|
+
valid whether we move (drops the trailing entry) or clone (leaves
|
|
1657
|
+
the list intact). */
|
|
1368
1658
|
for (let i = childCount - 1; i >= 0; --i) {
|
|
1369
|
-
const
|
|
1370
|
-
|
|
1659
|
+
const hoisted = IN_PLACE
|
|
1660
|
+
? childNodes[i]
|
|
1661
|
+
: cloneNode(childNodes[i], true);
|
|
1662
|
+
parentNode.insertBefore(hoisted, getNextSibling(currentNode));
|
|
1371
1663
|
}
|
|
1372
1664
|
}
|
|
1373
1665
|
}
|
|
@@ -1683,12 +1975,12 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1683
1975
|
} else {
|
|
1684
1976
|
switch (trustedTypes.getAttributeType(lcTag, lcName)) {
|
|
1685
1977
|
case 'TrustedHTML': {
|
|
1686
|
-
value =
|
|
1978
|
+
value = _createTrustedHTML(value);
|
|
1687
1979
|
break;
|
|
1688
1980
|
}
|
|
1689
1981
|
|
|
1690
1982
|
case 'TrustedScriptURL': {
|
|
1691
|
-
value =
|
|
1983
|
+
value = _createTrustedScriptURL(value);
|
|
1692
1984
|
break;
|
|
1693
1985
|
}
|
|
1694
1986
|
|
|
@@ -1766,7 +2058,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1766
2058
|
iterator also surfaces. */
|
|
1767
2059
|
const shadowNodeType = getNodeType
|
|
1768
2060
|
? getNodeType(shadowNode)
|
|
1769
|
-
:
|
|
2061
|
+
: shadowNode.nodeType;
|
|
1770
2062
|
if (shadowNodeType === NODE_TYPE.element) {
|
|
1771
2063
|
const innerSr = getShadowRoot
|
|
1772
2064
|
? getShadowRoot(shadowNode)
|
|
@@ -1802,57 +2094,80 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1802
2094
|
* @param root the subtree root to walk for attached shadow roots
|
|
1803
2095
|
*/
|
|
1804
2096
|
const _sanitizeAttachedShadowRoots = function (root: Node): void {
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
2097
|
+
/* Iterative (explicit stack) rather than per-child recursion. DOM APIs
|
|
2098
|
+
impose no depth cap, so an attacker-shaped tree (JSON/CRDT/editor data
|
|
2099
|
+
built straight into the DOM — the IN_PLACE surface) deeper than the JS
|
|
2100
|
+
call-stack budget would otherwise overflow native recursion here and
|
|
2101
|
+
throw at the IN_PLACE entry pre-pass, before a single node is
|
|
2102
|
+
sanitized, leaving the caller's live tree untouched (fail-open). See
|
|
2103
|
+
campaign-3 F4. A heap stack keeps depth off the call stack.
|
|
2104
|
+
|
|
2105
|
+
Each work item is either a node to descend into, or a deferred
|
|
2106
|
+
`_sanitizeShadowDOM` for an already-walked shadow root. The deferred
|
|
2107
|
+
form preserves the original post-order discipline: a shadow root's
|
|
2108
|
+
nested shadow roots are discovered before the outer shadow is
|
|
2109
|
+
sanitized (which may remove hosts). Pushes are in reverse of the
|
|
2110
|
+
desired processing order (LIFO): template content, then children, then
|
|
2111
|
+
the shadow-sanitize, then the shadow walk — so the order matches the
|
|
2112
|
+
previous recursion exactly. */
|
|
2113
|
+
const stack: Array<{ node: Node | null; shadow: DocumentFragment | null }> =
|
|
2114
|
+
[{ node: root, shadow: null }];
|
|
2115
|
+
|
|
2116
|
+
while (stack.length > 0) {
|
|
2117
|
+
const item = stack.pop();
|
|
2118
|
+
|
|
2119
|
+
/* Deferred shadow-DOM sanitisation: runs after its subtree was walked. */
|
|
2120
|
+
if (item.shadow) {
|
|
2121
|
+
_sanitizeShadowDOM(item.shadow);
|
|
2122
|
+
continue;
|
|
1823
2123
|
}
|
|
1824
|
-
}
|
|
1825
2124
|
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
2125
|
+
const node = item.node;
|
|
2126
|
+
const nodeType = getNodeType ? getNodeType(node) : (node as any).nodeType;
|
|
2127
|
+
const isElement = nodeType === NODE_TYPE.element;
|
|
2128
|
+
|
|
2129
|
+
/* (pushed last → processed first) Children, snapshotted in reverse so
|
|
2130
|
+
the first child is processed first. Snapshotting matters because a
|
|
2131
|
+
hook may detach siblings mid-walk. */
|
|
2132
|
+
const childNodes = getChildNodes
|
|
2133
|
+
? getChildNodes(node)
|
|
2134
|
+
: (node as Element).childNodes;
|
|
2135
|
+
if (childNodes) {
|
|
2136
|
+
for (let i = childNodes.length - 1; i >= 0; --i) {
|
|
2137
|
+
stack.push({ node: childNodes[i], shadow: null });
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
1841
2140
|
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
2141
|
+
/* (pushed before children → processed after them, matching the old
|
|
2142
|
+
"template content last" order) When the node is a <template>,
|
|
2143
|
+
descend into its content. */
|
|
2144
|
+
if (isElement) {
|
|
2145
|
+
const rootName = getNodeName ? getNodeName(node) : null;
|
|
2146
|
+
if (
|
|
2147
|
+
typeof rootName === 'string' &&
|
|
2148
|
+
transformCaseFunc(rootName) === 'template'
|
|
2149
|
+
) {
|
|
2150
|
+
const content = (node as HTMLTemplateElement).content;
|
|
2151
|
+
if (_isDocumentFragment(content)) {
|
|
2152
|
+
stack.push({ node: content, shadow: null });
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
1845
2156
|
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
2157
|
+
/* Shadow root (processed first): walk its subtree, then sanitise it.
|
|
2158
|
+
Realm-safe check (GHSA-hpcv-96wg-7vj8): nodeType-based detection
|
|
2159
|
+
rather than `instanceof DocumentFragment`, which is realm-bound and
|
|
2160
|
+
silently skipped foreign-realm shadow roots (e.g.
|
|
2161
|
+
iframe.contentDocument attachShadow). */
|
|
2162
|
+
if (isElement) {
|
|
2163
|
+
const sr = getShadowRoot
|
|
2164
|
+
? getShadowRoot(node)
|
|
2165
|
+
: (node as Element).shadowRoot;
|
|
2166
|
+
if (_isDocumentFragment(sr)) {
|
|
2167
|
+
/* Push the deferred sanitise first so it pops after the shadow
|
|
2168
|
+
walk we push next, i.e. nested shadow roots are discovered
|
|
2169
|
+
before this one is sanitised. */
|
|
2170
|
+
stack.push({ node: null, shadow: sr }, { node: sr, shadow: null });
|
|
1856
2171
|
}
|
|
1857
2172
|
}
|
|
1858
2173
|
}
|
|
@@ -1894,12 +2209,15 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1894
2209
|
/* Clean up removed elements */
|
|
1895
2210
|
DOMPurify.removed = [];
|
|
1896
2211
|
|
|
1897
|
-
/*
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
2212
|
+
/* Resolve IN_PLACE for this call without mutating persistent config.
|
|
2213
|
+
Writing the IN_PLACE closure variable here leaks under setConfig(),
|
|
2214
|
+
where _parseConfig is skipped on later calls: a single string call would
|
|
2215
|
+
disable in-place mode for every subsequent node call, returning a
|
|
2216
|
+
sanitized copy while leaving the caller's node — which in-place callers
|
|
2217
|
+
keep using and whose return value they ignore — unsanitized. REPORT-2. */
|
|
2218
|
+
const inPlace = IN_PLACE && typeof dirty !== 'string' && _isNode(dirty);
|
|
1901
2219
|
|
|
1902
|
-
if (
|
|
2220
|
+
if (inPlace) {
|
|
1903
2221
|
/* Do some early pre-sanitization to avoid unsafe root nodes.
|
|
1904
2222
|
Read nodeName through the cached prototype getter — a clobbering
|
|
1905
2223
|
child named "nodeName" on the form root would otherwise shadow
|
|
@@ -1934,8 +2252,17 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1934
2252
|
}
|
|
1935
2253
|
|
|
1936
2254
|
/* Sanitize attached shadow roots before the main iterator runs.
|
|
1937
|
-
The iterator does not descend into shadow trees.
|
|
1938
|
-
|
|
2255
|
+
The iterator does not descend into shadow trees. Same fail-closed
|
|
2256
|
+
barrier as the main walk (campaign-3 F2): a custom-element reaction
|
|
2257
|
+
inside a shadow root could abort this pre-pass before the walk runs,
|
|
2258
|
+
which would otherwise leave the entire live tree unsanitized. */
|
|
2259
|
+
try {
|
|
2260
|
+
_sanitizeAttachedShadowRoots(dirty as Node);
|
|
2261
|
+
} catch (error) {
|
|
2262
|
+
_neutralizeRoot(dirty as Node);
|
|
2263
|
+
|
|
2264
|
+
throw error;
|
|
2265
|
+
}
|
|
1939
2266
|
} else if (_isNode(dirty)) {
|
|
1940
2267
|
/* If dirty is a DOM element, append to an empty document to avoid
|
|
1941
2268
|
elements being stripped by the parser */
|
|
@@ -1970,7 +2297,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1970
2297
|
dirty.indexOf('<') === -1
|
|
1971
2298
|
) {
|
|
1972
2299
|
return trustedTypesPolicy && RETURN_TRUSTED_TYPE
|
|
1973
|
-
?
|
|
2300
|
+
? _createTrustedHTML(dirty)
|
|
1974
2301
|
: dirty;
|
|
1975
2302
|
}
|
|
1976
2303
|
|
|
@@ -1989,27 +2316,56 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1989
2316
|
}
|
|
1990
2317
|
|
|
1991
2318
|
/* Get node iterator */
|
|
1992
|
-
const nodeIterator = _createNodeIterator(
|
|
1993
|
-
|
|
1994
|
-
/* Now start iterating over the created document
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2319
|
+
const nodeIterator = _createNodeIterator(inPlace ? dirty : body);
|
|
2320
|
+
|
|
2321
|
+
/* Now start iterating over the created document.
|
|
2322
|
+
The walk runs inside an exception barrier (campaign-3 F2): a re-entrant
|
|
2323
|
+
engine/custom-element mutation can detach a node mid-walk so
|
|
2324
|
+
`_forceRemove`'s parentless guard throws, aborting the loop. Without the
|
|
2325
|
+
barrier the caller's in-place tree would be left half-sanitized with the
|
|
2326
|
+
unvisited tail still armed. On any throw we fail closed — strip the
|
|
2327
|
+
in-place root bare — then rethrow so the existing throw contract is
|
|
2328
|
+
preserved. (String/DOM-copy paths never return the partial body, so the
|
|
2329
|
+
propagating throw is already fail-closed there.) */
|
|
2330
|
+
try {
|
|
2331
|
+
while ((currentNode = nodeIterator.nextNode())) {
|
|
2332
|
+
/* Sanitize tags and elements */
|
|
2333
|
+
_sanitizeElements(currentNode);
|
|
2334
|
+
|
|
2335
|
+
/* Check attributes next */
|
|
2336
|
+
_sanitizeAttributes(currentNode);
|
|
2337
|
+
|
|
2338
|
+
/* Shadow DOM detected, sanitize it.
|
|
2339
|
+
Realm-safe check (GHSA-hpcv-96wg-7vj8): nodeType-based detection
|
|
2340
|
+
instead of instanceof, so foreign-realm <template>.content is
|
|
2341
|
+
walked correctly. */
|
|
2342
|
+
if (_isDocumentFragment(currentNode.content)) {
|
|
2343
|
+
_sanitizeShadowDOM(currentNode.content);
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
} catch (error) {
|
|
2347
|
+
if (inPlace) {
|
|
2348
|
+
_neutralizeRoot(dirty as Node);
|
|
2008
2349
|
}
|
|
2350
|
+
|
|
2351
|
+
throw error;
|
|
2009
2352
|
}
|
|
2010
2353
|
|
|
2011
2354
|
/* If we sanitized `dirty` in-place, return it. */
|
|
2012
|
-
if (
|
|
2355
|
+
if (inPlace) {
|
|
2356
|
+
/* Fail-closed completion of the audit-5 F1 fix: every node removed from
|
|
2357
|
+
the caller's live tree is detached but may still hold a queued
|
|
2358
|
+
resource-event handler that fires in page scope after we return. The
|
|
2359
|
+
move-hoist covers only disallowed-tag KEEP_CONTENT removals; strip the
|
|
2360
|
+
non-allow-listed attributes off every other removed subtree (clobber,
|
|
2361
|
+
mXSS, namespace, comments, KEEP_CONTENT:false, …) so those handlers are
|
|
2362
|
+
cancelled before any event can fire. Runs synchronously, pre-return. */
|
|
2363
|
+
arrayForEach(DOMPurify.removed, (entry) => {
|
|
2364
|
+
if (entry.element) {
|
|
2365
|
+
_neutralizeSubtree(entry.element as Node);
|
|
2366
|
+
}
|
|
2367
|
+
});
|
|
2368
|
+
|
|
2013
2369
|
if (SAFE_FOR_TEMPLATES) {
|
|
2014
2370
|
_scrubTemplateExpressions(dirty as Element);
|
|
2015
2371
|
}
|
|
@@ -2071,7 +2427,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2071
2427
|
}
|
|
2072
2428
|
|
|
2073
2429
|
return trustedTypesPolicy && RETURN_TRUSTED_TYPE
|
|
2074
|
-
?
|
|
2430
|
+
? _createTrustedHTML(serializedHTML)
|
|
2075
2431
|
: serializedHTML;
|
|
2076
2432
|
};
|
|
2077
2433
|
|
|
@@ -2083,6 +2439,13 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2083
2439
|
DOMPurify.clearConfig = function () {
|
|
2084
2440
|
CONFIG = null;
|
|
2085
2441
|
SET_CONFIG = false;
|
|
2442
|
+
|
|
2443
|
+
// Drop any caller-supplied Trusted Types policy so it cannot poison later
|
|
2444
|
+
// `RETURN_TRUSTED_TYPE` output. The internal default policy (cached, and
|
|
2445
|
+
// never recreated — Trusted Types throws on duplicate names) is restored by
|
|
2446
|
+
// the next `_parseConfig`. See GHSA-vxr8-fq34-vvx9.
|
|
2447
|
+
trustedTypesPolicy = defaultTrustedTypesPolicy;
|
|
2448
|
+
emptyHTML = '';
|
|
2086
2449
|
};
|
|
2087
2450
|
|
|
2088
2451
|
DOMPurify.isValidAttribute = function (tag, attr, value) {
|