dompurify 3.4.13 → 3.4.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -2
- package/dist/purify.cjs.d.ts +1 -1
- package/dist/purify.cjs.js +360 -126
- package/dist/purify.cjs.js.map +1 -1
- package/dist/purify.es.d.mts +1 -1
- package/dist/purify.es.mjs +360 -126
- package/dist/purify.es.mjs.map +1 -1
- package/dist/purify.js +360 -126
- 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 +1 -1
- package/src/attrs.ts +2 -0
- package/src/purify.ts +442 -210
- package/src/types.ts +2 -5
package/src/purify.ts
CHANGED
|
@@ -65,6 +65,48 @@ const NODE_TYPE = {
|
|
|
65
65
|
notation: 12, // Deprecated
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
+
/* HTML-namespace elements whose child text nodes are serialized *literally*
|
|
69
|
+
(unescaped) by the HTML fragment-serialization algorithm. Two reparse-mXSS
|
|
70
|
+
shapes ride on that literal serialization:
|
|
71
|
+
(a) an element child - a tree the HTML parser can never build, but the DOM
|
|
72
|
+
API and an XML/XHTML parse can - after which a `</tag>`-bearing text
|
|
73
|
+
sibling breaks the element open on reparse; and
|
|
74
|
+
(b) text-only content that already carries the element's OWN end tag, e.g.
|
|
75
|
+
`<style>...</style><img onerror=x>` built as a node, which the literal
|
|
76
|
+
serializer emits verbatim for the HTML parser to re-open.
|
|
77
|
+
Shape (a) is handled by the firstElementChild branch in _isUnsafeNode; shape
|
|
78
|
+
(b) by the LITERAL_TEXT_CLOSE probe. Both read textContent (the raw-serialized
|
|
79
|
+
form for these elements) rather than innerHTML, because an XML/XHTML working
|
|
80
|
+
document serializes innerHTML with `<` escaped, which silently blinds the
|
|
81
|
+
innerHTML-based probes (rule 1's second probe and FALLBACK_TAG_CLOSE) there.
|
|
82
|
+
`script` is never allow-listed, but is kept here so the guard matches the
|
|
83
|
+
serializer's own literal-text list exactly. */
|
|
84
|
+
const LITERAL_TEXT_ELEMENT_NAMES = [
|
|
85
|
+
'style',
|
|
86
|
+
'script',
|
|
87
|
+
'xmp',
|
|
88
|
+
'iframe',
|
|
89
|
+
'noembed',
|
|
90
|
+
'noframes',
|
|
91
|
+
'plaintext',
|
|
92
|
+
'noscript',
|
|
93
|
+
];
|
|
94
|
+
const LITERAL_TEXT_ELEMENTS = freeze(addToSet({}, LITERAL_TEXT_ELEMENT_NAMES));
|
|
95
|
+
|
|
96
|
+
/* Per-element end-tag matcher. On an HTML reparse the ONLY token that
|
|
97
|
+
terminates a literal-text element's raw content is its own end tag; a foreign
|
|
98
|
+
literal-text close (e.g. `</xmp>` sitting inside `<style>`) does not break
|
|
99
|
+
out, so matching is per-element, not a shared alternation. The lookahead
|
|
100
|
+
requires an HTML tag-name terminator (whitespace, `/` or `>`) so a longer
|
|
101
|
+
name such as `</styles` is not mistaken for `</style`. */
|
|
102
|
+
const LITERAL_TEXT_CLOSE = (function (): Record<string, RegExp> {
|
|
103
|
+
const map: Record<string, RegExp> = {};
|
|
104
|
+
arrayForEach(LITERAL_TEXT_ELEMENT_NAMES, (name) => {
|
|
105
|
+
map[name] = seal(new RegExp('</' + name + '(?=[\\t\\n\\f\\r />])', 'i'));
|
|
106
|
+
});
|
|
107
|
+
return freeze(map);
|
|
108
|
+
})();
|
|
109
|
+
|
|
68
110
|
const getGlobal = function (): WindowLike {
|
|
69
111
|
return typeof window === 'undefined' ? null : window;
|
|
70
112
|
};
|
|
@@ -163,6 +205,28 @@ const _resolveSetOption = function (
|
|
|
163
205
|
: fallback;
|
|
164
206
|
};
|
|
165
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Resolve an object-valued configuration option: a prototype-free clone
|
|
210
|
+
* of cfg[key] when it is an own, truthy object property, else a fresh
|
|
211
|
+
* fallback built by makeFallback (fresh on every parse, so a previous
|
|
212
|
+
* parse can never leak state into the next one).
|
|
213
|
+
*
|
|
214
|
+
* @param cfg the cloned, prototype-free configuration object
|
|
215
|
+
* @param key the configuration property to read
|
|
216
|
+
* @param makeFallback builds the fallback value when the option is absent
|
|
217
|
+
* @returns the resolved object
|
|
218
|
+
*/
|
|
219
|
+
const _resolveObjectOption = function <T extends Record<string, any>>(
|
|
220
|
+
cfg: Config,
|
|
221
|
+
key: keyof Config,
|
|
222
|
+
makeFallback: () => T
|
|
223
|
+
): T {
|
|
224
|
+
const value = objectHasOwnProperty(cfg, key) ? cfg[key] : undefined;
|
|
225
|
+
return value && typeof value === 'object'
|
|
226
|
+
? clone(value as T)
|
|
227
|
+
: makeFallback();
|
|
228
|
+
};
|
|
229
|
+
|
|
166
230
|
function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
167
231
|
const DOMPurify: DOMPurify = (root: WindowLike) => createDOMPurify(root);
|
|
168
232
|
|
|
@@ -204,6 +268,15 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
204
268
|
|
|
205
269
|
const cloneNode = lookupGetter(ElementPrototype, 'cloneNode');
|
|
206
270
|
const remove = lookupGetter(ElementPrototype, 'remove');
|
|
271
|
+
// Clobber-safe Attr-node removal. On an HTMLFormElement a descendant named
|
|
272
|
+
// "removeAttributeNode" shadows the prototype method via
|
|
273
|
+
// [LegacyOverrideBuiltIns], so element.removeAttributeNode(attr) throws.
|
|
274
|
+
// Calling the cached Element.prototype method with the element as the
|
|
275
|
+
// receiver removes the exact live Attr node regardless of the shadowing.
|
|
276
|
+
const removeAttributeNode = lookupGetter(
|
|
277
|
+
ElementPrototype,
|
|
278
|
+
'removeAttributeNode'
|
|
279
|
+
);
|
|
207
280
|
const getNextSibling = lookupGetter(ElementPrototype, 'nextSibling');
|
|
208
281
|
const getChildNodes = lookupGetter(ElementPrototype, 'childNodes');
|
|
209
282
|
const getParentNode = lookupGetter(ElementPrototype, 'parentNode');
|
|
@@ -218,6 +291,18 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
218
291
|
? lookupGetter(Node.prototype, 'ownerDocument')
|
|
219
292
|
: null;
|
|
220
293
|
|
|
294
|
+
/* Clobber-safe nodeType / nodeName reads through the cached Node.prototype
|
|
295
|
+
getters, with a direct-property fallback for environments that lack
|
|
296
|
+
Node.prototype. Sites that need a different fallback (e.g. _isClobbered
|
|
297
|
+
returns early on a null name) intentionally keep their own reads. */
|
|
298
|
+
const _readNodeType = function (node: Node): number {
|
|
299
|
+
return getNodeType ? getNodeType(node) : (node as any).nodeType;
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const _readNodeName = function (node: Node): string {
|
|
303
|
+
return getNodeName ? getNodeName(node) : (node as any).nodeName;
|
|
304
|
+
};
|
|
305
|
+
|
|
221
306
|
// As per issue #47, the web-components registry is inherited by a
|
|
222
307
|
// new document created via createHTMLDocument. As per the spec
|
|
223
308
|
// (http://w3c.github.io/webcomponents/spec/custom/#creating-and-passing-registries)
|
|
@@ -728,26 +813,23 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
728
813
|
NAMESPACE =
|
|
729
814
|
typeof cfg.NAMESPACE === 'string' ? cfg.NAMESPACE : HTML_NAMESPACE; // Default HTML namespace
|
|
730
815
|
|
|
731
|
-
MATHML_TEXT_INTEGRATION_POINTS =
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
typeof cfg.CUSTOM_ELEMENT_HANDLING === 'object'
|
|
749
|
-
? clone(cfg.CUSTOM_ELEMENT_HANDLING)
|
|
750
|
-
: create(null);
|
|
816
|
+
MATHML_TEXT_INTEGRATION_POINTS = _resolveObjectOption(
|
|
817
|
+
cfg,
|
|
818
|
+
'MATHML_TEXT_INTEGRATION_POINTS',
|
|
819
|
+
() => addToSet({}, DEFAULT_MATHML_TEXT_INTEGRATION_POINTS) // Default built-in map
|
|
820
|
+
);
|
|
821
|
+
|
|
822
|
+
HTML_INTEGRATION_POINTS = _resolveObjectOption(
|
|
823
|
+
cfg,
|
|
824
|
+
'HTML_INTEGRATION_POINTS',
|
|
825
|
+
() => addToSet({}, DEFAULT_HTML_INTEGRATION_POINTS) // Default built-in map
|
|
826
|
+
);
|
|
827
|
+
|
|
828
|
+
const customElementHandling = _resolveObjectOption(
|
|
829
|
+
cfg,
|
|
830
|
+
'CUSTOM_ELEMENT_HANDLING',
|
|
831
|
+
() => create(null)
|
|
832
|
+
);
|
|
751
833
|
|
|
752
834
|
CUSTOM_ELEMENT_HANDLING = create(null);
|
|
753
835
|
|
|
@@ -845,24 +927,6 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
845
927
|
}
|
|
846
928
|
}
|
|
847
929
|
|
|
848
|
-
if (
|
|
849
|
-
objectHasOwnProperty(cfg, 'ADD_URI_SAFE_ATTR') &&
|
|
850
|
-
arrayIsArray(cfg.ADD_URI_SAFE_ATTR)
|
|
851
|
-
) {
|
|
852
|
-
addToSet(URI_SAFE_ATTRIBUTES, cfg.ADD_URI_SAFE_ATTR, transformCaseFunc);
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
if (
|
|
856
|
-
objectHasOwnProperty(cfg, 'FORBID_CONTENTS') &&
|
|
857
|
-
arrayIsArray(cfg.FORBID_CONTENTS)
|
|
858
|
-
) {
|
|
859
|
-
if (FORBID_CONTENTS === DEFAULT_FORBID_CONTENTS) {
|
|
860
|
-
FORBID_CONTENTS = clone(FORBID_CONTENTS);
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
addToSet(FORBID_CONTENTS, cfg.FORBID_CONTENTS, transformCaseFunc);
|
|
864
|
-
}
|
|
865
|
-
|
|
866
930
|
if (
|
|
867
931
|
objectHasOwnProperty(cfg, 'ADD_FORBID_CONTENTS') &&
|
|
868
932
|
arrayIsArray(cfg.ADD_FORBID_CONTENTS)
|
|
@@ -1175,6 +1239,37 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1175
1239
|
}
|
|
1176
1240
|
};
|
|
1177
1241
|
|
|
1242
|
+
/**
|
|
1243
|
+
* _stripAttributeNode
|
|
1244
|
+
*
|
|
1245
|
+
* Remove a single Attr node case/namespace-exactly on an attribute-teardown
|
|
1246
|
+
* path. Name-based removeAttribute() ASCII-lowercases its lookup key for an
|
|
1247
|
+
* HTML element in an HTML document and so silently misses a case-preserved
|
|
1248
|
+
* handler (e.g. `ONERROR` off an XML/XHTML import) - the same defect
|
|
1249
|
+
* _removeAttribute() was fixed for, which a name-based call would reintroduce
|
|
1250
|
+
* on these IN_PLACE teardown paths. Unlike _removeAttribute this does not
|
|
1251
|
+
* record into DOMPurify.removed: the neutralize passes intentionally do not
|
|
1252
|
+
* book-keep. A clobbered/detached node falls back to best-effort name-based
|
|
1253
|
+
* removal.
|
|
1254
|
+
*
|
|
1255
|
+
* @param element the element to strip the attribute from
|
|
1256
|
+
* @param attribute the Attr node to remove
|
|
1257
|
+
* @param name the attribute's name, for the fallback path
|
|
1258
|
+
*/
|
|
1259
|
+
const _stripAttributeNode = function (
|
|
1260
|
+
element: Element,
|
|
1261
|
+
attribute: Attr,
|
|
1262
|
+
name: string
|
|
1263
|
+
): void {
|
|
1264
|
+
try {
|
|
1265
|
+
removeAttributeNode(element, attribute);
|
|
1266
|
+
} catch (_) {
|
|
1267
|
+
try {
|
|
1268
|
+
element.removeAttribute(name);
|
|
1269
|
+
} catch (_) {}
|
|
1270
|
+
}
|
|
1271
|
+
};
|
|
1272
|
+
|
|
1178
1273
|
/**
|
|
1179
1274
|
* _neutralizeRoot
|
|
1180
1275
|
*
|
|
@@ -1221,11 +1316,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1221
1316
|
const attribute = attributes[i];
|
|
1222
1317
|
const name = attribute && attribute.name;
|
|
1223
1318
|
if (typeof name === 'string') {
|
|
1224
|
-
|
|
1225
|
-
(root as Element).removeAttribute(name);
|
|
1226
|
-
} catch (_) {
|
|
1227
|
-
/* Clobbered removeAttribute — ignore (fail-closed best effort) */
|
|
1228
|
-
}
|
|
1319
|
+
_stripAttributeNode(root as Element, attribute, name);
|
|
1229
1320
|
}
|
|
1230
1321
|
}
|
|
1231
1322
|
}
|
|
@@ -1234,24 +1325,58 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1234
1325
|
/**
|
|
1235
1326
|
* _removeAttribute
|
|
1236
1327
|
*
|
|
1328
|
+
* Name-based getAttributeNode()/removeAttribute() ASCII-lowercase their
|
|
1329
|
+
* lookup key for HTML elements in an HTML document, so they silently miss an
|
|
1330
|
+
* attribute whose stored qualified name still contains uppercase ASCII
|
|
1331
|
+
* letters. That happens when the node came from a case-preserving source
|
|
1332
|
+
* (an XML/XHTML document imported via importNode(), or createAttributeNS()),
|
|
1333
|
+
* where e.g. `ONERROR` survives the walk: the policy check lowercases to
|
|
1334
|
+
* `onerror` and rejects it, but `removeAttribute('ONERROR')` looks up
|
|
1335
|
+
* `onerror` and finds nothing. Remove the exact Attr node instead, which is
|
|
1336
|
+
* case- and namespace-exact, and fall back to name-based removal only when
|
|
1337
|
+
* the caller could not supply the node.
|
|
1338
|
+
*
|
|
1237
1339
|
* @param name an Attribute name
|
|
1238
1340
|
* @param element a DOM node
|
|
1341
|
+
* @param attr the exact Attr node to remove, when the caller has it
|
|
1239
1342
|
*/
|
|
1240
|
-
const _removeAttribute = function (
|
|
1343
|
+
const _removeAttribute = function (
|
|
1344
|
+
name: string,
|
|
1345
|
+
element: Element,
|
|
1346
|
+
attr?: Attr | null
|
|
1347
|
+
): void {
|
|
1348
|
+
if (!attr) {
|
|
1349
|
+
try {
|
|
1350
|
+
attr = element.getAttributeNode(name);
|
|
1351
|
+
} catch (_) {
|
|
1352
|
+
attr = null;
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
arrayPush(DOMPurify.removed, {
|
|
1357
|
+
attribute: attr || null,
|
|
1358
|
+
from: element,
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1241
1361
|
try {
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1362
|
+
if (attr) {
|
|
1363
|
+
removeAttributeNode(element, attr);
|
|
1364
|
+
} else {
|
|
1365
|
+
element.removeAttribute(name);
|
|
1366
|
+
}
|
|
1246
1367
|
} catch (_) {
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1368
|
+
/* Clobbered or already-detached node - best-effort fall back to a
|
|
1369
|
+
name-based removal so the "is" handling below still runs. Note this
|
|
1370
|
+
fallback ASCII-lowercases its lookup key in an HTML document and so
|
|
1371
|
+
cannot reach a case-preserved attribute name; the cached
|
|
1372
|
+
removeAttributeNode() above is what removes those, and a clobbered
|
|
1373
|
+
form is already removed wholesale by _isClobbered() before reaching
|
|
1374
|
+
here. */
|
|
1375
|
+
try {
|
|
1376
|
+
element.removeAttribute(name);
|
|
1377
|
+
} catch (_) {}
|
|
1251
1378
|
}
|
|
1252
1379
|
|
|
1253
|
-
element.removeAttribute(name);
|
|
1254
|
-
|
|
1255
1380
|
// We void attribute values for unremovable "is" attributes
|
|
1256
1381
|
if (name === 'is') {
|
|
1257
1382
|
if (RETURN_DOM || RETURN_DOM_FRAGMENT) {
|
|
@@ -1289,11 +1414,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1289
1414
|
continue;
|
|
1290
1415
|
}
|
|
1291
1416
|
|
|
1292
|
-
|
|
1293
|
-
element.removeAttribute(name);
|
|
1294
|
-
} catch (_) {
|
|
1295
|
-
/* Clobbered removeAttribute on a doomed node — ignore */
|
|
1296
|
-
}
|
|
1417
|
+
_stripAttributeNode(element, attribute, name);
|
|
1297
1418
|
}
|
|
1298
1419
|
};
|
|
1299
1420
|
|
|
@@ -1324,7 +1445,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1324
1445
|
|
|
1325
1446
|
while (stack.length > 0) {
|
|
1326
1447
|
const node = stack.pop();
|
|
1327
|
-
const nodeType =
|
|
1448
|
+
const nodeType = _readNodeType(node);
|
|
1328
1449
|
|
|
1329
1450
|
if (nodeType === NODE_TYPE.element) {
|
|
1330
1451
|
_stripDisallowedAttributes(node as Element);
|
|
@@ -1374,6 +1495,32 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1374
1495
|
*
|
|
1375
1496
|
* @param root the in-place root to sweep
|
|
1376
1497
|
*/
|
|
1498
|
+
/**
|
|
1499
|
+
* Central policy for declarative-partial-updates patch-linkage attributes,
|
|
1500
|
+
* shared by the _neutralizePatchLinkage pre-pass and _isValidAttribute so
|
|
1501
|
+
* the two sites cannot drift: `patchsrc` always links, `for` links
|
|
1502
|
+
* everywhere except on <label>/<output>, and the whole policy is gated on
|
|
1503
|
+
* SAFE_FOR_XML (see the rationale block in _isValidAttribute).
|
|
1504
|
+
*
|
|
1505
|
+
* @param lcName the transformCaseFunc'd attribute name
|
|
1506
|
+
* @param lcTag the transformCaseFunc'd tag name of the carrying element
|
|
1507
|
+
* @return true if the attribute is patch linkage and must be dropped
|
|
1508
|
+
*/
|
|
1509
|
+
const _isPatchLinkageAttribute = function (
|
|
1510
|
+
lcName: string,
|
|
1511
|
+
lcTag: string
|
|
1512
|
+
): boolean {
|
|
1513
|
+
if (!SAFE_FOR_XML) {
|
|
1514
|
+
return false;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
if (lcName === 'patchsrc') {
|
|
1518
|
+
return true;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
return lcName === 'for' && lcTag !== 'label' && lcTag !== 'output';
|
|
1522
|
+
};
|
|
1523
|
+
|
|
1377
1524
|
const _neutralizePatchLinkage = function (root: Node): void {
|
|
1378
1525
|
if (!SAFE_FOR_XML) {
|
|
1379
1526
|
return;
|
|
@@ -1382,7 +1529,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1382
1529
|
const stack: Node[] = [root];
|
|
1383
1530
|
while (stack.length > 0) {
|
|
1384
1531
|
const node = stack.pop();
|
|
1385
|
-
const nodeType =
|
|
1532
|
+
const nodeType = _readNodeType(node);
|
|
1386
1533
|
|
|
1387
1534
|
/* Remove range markers (the target side of a patch linkage): every
|
|
1388
1535
|
processing instruction, and any markup-bearing comment. */
|
|
@@ -1403,9 +1550,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1403
1550
|
/* Strip patch-source attributes (the source side) off elements. */
|
|
1404
1551
|
if (nodeType === NODE_TYPE.element) {
|
|
1405
1552
|
const element = node as Element;
|
|
1406
|
-
const lcTag = transformCaseFunc(
|
|
1407
|
-
getNodeName ? getNodeName(node) : (node as any).nodeName
|
|
1408
|
-
);
|
|
1553
|
+
const lcTag = transformCaseFunc(_readNodeName(node));
|
|
1409
1554
|
try {
|
|
1410
1555
|
if (element.hasAttribute && element.hasAttribute('patchsrc')) {
|
|
1411
1556
|
element.removeAttribute('patchsrc');
|
|
@@ -1414,8 +1559,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1414
1559
|
if (
|
|
1415
1560
|
element.hasAttribute &&
|
|
1416
1561
|
element.hasAttribute('for') &&
|
|
1417
|
-
|
|
1418
|
-
lcTag !== 'output'
|
|
1562
|
+
_isPatchLinkageAttribute('for', lcTag)
|
|
1419
1563
|
) {
|
|
1420
1564
|
element.removeAttribute('for');
|
|
1421
1565
|
}
|
|
@@ -1642,6 +1786,17 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1642
1786
|
// canonical NamedNodeMap.
|
|
1643
1787
|
element.attributes !== getAttributes(element) ||
|
|
1644
1788
|
typeof element.removeAttribute !== 'function' ||
|
|
1789
|
+
// A form descendant named "removeAttributeNode" or "getAttributeNode"
|
|
1790
|
+
// shadows these Attr-node methods via [LegacyOverrideBuiltIns].
|
|
1791
|
+
// _removeAttribute() / _stripAttributeNode() reach for
|
|
1792
|
+
// element.removeAttributeNode(attr) first; when it is shadowed the call
|
|
1793
|
+
// throws and the name-based fallback element.removeAttribute(name)
|
|
1794
|
+
// ASCII-lowercases its lookup key in an HTML document, silently missing
|
|
1795
|
+
// a case-preserved event-handler attribute (e.g. an ONANIMATIONSTART
|
|
1796
|
+
// that reached the sanitizer through an XML/XHTML parse). Flag the form
|
|
1797
|
+
// so it is removed wholesale, exactly as for the other shadowed methods.
|
|
1798
|
+
typeof element.removeAttributeNode !== 'function' ||
|
|
1799
|
+
typeof element.getAttributeNode !== 'function' ||
|
|
1645
1800
|
typeof element.setAttribute !== 'function' ||
|
|
1646
1801
|
typeof element.namespaceURI !== 'string' ||
|
|
1647
1802
|
typeof element.insertBefore !== 'function' ||
|
|
@@ -1751,12 +1906,22 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1751
1906
|
return true;
|
|
1752
1907
|
}
|
|
1753
1908
|
|
|
1754
|
-
/* Remove
|
|
1909
|
+
/* Remove rawtext/literal-text elements whose literal serialization
|
|
1910
|
+
re-opens markup on an HTML reparse - shapes (a) and (b) documented at
|
|
1911
|
+
LITERAL_TEXT_ELEMENTS. Both are invisible to rule 1 above (which
|
|
1912
|
+
self-disables once there is an element child, and whose second probe
|
|
1913
|
+
reads the innerHTML an XML/XHTML document serializes escaped), which
|
|
1914
|
+
is why both probes here read textContent instead. Previously only
|
|
1915
|
+
`style`-with-element-child was covered; every element in
|
|
1916
|
+
LITERAL_TEXT_ELEMENTS shares this literal serialization and is
|
|
1917
|
+
equally affected. */
|
|
1755
1918
|
if (
|
|
1756
1919
|
SAFE_FOR_XML &&
|
|
1757
1920
|
currentNode.namespaceURI === HTML_NAMESPACE &&
|
|
1758
|
-
tagName
|
|
1759
|
-
_isNode(currentNode.firstElementChild)
|
|
1921
|
+
LITERAL_TEXT_ELEMENTS[tagName] &&
|
|
1922
|
+
(_isNode(currentNode.firstElementChild) ||
|
|
1923
|
+
(typeof currentNode.textContent === 'string' &&
|
|
1924
|
+
regExpTest(LITERAL_TEXT_CLOSE[tagName], currentNode.textContent)))
|
|
1760
1925
|
) {
|
|
1761
1926
|
return true;
|
|
1762
1927
|
}
|
|
@@ -1778,6 +1943,34 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1778
1943
|
return false;
|
|
1779
1944
|
};
|
|
1780
1945
|
|
|
1946
|
+
/**
|
|
1947
|
+
* Evaluate a CUSTOM_ELEMENT_HANDLING check (a RegExp or a predicate
|
|
1948
|
+
* function, per the validation in _parseConfig) against a name.
|
|
1949
|
+
* Additional arguments are forwarded to predicate functions - the
|
|
1950
|
+
* attributeNameCheck predicate receives the tag name as its second
|
|
1951
|
+
* argument. A null/absent check never matches.
|
|
1952
|
+
*
|
|
1953
|
+
* @param check the configured tagNameCheck / attributeNameCheck value
|
|
1954
|
+
* @param name the name to test
|
|
1955
|
+
* @param args extra arguments forwarded to a predicate function
|
|
1956
|
+
* @return true if the check matches the name
|
|
1957
|
+
*/
|
|
1958
|
+
const _matchesNameCheck = function (
|
|
1959
|
+
check: unknown,
|
|
1960
|
+
name: string,
|
|
1961
|
+
...args: unknown[]
|
|
1962
|
+
): boolean {
|
|
1963
|
+
if (check instanceof RegExp) {
|
|
1964
|
+
return regExpTest(check, name);
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
if (check instanceof Function) {
|
|
1968
|
+
return Boolean(check(name, ...args));
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
return false;
|
|
1972
|
+
};
|
|
1973
|
+
|
|
1781
1974
|
/**
|
|
1782
1975
|
* Handle a node whose tag is forbidden or not allowlisted: keep
|
|
1783
1976
|
* allowed custom elements (false return exits _sanitizeElements
|
|
@@ -1801,20 +1994,12 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1801
1994
|
root: Node
|
|
1802
1995
|
): boolean {
|
|
1803
1996
|
/* Check if we have a custom element to handle */
|
|
1804
|
-
if (
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
}
|
|
1811
|
-
|
|
1812
|
-
if (
|
|
1813
|
-
CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function &&
|
|
1814
|
-
CUSTOM_ELEMENT_HANDLING.tagNameCheck(tagName)
|
|
1815
|
-
) {
|
|
1816
|
-
return false;
|
|
1817
|
-
}
|
|
1997
|
+
if (
|
|
1998
|
+
!FORBID_TAGS[tagName] &&
|
|
1999
|
+
_isBasicCustomElement(tagName) &&
|
|
2000
|
+
_matchesNameCheck(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)
|
|
2001
|
+
) {
|
|
2002
|
+
return false;
|
|
1818
2003
|
}
|
|
1819
2004
|
|
|
1820
2005
|
/* Keep content except for bad-listed elements.
|
|
@@ -1893,6 +2078,52 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1893
2078
|
return set === defaultSet || set === setConfigSet ? clone(set) : set;
|
|
1894
2079
|
};
|
|
1895
2080
|
|
|
2081
|
+
/**
|
|
2082
|
+
* Shared guard for a node that a hook has detached from the walk tree,
|
|
2083
|
+
* used after each element-hook site in _sanitizeElements. Detaching is a
|
|
2084
|
+
* long-standing user pattern (issue #469; draw.io-style foreignObject
|
|
2085
|
+
* filtering). Per the cached, unclobberable parentNode getter the node is
|
|
2086
|
+
* genuinely out of the tree, so it can reach neither the serialized
|
|
2087
|
+
* output nor an IN_PLACE live tree; treat it as removed and stop
|
|
2088
|
+
* processing it. Without this guard, the unsafe-node / namespace checks
|
|
2089
|
+
* would call _forceRemove on a parentless node and hit the REPORT-3
|
|
2090
|
+
* fail-closed throw — which exists for nodes DOMPurify wants gone but
|
|
2091
|
+
* *cannot* detach (clobbered / parentless roots), the opposite of a node
|
|
2092
|
+
* that is already safely gone. The walk root is exempt: a detached
|
|
2093
|
+
* IN_PLACE root is legitimate input and must still be fully sanitized,
|
|
2094
|
+
* and a kill-decision on it must keep hitting the REPORT-3 throw.
|
|
2095
|
+
*
|
|
2096
|
+
* Nodes detached by hooks stay the hook's responsibility for placement:
|
|
2097
|
+
* they are not recorded in DOMPurify.removed, so the post-walk IN_PLACE
|
|
2098
|
+
* pass (which iterates DOMPurify.removed) does not reach them. But a
|
|
2099
|
+
* hook-detached subtree can still hold a queued resource-event handler -
|
|
2100
|
+
* e.g. an <img onload> that began loading when the caller built the live
|
|
2101
|
+
* tree - which fires in page scope after sanitize returns even though the
|
|
2102
|
+
* handler never reached the returned tree. That is the audit-5 F1 hazard,
|
|
2103
|
+
* and the documented node.remove() hook pattern walks straight into it.
|
|
2104
|
+
* So on the IN_PLACE path we neutralize the detached subtree inline,
|
|
2105
|
+
* stripping its non-allow-listed attributes before returning, exactly as
|
|
2106
|
+
* the post-walk pass does for _forceRemove'd subtrees.
|
|
2107
|
+
*
|
|
2108
|
+
* @param currentNode the node a hook may have detached
|
|
2109
|
+
* @param root the current walk root
|
|
2110
|
+
* @return true if the node is detached and now handled, false otherwise
|
|
2111
|
+
*/
|
|
2112
|
+
const _handleHookDetachedNode = function (
|
|
2113
|
+
currentNode: Node,
|
|
2114
|
+
root: Node
|
|
2115
|
+
): boolean {
|
|
2116
|
+
if (currentNode === root || getParentNode(currentNode) !== null) {
|
|
2117
|
+
return false;
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
if (IN_PLACE) {
|
|
2121
|
+
_neutralizeSubtree(currentNode);
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
return true;
|
|
2125
|
+
};
|
|
2126
|
+
|
|
1896
2127
|
/**
|
|
1897
2128
|
* _sanitizeElements
|
|
1898
2129
|
*
|
|
@@ -1902,20 +2133,13 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1902
2133
|
* @param currentNode to check for permission to exist
|
|
1903
2134
|
* @return true if node was killed, false if left alive
|
|
1904
2135
|
*/
|
|
1905
|
-
// eslint-disable-next-line complexity
|
|
1906
2136
|
const _sanitizeElements = function (currentNode: any, root: Node): boolean {
|
|
1907
2137
|
/* Execute a hook if present */
|
|
1908
2138
|
_executeHooks(hooks.beforeSanitizeElements, currentNode, null);
|
|
1909
2139
|
|
|
1910
|
-
/* A hook may have detached the node - treat it as removed (see
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
resource handler on it cannot fire in page scope after we return. */
|
|
1914
|
-
if (currentNode !== root && getParentNode(currentNode) === null) {
|
|
1915
|
-
if (IN_PLACE) {
|
|
1916
|
-
_neutralizeSubtree(currentNode);
|
|
1917
|
-
}
|
|
1918
|
-
|
|
2140
|
+
/* A hook may have detached the node - treat it as removed (see
|
|
2141
|
+
_handleHookDetachedNode for the full rationale). */
|
|
2142
|
+
if (_handleHookDetachedNode(currentNode, root)) {
|
|
1919
2143
|
return true;
|
|
1920
2144
|
}
|
|
1921
2145
|
|
|
@@ -1926,9 +2150,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1926
2150
|
}
|
|
1927
2151
|
|
|
1928
2152
|
/* Now let's check the element's type and name */
|
|
1929
|
-
const tagName = transformCaseFunc(
|
|
1930
|
-
getNodeName ? getNodeName(currentNode) : currentNode.nodeName
|
|
1931
|
-
);
|
|
2153
|
+
const tagName = transformCaseFunc(_readNodeName(currentNode));
|
|
1932
2154
|
|
|
1933
2155
|
/* Close the pre-walk clone-guard's timing gap: an uponSanitizeElement
|
|
1934
2156
|
hook may have been installed after that guard sampled the hook arrays
|
|
@@ -1948,35 +2170,9 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1948
2170
|
allowedTags: ALLOWED_TAGS,
|
|
1949
2171
|
});
|
|
1950
2172
|
|
|
1951
|
-
/*
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
genuinely out of the tree, so it can reach neither the serialized
|
|
1955
|
-
output nor an IN_PLACE live tree; treat it as removed and stop
|
|
1956
|
-
processing it. Without this guard, the unsafe-node / namespace
|
|
1957
|
-
checks below would call _forceRemove on a parentless node and hit
|
|
1958
|
-
the REPORT-3 fail-closed throw — which exists for nodes DOMPurify
|
|
1959
|
-
wants gone but *cannot* detach (clobbered / parentless roots), the
|
|
1960
|
-
opposite of a node that is already safely gone. The walk root is
|
|
1961
|
-
exempt: a detached IN_PLACE root is legitimate input and must still
|
|
1962
|
-
be fully sanitized, and a kill-decision on it must keep hitting the
|
|
1963
|
-
REPORT-3 throw. Nodes detached by hooks stay the hook's
|
|
1964
|
-
responsibility for placement: they are not recorded in
|
|
1965
|
-
DOMPurify.removed, so the post-walk IN_PLACE pass (which iterates
|
|
1966
|
-
DOMPurify.removed) does not reach them. But a hook-detached subtree
|
|
1967
|
-
can still hold a queued resource-event handler - e.g. an <img onload>
|
|
1968
|
-
that began loading when the caller built the live tree - which fires
|
|
1969
|
-
in page scope after sanitize returns even though the handler never
|
|
1970
|
-
reached the returned tree. That is the audit-5 F1 hazard, and the
|
|
1971
|
-
documented node.remove() hook pattern walks straight into it. So on
|
|
1972
|
-
the IN_PLACE path we neutralize the detached subtree inline here,
|
|
1973
|
-
stripping its non-allow-listed attributes before returning, exactly
|
|
1974
|
-
as the post-walk pass does for _forceRemove'd subtrees. */
|
|
1975
|
-
if (currentNode !== root && getParentNode(currentNode) === null) {
|
|
1976
|
-
if (IN_PLACE) {
|
|
1977
|
-
_neutralizeSubtree(currentNode);
|
|
1978
|
-
}
|
|
1979
|
-
|
|
2173
|
+
/* The uponSanitizeElement hook may have detached the node, exactly as
|
|
2174
|
+
above (see _handleHookDetachedNode for the full rationale). */
|
|
2175
|
+
if (_handleHookDetachedNode(currentNode, root)) {
|
|
1980
2176
|
return true;
|
|
1981
2177
|
}
|
|
1982
2178
|
|
|
@@ -2021,7 +2217,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2021
2217
|
bound and short-circuits to false for any node minted in a different
|
|
2022
2218
|
realm — letting a foreign-realm element with a forbidden namespace
|
|
2023
2219
|
slip past the namespace check entirely. */
|
|
2024
|
-
const nt =
|
|
2220
|
+
const nt = _readNodeType(currentNode);
|
|
2025
2221
|
if (nt === NODE_TYPE.element && !_checkValidNamespace(currentNode)) {
|
|
2026
2222
|
_forceRemove(currentNode);
|
|
2027
2223
|
return true;
|
|
@@ -2097,16 +2293,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2097
2293
|
other structural-threat checks and stays overridable, consistent with
|
|
2098
2294
|
the rest of the codebase. PI range markers are already removed by
|
|
2099
2295
|
_isUnsafeNode. */
|
|
2100
|
-
if (
|
|
2101
|
-
return false;
|
|
2102
|
-
}
|
|
2103
|
-
|
|
2104
|
-
if (
|
|
2105
|
-
SAFE_FOR_XML &&
|
|
2106
|
-
lcName === 'for' &&
|
|
2107
|
-
lcTag !== 'label' &&
|
|
2108
|
-
lcTag !== 'output'
|
|
2109
|
-
) {
|
|
2296
|
+
if (_isPatchLinkageAttribute(lcName, lcTag)) {
|
|
2110
2297
|
return false;
|
|
2111
2298
|
}
|
|
2112
2299
|
|
|
@@ -2129,73 +2316,76 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2129
2316
|
XML-compatible (https://html.spec.whatwg.org/multipage/infrastructure.html#xml-compatible and http://www.w3.org/TR/xml/#d0e804)
|
|
2130
2317
|
We don't need to check the value; it's always URI safe. */
|
|
2131
2318
|
if (ALLOW_DATA_ATTR && regExpTest(DATA_ATTR, lcName)) {
|
|
2132
|
-
|
|
2133
|
-
}
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2319
|
+
return true;
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
/* Allow valid aria-* attributes, the value is always URI safe */
|
|
2323
|
+
if (ALLOW_ARIA_ATTR && regExpTest(ARIA_ATTR, lcName)) {
|
|
2324
|
+
return true;
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
/* A name outside the allowlist is acceptable on custom-element terms
|
|
2328
|
+
only. The value checks below are intentionally skipped in that case:
|
|
2329
|
+
if the user supplied a tagNameCheck we also allow derived custom
|
|
2330
|
+
elements using the same test, and attributes passing the configured
|
|
2331
|
+
attributeNameCheck are allowed as custom elements define these at
|
|
2332
|
+
their own discretion. */
|
|
2333
|
+
if (!nameIsPermitted) {
|
|
2334
|
+
return (
|
|
2335
|
+
// Condition a) covers a basically valid custom element tag name whose
|
|
2336
|
+
// tag passes the configured tagNameCheck and whose attribute name
|
|
2337
|
+
// passes the configured attributeNameCheck ...
|
|
2141
2338
|
(_isBasicCustomElement(lcTag) &&
|
|
2142
|
-
(
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
//
|
|
2151
|
-
// the value passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck
|
|
2339
|
+
_matchesNameCheck(CUSTOM_ELEMENT_HANDLING.tagNameCheck, lcTag) &&
|
|
2340
|
+
_matchesNameCheck(
|
|
2341
|
+
CUSTOM_ELEMENT_HANDLING.attributeNameCheck,
|
|
2342
|
+
lcName,
|
|
2343
|
+
lcTag
|
|
2344
|
+
)) ||
|
|
2345
|
+
// Condition b) covers an `is` attribute whose value passes the
|
|
2346
|
+
// configured tagNameCheck while customized built-in elements are
|
|
2347
|
+
// allowed.
|
|
2152
2348
|
(lcName === 'is' &&
|
|
2153
2349
|
CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements &&
|
|
2154
|
-
(
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
/* Check value is safe. First, is attr inert? If so, is safe */
|
|
2165
|
-
} else if (URI_SAFE_ATTRIBUTES[lcName]) {
|
|
2166
|
-
// This attribute is safe
|
|
2167
|
-
/* Check no script, data or unknown possibly unsafe URI
|
|
2350
|
+
_matchesNameCheck(CUSTOM_ELEMENT_HANDLING.tagNameCheck, value))
|
|
2351
|
+
);
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
/* Check value is safe. First, is attr inert? If so, is safe */
|
|
2355
|
+
if (URI_SAFE_ATTRIBUTES[lcName]) {
|
|
2356
|
+
return true;
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
/* Check no script, data or unknown possibly unsafe URI
|
|
2168
2360
|
unless we know URI values are safe for that attribute */
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2361
|
+
if (regExpTest(IS_ALLOWED_URI, stringReplace(value, ATTR_WHITESPACE, ''))) {
|
|
2362
|
+
return true;
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
/* Keep image data URIs alive if src/xlink:href is allowed */
|
|
2366
|
+
/* Further prevent gadget XSS for dynamically built script tags */
|
|
2367
|
+
if (
|
|
2176
2368
|
(lcName === 'src' || lcName === 'xlink:href' || lcName === 'href') &&
|
|
2177
2369
|
lcTag !== 'script' &&
|
|
2178
2370
|
stringIndexOf(value, 'data:') === 0 &&
|
|
2179
2371
|
DATA_URI_TAGS[lcTag]
|
|
2180
2372
|
) {
|
|
2181
|
-
|
|
2182
|
-
|
|
2373
|
+
return true;
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
/* Allow unknown protocols: This provides support for links that
|
|
2183
2377
|
are handled by protocol handlers which may be unknown ahead of
|
|
2184
2378
|
time, e.g. fb:, spotify: */
|
|
2185
|
-
|
|
2379
|
+
if (
|
|
2186
2380
|
ALLOW_UNKNOWN_PROTOCOLS &&
|
|
2187
2381
|
!regExpTest(IS_SCRIPT_OR_DATA, stringReplace(value, ATTR_WHITESPACE, ''))
|
|
2188
2382
|
) {
|
|
2189
|
-
|
|
2190
|
-
/* Check for binary attributes */
|
|
2191
|
-
} else if (value) {
|
|
2192
|
-
return false;
|
|
2193
|
-
} else {
|
|
2194
|
-
// Binary attributes are safe at this point
|
|
2195
|
-
/* Anything else, presume unsafe, do not add it back */
|
|
2383
|
+
return true;
|
|
2196
2384
|
}
|
|
2197
2385
|
|
|
2198
|
-
|
|
2386
|
+
/* Only an empty (binary) value remains safe at this point;
|
|
2387
|
+
anything else is presumed unsafe, do not add it back */
|
|
2388
|
+
return !value;
|
|
2199
2389
|
};
|
|
2200
2390
|
|
|
2201
2391
|
/* Names the HTML spec reserves from valid-custom-element-name; these must
|
|
@@ -2272,22 +2462,32 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2272
2462
|
/**
|
|
2273
2463
|
* Write a modified attribute value back onto the element. On
|
|
2274
2464
|
* success, re-probe for clobbering introduced by the new value and
|
|
2275
|
-
* remove the element when found; otherwise
|
|
2276
|
-
*
|
|
2277
|
-
*
|
|
2465
|
+
* remove the element when found; otherwise, when this writeback is the
|
|
2466
|
+
* recreate half of the SANITIZE_NAMED_PROPS remove-and-recreate, pop the
|
|
2467
|
+
* removal entry that path recorded so it does not show as removed. On
|
|
2278
2468
|
* failure, remove the attribute instead.
|
|
2279
2469
|
*
|
|
2470
|
+
* Returns true only on a clean write (the value was set and the new value
|
|
2471
|
+
* introduced no clobbering). The caller uses that, together with its own
|
|
2472
|
+
* knowledge of whether this attribute pushed a DOMPurify.removed record, to
|
|
2473
|
+
* decide whether to pop that record. The pop must happen ONLY for the
|
|
2474
|
+
* named-prop remove-and-recreate; popping on any other value change (trim,
|
|
2475
|
+
* template scrubbing, Trusted Types) would consume an unrelated _forceRemove
|
|
2476
|
+
* subtree-cleanup record and let that detached subtree keep a live event
|
|
2477
|
+
* handler through the IN_PLACE neutralization pass (SO-001).
|
|
2478
|
+
*
|
|
2280
2479
|
* @param currentNode the element carrying the attribute
|
|
2281
2480
|
* @param name the attribute name as present on the element
|
|
2282
2481
|
* @param namespaceURI the attribute's namespace, if any
|
|
2283
2482
|
* @param value the new attribute value
|
|
2483
|
+
* @return true if the value was written without introducing clobbering
|
|
2284
2484
|
*/
|
|
2285
2485
|
const _setAttributeValue = function (
|
|
2286
2486
|
currentNode: Element,
|
|
2287
2487
|
name: string,
|
|
2288
2488
|
namespaceURI: string | null,
|
|
2289
2489
|
value: string
|
|
2290
|
-
):
|
|
2490
|
+
): boolean {
|
|
2291
2491
|
try {
|
|
2292
2492
|
if (namespaceURI) {
|
|
2293
2493
|
currentNode.setAttributeNS(namespaceURI, name, value);
|
|
@@ -2298,11 +2498,13 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2298
2498
|
|
|
2299
2499
|
if (_isClobbered(currentNode)) {
|
|
2300
2500
|
_forceRemove(currentNode);
|
|
2301
|
-
|
|
2302
|
-
arrayPop(DOMPurify.removed);
|
|
2501
|
+
return false;
|
|
2303
2502
|
}
|
|
2503
|
+
|
|
2504
|
+
return true;
|
|
2304
2505
|
} catch (_) {
|
|
2305
2506
|
_removeAttribute(name, currentNode);
|
|
2507
|
+
return false;
|
|
2306
2508
|
}
|
|
2307
2509
|
};
|
|
2308
2510
|
|
|
@@ -2316,6 +2518,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2316
2518
|
*
|
|
2317
2519
|
* @param currentNode to sanitize
|
|
2318
2520
|
*/
|
|
2521
|
+
// eslint-disable-next-line complexity
|
|
2319
2522
|
const _sanitizeAttributes = function (currentNode: Element): void {
|
|
2320
2523
|
/* Execute a hook if present */
|
|
2321
2524
|
_executeHooks(hooks.beforeSanitizeAttributes, currentNode, null);
|
|
@@ -2355,6 +2558,16 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2355
2558
|
const initValue = attrValue;
|
|
2356
2559
|
let value = name === 'value' ? initValue : stringTrim(initValue);
|
|
2357
2560
|
|
|
2561
|
+
/* Tracks whether this specific attribute pushed a DOMPurify.removed
|
|
2562
|
+
record that a later _setAttributeValue() writeback is expected to pop
|
|
2563
|
+
back off (the SANITIZE_NAMED_PROPS remove-and-recreate below is the
|
|
2564
|
+
only path that does). A value that merely changed via trim, template
|
|
2565
|
+
scrubbing, or Trusted Types pushes no such record, so its writeback
|
|
2566
|
+
must NOT pop — otherwise it consumes an unrelated _forceRemove subtree
|
|
2567
|
+
record and that detached subtree escapes the IN_PLACE handler-
|
|
2568
|
+
neutralization pass (SO-001). */
|
|
2569
|
+
let recreatedNamedProp = false;
|
|
2570
|
+
|
|
2358
2571
|
/* Execute a hook if present */
|
|
2359
2572
|
hookEvent.attrName = lcName;
|
|
2360
2573
|
hookEvent.attrValue = value;
|
|
@@ -2372,9 +2585,12 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2372
2585
|
stringIndexOf(value, SANITIZE_NAMED_PROPS_PREFIX) !== 0
|
|
2373
2586
|
) {
|
|
2374
2587
|
// Remove the attribute with this value
|
|
2375
|
-
_removeAttribute(name, currentNode);
|
|
2588
|
+
_removeAttribute(name, currentNode, attr);
|
|
2376
2589
|
// Prefix the value and later re-create the attribute with the sanitized value
|
|
2377
2590
|
value = SANITIZE_NAMED_PROPS_PREFIX + value;
|
|
2591
|
+
// This attribute owns the record just pushed; its recreate writeback
|
|
2592
|
+
// is the one allowed to pop it back off.
|
|
2593
|
+
recreatedNamedProp = true;
|
|
2378
2594
|
}
|
|
2379
2595
|
// Else: already prefixed, leave the attribute alone — the prefix is
|
|
2380
2596
|
// itself the clobbering protection, and re-applying it is incorrect.
|
|
@@ -2387,13 +2603,13 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2387
2603
|
value
|
|
2388
2604
|
)
|
|
2389
2605
|
) {
|
|
2390
|
-
_removeAttribute(name, currentNode);
|
|
2606
|
+
_removeAttribute(name, currentNode, attr);
|
|
2391
2607
|
continue;
|
|
2392
2608
|
}
|
|
2393
2609
|
|
|
2394
2610
|
/* Make sure we cannot easily use animated hrefs, even if animations are allowed */
|
|
2395
2611
|
if (lcName === 'attributename' && stringMatch(value, 'href')) {
|
|
2396
|
-
_removeAttribute(name, currentNode);
|
|
2612
|
+
_removeAttribute(name, currentNode, attr);
|
|
2397
2613
|
continue;
|
|
2398
2614
|
}
|
|
2399
2615
|
|
|
@@ -2404,7 +2620,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2404
2620
|
|
|
2405
2621
|
/* Did the hooks approve of the attribute? */
|
|
2406
2622
|
if (!hookEvent.keepAttr) {
|
|
2407
|
-
_removeAttribute(name, currentNode);
|
|
2623
|
+
_removeAttribute(name, currentNode, attr);
|
|
2408
2624
|
continue;
|
|
2409
2625
|
}
|
|
2410
2626
|
|
|
@@ -2413,7 +2629,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2413
2629
|
!ALLOW_SELF_CLOSE_IN_ATTR &&
|
|
2414
2630
|
regExpTest(EXPRESSIONS.SELF_CLOSING_TAG, value)
|
|
2415
2631
|
) {
|
|
2416
|
-
_removeAttribute(name, currentNode);
|
|
2632
|
+
_removeAttribute(name, currentNode, attr);
|
|
2417
2633
|
continue;
|
|
2418
2634
|
}
|
|
2419
2635
|
|
|
@@ -2424,7 +2640,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2424
2640
|
|
|
2425
2641
|
/* Is `value` valid for this attribute? */
|
|
2426
2642
|
if (!_isValidAttribute(lcTag, lcName, value)) {
|
|
2427
|
-
_removeAttribute(name, currentNode);
|
|
2643
|
+
_removeAttribute(name, currentNode, attr);
|
|
2428
2644
|
continue;
|
|
2429
2645
|
}
|
|
2430
2646
|
|
|
@@ -2433,7 +2649,20 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2433
2649
|
|
|
2434
2650
|
/* Handle invalid data-* attribute set by try-catching it */
|
|
2435
2651
|
if (value !== initValue) {
|
|
2436
|
-
|
|
2652
|
+
const cleanWrite = _setAttributeValue(
|
|
2653
|
+
currentNode,
|
|
2654
|
+
name,
|
|
2655
|
+
namespaceURI,
|
|
2656
|
+
value
|
|
2657
|
+
);
|
|
2658
|
+
|
|
2659
|
+
/* Only the named-prop remove-and-recreate owns a DOMPurify.removed
|
|
2660
|
+
record to pop; a plain trim/template/Trusted-Types writeback owns
|
|
2661
|
+
none, so popping here would drop an unrelated subtree's cleanup
|
|
2662
|
+
record (SO-001). */
|
|
2663
|
+
if (cleanWrite && recreatedNamedProp) {
|
|
2664
|
+
arrayPop(DOMPurify.removed);
|
|
2665
|
+
}
|
|
2437
2666
|
}
|
|
2438
2667
|
}
|
|
2439
2668
|
|
|
@@ -2481,10 +2710,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2481
2710
|
Walk it explicitly. The nodeType guard avoids reading
|
|
2482
2711
|
shadowRoot off text / comment / CDATA / PI nodes that the
|
|
2483
2712
|
iterator also surfaces. */
|
|
2484
|
-
|
|
2485
|
-
? getNodeType(shadowNode)
|
|
2486
|
-
: shadowNode.nodeType;
|
|
2487
|
-
if (shadowNodeType === NODE_TYPE.element) {
|
|
2713
|
+
if (_readNodeType(shadowNode) === NODE_TYPE.element) {
|
|
2488
2714
|
const innerSr = getShadowRoot(shadowNode);
|
|
2489
2715
|
if (_isDocumentFragment(innerSr)) {
|
|
2490
2716
|
_sanitizeAttachedShadowRoots(innerSr);
|
|
@@ -2546,7 +2772,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2546
2772
|
}
|
|
2547
2773
|
|
|
2548
2774
|
const node = item.node;
|
|
2549
|
-
const nodeType =
|
|
2775
|
+
const nodeType = _readNodeType(node);
|
|
2550
2776
|
const isElement = nodeType === NODE_TYPE.element;
|
|
2551
2777
|
|
|
2552
2778
|
/* (pushed last → processed first) Children, snapshotted in reverse so
|
|
@@ -2675,9 +2901,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2675
2901
|
child named "nodeName" on the form root would otherwise shadow
|
|
2676
2902
|
the property and let this check skip the root-allowlist
|
|
2677
2903
|
validation entirely. */
|
|
2678
|
-
const nn =
|
|
2679
|
-
? getNodeName(dirty as Node)
|
|
2680
|
-
: (dirty as Node).nodeName;
|
|
2904
|
+
const nn = _readNodeName(dirty as Node);
|
|
2681
2905
|
if (typeof nn === 'string') {
|
|
2682
2906
|
const tagName = transformCaseFunc(nn);
|
|
2683
2907
|
if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) {
|
|
@@ -2746,8 +2970,16 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2746
2970
|
them before the main iterator runs, since the iterator does not
|
|
2747
2971
|
descend into shadow trees. The walk routes every read through a
|
|
2748
2972
|
cached prototype getter so clobbering descendants on a form root
|
|
2749
|
-
cannot hide a shadow host from this pass.
|
|
2750
|
-
|
|
2973
|
+
cannot hide a shadow host from this pass.
|
|
2974
|
+
|
|
2975
|
+
Traverse from `body`, not `importedNode`: when importedNode is a
|
|
2976
|
+
DocumentFragment, the appendChild() above moves its children into
|
|
2977
|
+
`body` and leaves the fragment empty, so walking importedNode would
|
|
2978
|
+
scan nothing and miss every attached shadow host now under `body`
|
|
2979
|
+
(SO-003). In the BODY/HTML branches `body === importedNode`, so this
|
|
2980
|
+
is equivalent there; in the element branch `body` contains the
|
|
2981
|
+
appended element and its descendants. `body` covers all three. */
|
|
2982
|
+
_sanitizeAttachedShadowRoots(body);
|
|
2751
2983
|
} else {
|
|
2752
2984
|
/* Exit directly if we have nothing to do */
|
|
2753
2985
|
if (
|