dompurify 3.4.12 → 3.4.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -2
- package/dist/purify.cjs.d.ts +1 -1
- package/dist/purify.cjs.js +364 -126
- package/dist/purify.cjs.js.map +1 -1
- package/dist/purify.es.d.mts +1 -1
- package/dist/purify.es.mjs +364 -126
- package/dist/purify.es.mjs.map +1 -1
- package/dist/purify.js +364 -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 +459 -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
|
|
|
@@ -213,6 +277,22 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
213
277
|
Node && Node.prototype ? lookupGetter(Node.prototype, 'nodeType') : null;
|
|
214
278
|
const getNodeName =
|
|
215
279
|
Node && Node.prototype ? lookupGetter(Node.prototype, 'nodeName') : null;
|
|
280
|
+
const getOwnerDocument =
|
|
281
|
+
Node && Node.prototype
|
|
282
|
+
? lookupGetter(Node.prototype, 'ownerDocument')
|
|
283
|
+
: null;
|
|
284
|
+
|
|
285
|
+
/* Clobber-safe nodeType / nodeName reads through the cached Node.prototype
|
|
286
|
+
getters, with a direct-property fallback for environments that lack
|
|
287
|
+
Node.prototype. Sites that need a different fallback (e.g. _isClobbered
|
|
288
|
+
returns early on a null name) intentionally keep their own reads. */
|
|
289
|
+
const _readNodeType = function (node: Node): number {
|
|
290
|
+
return getNodeType ? getNodeType(node) : (node as any).nodeType;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const _readNodeName = function (node: Node): string {
|
|
294
|
+
return getNodeName ? getNodeName(node) : (node as any).nodeName;
|
|
295
|
+
};
|
|
216
296
|
|
|
217
297
|
// As per issue #47, the web-components registry is inherited by a
|
|
218
298
|
// new document created via createHTMLDocument. As per the spec
|
|
@@ -724,26 +804,23 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
724
804
|
NAMESPACE =
|
|
725
805
|
typeof cfg.NAMESPACE === 'string' ? cfg.NAMESPACE : HTML_NAMESPACE; // Default HTML namespace
|
|
726
806
|
|
|
727
|
-
MATHML_TEXT_INTEGRATION_POINTS =
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
typeof cfg.CUSTOM_ELEMENT_HANDLING === 'object'
|
|
745
|
-
? clone(cfg.CUSTOM_ELEMENT_HANDLING)
|
|
746
|
-
: create(null);
|
|
807
|
+
MATHML_TEXT_INTEGRATION_POINTS = _resolveObjectOption(
|
|
808
|
+
cfg,
|
|
809
|
+
'MATHML_TEXT_INTEGRATION_POINTS',
|
|
810
|
+
() => addToSet({}, DEFAULT_MATHML_TEXT_INTEGRATION_POINTS) // Default built-in map
|
|
811
|
+
);
|
|
812
|
+
|
|
813
|
+
HTML_INTEGRATION_POINTS = _resolveObjectOption(
|
|
814
|
+
cfg,
|
|
815
|
+
'HTML_INTEGRATION_POINTS',
|
|
816
|
+
() => addToSet({}, DEFAULT_HTML_INTEGRATION_POINTS) // Default built-in map
|
|
817
|
+
);
|
|
818
|
+
|
|
819
|
+
const customElementHandling = _resolveObjectOption(
|
|
820
|
+
cfg,
|
|
821
|
+
'CUSTOM_ELEMENT_HANDLING',
|
|
822
|
+
() => create(null)
|
|
823
|
+
);
|
|
747
824
|
|
|
748
825
|
CUSTOM_ELEMENT_HANDLING = create(null);
|
|
749
826
|
|
|
@@ -841,24 +918,6 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
841
918
|
}
|
|
842
919
|
}
|
|
843
920
|
|
|
844
|
-
if (
|
|
845
|
-
objectHasOwnProperty(cfg, 'ADD_URI_SAFE_ATTR') &&
|
|
846
|
-
arrayIsArray(cfg.ADD_URI_SAFE_ATTR)
|
|
847
|
-
) {
|
|
848
|
-
addToSet(URI_SAFE_ATTRIBUTES, cfg.ADD_URI_SAFE_ATTR, transformCaseFunc);
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
if (
|
|
852
|
-
objectHasOwnProperty(cfg, 'FORBID_CONTENTS') &&
|
|
853
|
-
arrayIsArray(cfg.FORBID_CONTENTS)
|
|
854
|
-
) {
|
|
855
|
-
if (FORBID_CONTENTS === DEFAULT_FORBID_CONTENTS) {
|
|
856
|
-
FORBID_CONTENTS = clone(FORBID_CONTENTS);
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
addToSet(FORBID_CONTENTS, cfg.FORBID_CONTENTS, transformCaseFunc);
|
|
860
|
-
}
|
|
861
|
-
|
|
862
921
|
if (
|
|
863
922
|
objectHasOwnProperty(cfg, 'ADD_FORBID_CONTENTS') &&
|
|
864
923
|
arrayIsArray(cfg.ADD_FORBID_CONTENTS)
|
|
@@ -1171,6 +1230,37 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1171
1230
|
}
|
|
1172
1231
|
};
|
|
1173
1232
|
|
|
1233
|
+
/**
|
|
1234
|
+
* _stripAttributeNode
|
|
1235
|
+
*
|
|
1236
|
+
* Remove a single Attr node case/namespace-exactly on an attribute-teardown
|
|
1237
|
+
* path. Name-based removeAttribute() ASCII-lowercases its lookup key for an
|
|
1238
|
+
* HTML element in an HTML document and so silently misses a case-preserved
|
|
1239
|
+
* handler (e.g. `ONERROR` off an XML/XHTML import) - the same defect
|
|
1240
|
+
* _removeAttribute() was fixed for, which a name-based call would reintroduce
|
|
1241
|
+
* on these IN_PLACE teardown paths. Unlike _removeAttribute this does not
|
|
1242
|
+
* record into DOMPurify.removed: the neutralize passes intentionally do not
|
|
1243
|
+
* book-keep. A clobbered/detached node falls back to best-effort name-based
|
|
1244
|
+
* removal.
|
|
1245
|
+
*
|
|
1246
|
+
* @param element the element to strip the attribute from
|
|
1247
|
+
* @param attribute the Attr node to remove
|
|
1248
|
+
* @param name the attribute's name, for the fallback path
|
|
1249
|
+
*/
|
|
1250
|
+
const _stripAttributeNode = function (
|
|
1251
|
+
element: Element,
|
|
1252
|
+
attribute: Attr,
|
|
1253
|
+
name: string
|
|
1254
|
+
): void {
|
|
1255
|
+
try {
|
|
1256
|
+
element.removeAttributeNode(attribute);
|
|
1257
|
+
} catch (_) {
|
|
1258
|
+
try {
|
|
1259
|
+
element.removeAttribute(name);
|
|
1260
|
+
} catch (_) {}
|
|
1261
|
+
}
|
|
1262
|
+
};
|
|
1263
|
+
|
|
1174
1264
|
/**
|
|
1175
1265
|
* _neutralizeRoot
|
|
1176
1266
|
*
|
|
@@ -1217,11 +1307,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1217
1307
|
const attribute = attributes[i];
|
|
1218
1308
|
const name = attribute && attribute.name;
|
|
1219
1309
|
if (typeof name === 'string') {
|
|
1220
|
-
|
|
1221
|
-
(root as Element).removeAttribute(name);
|
|
1222
|
-
} catch (_) {
|
|
1223
|
-
/* Clobbered removeAttribute — ignore (fail-closed best effort) */
|
|
1224
|
-
}
|
|
1310
|
+
_stripAttributeNode(root as Element, attribute, name);
|
|
1225
1311
|
}
|
|
1226
1312
|
}
|
|
1227
1313
|
}
|
|
@@ -1230,24 +1316,53 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1230
1316
|
/**
|
|
1231
1317
|
* _removeAttribute
|
|
1232
1318
|
*
|
|
1319
|
+
* Name-based getAttributeNode()/removeAttribute() ASCII-lowercase their
|
|
1320
|
+
* lookup key for HTML elements in an HTML document, so they silently miss an
|
|
1321
|
+
* attribute whose stored qualified name still contains uppercase ASCII
|
|
1322
|
+
* letters. That happens when the node came from a case-preserving source
|
|
1323
|
+
* (an XML/XHTML document imported via importNode(), or createAttributeNS()),
|
|
1324
|
+
* where e.g. `ONERROR` survives the walk: the policy check lowercases to
|
|
1325
|
+
* `onerror` and rejects it, but `removeAttribute('ONERROR')` looks up
|
|
1326
|
+
* `onerror` and finds nothing. Remove the exact Attr node instead, which is
|
|
1327
|
+
* case- and namespace-exact, and fall back to name-based removal only when
|
|
1328
|
+
* the caller could not supply the node.
|
|
1329
|
+
*
|
|
1233
1330
|
* @param name an Attribute name
|
|
1234
1331
|
* @param element a DOM node
|
|
1332
|
+
* @param attr the exact Attr node to remove, when the caller has it
|
|
1235
1333
|
*/
|
|
1236
|
-
const _removeAttribute = function (
|
|
1334
|
+
const _removeAttribute = function (
|
|
1335
|
+
name: string,
|
|
1336
|
+
element: Element,
|
|
1337
|
+
attr?: Attr | null
|
|
1338
|
+
): void {
|
|
1339
|
+
if (!attr) {
|
|
1340
|
+
try {
|
|
1341
|
+
attr = element.getAttributeNode(name);
|
|
1342
|
+
} catch (_) {
|
|
1343
|
+
attr = null;
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
arrayPush(DOMPurify.removed, {
|
|
1348
|
+
attribute: attr || null,
|
|
1349
|
+
from: element,
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1237
1352
|
try {
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1353
|
+
if (attr) {
|
|
1354
|
+
element.removeAttributeNode(attr);
|
|
1355
|
+
} else {
|
|
1356
|
+
element.removeAttribute(name);
|
|
1357
|
+
}
|
|
1242
1358
|
} catch (_) {
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1359
|
+
/* Clobbered or already-detached node - best-effort fall back to a
|
|
1360
|
+
name-based removal so the "is" handling below still runs. */
|
|
1361
|
+
try {
|
|
1362
|
+
element.removeAttribute(name);
|
|
1363
|
+
} catch (_) {}
|
|
1247
1364
|
}
|
|
1248
1365
|
|
|
1249
|
-
element.removeAttribute(name);
|
|
1250
|
-
|
|
1251
1366
|
// We void attribute values for unremovable "is" attributes
|
|
1252
1367
|
if (name === 'is') {
|
|
1253
1368
|
if (RETURN_DOM || RETURN_DOM_FRAGMENT) {
|
|
@@ -1285,11 +1400,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1285
1400
|
continue;
|
|
1286
1401
|
}
|
|
1287
1402
|
|
|
1288
|
-
|
|
1289
|
-
element.removeAttribute(name);
|
|
1290
|
-
} catch (_) {
|
|
1291
|
-
/* Clobbered removeAttribute on a doomed node — ignore */
|
|
1292
|
-
}
|
|
1403
|
+
_stripAttributeNode(element, attribute, name);
|
|
1293
1404
|
}
|
|
1294
1405
|
};
|
|
1295
1406
|
|
|
@@ -1320,7 +1431,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1320
1431
|
|
|
1321
1432
|
while (stack.length > 0) {
|
|
1322
1433
|
const node = stack.pop();
|
|
1323
|
-
const nodeType =
|
|
1434
|
+
const nodeType = _readNodeType(node);
|
|
1324
1435
|
|
|
1325
1436
|
if (nodeType === NODE_TYPE.element) {
|
|
1326
1437
|
_stripDisallowedAttributes(node as Element);
|
|
@@ -1370,6 +1481,32 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1370
1481
|
*
|
|
1371
1482
|
* @param root the in-place root to sweep
|
|
1372
1483
|
*/
|
|
1484
|
+
/**
|
|
1485
|
+
* Central policy for declarative-partial-updates patch-linkage attributes,
|
|
1486
|
+
* shared by the _neutralizePatchLinkage pre-pass and _isValidAttribute so
|
|
1487
|
+
* the two sites cannot drift: `patchsrc` always links, `for` links
|
|
1488
|
+
* everywhere except on <label>/<output>, and the whole policy is gated on
|
|
1489
|
+
* SAFE_FOR_XML (see the rationale block in _isValidAttribute).
|
|
1490
|
+
*
|
|
1491
|
+
* @param lcName the transformCaseFunc'd attribute name
|
|
1492
|
+
* @param lcTag the transformCaseFunc'd tag name of the carrying element
|
|
1493
|
+
* @return true if the attribute is patch linkage and must be dropped
|
|
1494
|
+
*/
|
|
1495
|
+
const _isPatchLinkageAttribute = function (
|
|
1496
|
+
lcName: string,
|
|
1497
|
+
lcTag: string
|
|
1498
|
+
): boolean {
|
|
1499
|
+
if (!SAFE_FOR_XML) {
|
|
1500
|
+
return false;
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
if (lcName === 'patchsrc') {
|
|
1504
|
+
return true;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
return lcName === 'for' && lcTag !== 'label' && lcTag !== 'output';
|
|
1508
|
+
};
|
|
1509
|
+
|
|
1373
1510
|
const _neutralizePatchLinkage = function (root: Node): void {
|
|
1374
1511
|
if (!SAFE_FOR_XML) {
|
|
1375
1512
|
return;
|
|
@@ -1378,7 +1515,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1378
1515
|
const stack: Node[] = [root];
|
|
1379
1516
|
while (stack.length > 0) {
|
|
1380
1517
|
const node = stack.pop();
|
|
1381
|
-
const nodeType =
|
|
1518
|
+
const nodeType = _readNodeType(node);
|
|
1382
1519
|
|
|
1383
1520
|
/* Remove range markers (the target side of a patch linkage): every
|
|
1384
1521
|
processing instruction, and any markup-bearing comment. */
|
|
@@ -1399,9 +1536,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1399
1536
|
/* Strip patch-source attributes (the source side) off elements. */
|
|
1400
1537
|
if (nodeType === NODE_TYPE.element) {
|
|
1401
1538
|
const element = node as Element;
|
|
1402
|
-
const lcTag = transformCaseFunc(
|
|
1403
|
-
getNodeName ? getNodeName(node) : (node as any).nodeName
|
|
1404
|
-
);
|
|
1539
|
+
const lcTag = transformCaseFunc(_readNodeName(node));
|
|
1405
1540
|
try {
|
|
1406
1541
|
if (element.hasAttribute && element.hasAttribute('patchsrc')) {
|
|
1407
1542
|
element.removeAttribute('patchsrc');
|
|
@@ -1410,8 +1545,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1410
1545
|
if (
|
|
1411
1546
|
element.hasAttribute &&
|
|
1412
1547
|
element.hasAttribute('for') &&
|
|
1413
|
-
|
|
1414
|
-
lcTag !== 'output'
|
|
1548
|
+
_isPatchLinkageAttribute('for', lcTag)
|
|
1415
1549
|
) {
|
|
1416
1550
|
element.removeAttribute('for');
|
|
1417
1551
|
}
|
|
@@ -1509,8 +1643,18 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1509
1643
|
* @return The created NodeIterator
|
|
1510
1644
|
*/
|
|
1511
1645
|
const _createNodeIterator = function (root: Node): NodeIterator {
|
|
1646
|
+
/* Read ownerDocument through the cached Node.prototype getter, never the
|
|
1647
|
+
direct property. HTMLFormElement has [LegacyOverrideBuiltIns], so a
|
|
1648
|
+
clobbering child (<input name="ownerDocument"> or a form-associated
|
|
1649
|
+
external input) shadows the prototype getter and makes a direct read
|
|
1650
|
+
return that <input>. createNodeIterator.call(<input>, ...) then throws
|
|
1651
|
+
"Illegal invocation", and on the IN_PLACE path that throw lands before
|
|
1652
|
+
the walk's fail-closed barrier - leaving the caller's live tree, with
|
|
1653
|
+
any already-armed handler in it, un-neutralized. The cached getter
|
|
1654
|
+
returns the real Document regardless of the clobber. */
|
|
1655
|
+
const doc = getOwnerDocument ? getOwnerDocument(root) : root.ownerDocument;
|
|
1512
1656
|
return createNodeIterator.call(
|
|
1513
|
-
|
|
1657
|
+
doc || root,
|
|
1514
1658
|
root,
|
|
1515
1659
|
// eslint-disable-next-line no-bitwise
|
|
1516
1660
|
NodeFilter.SHOW_ELEMENT |
|
|
@@ -1558,8 +1702,12 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1558
1702
|
*/
|
|
1559
1703
|
const _scrubTemplateExpressions = function (node: Element): void {
|
|
1560
1704
|
node.normalize();
|
|
1705
|
+
/* Clobber-safe ownerDocument read, same reasoning as _createNodeIterator:
|
|
1706
|
+
under SAFE_FOR_TEMPLATES this runs on the live IN_PLACE root, which may
|
|
1707
|
+
carry a form-named-getter override of ownerDocument. */
|
|
1708
|
+
const doc = getOwnerDocument ? getOwnerDocument(node) : node.ownerDocument;
|
|
1561
1709
|
const walker = createNodeIterator.call(
|
|
1562
|
-
|
|
1710
|
+
doc || node,
|
|
1563
1711
|
node,
|
|
1564
1712
|
// eslint-disable-next-line no-bitwise
|
|
1565
1713
|
NodeFilter.SHOW_TEXT |
|
|
@@ -1733,12 +1881,22 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1733
1881
|
return true;
|
|
1734
1882
|
}
|
|
1735
1883
|
|
|
1736
|
-
/* Remove
|
|
1884
|
+
/* Remove rawtext/literal-text elements whose literal serialization
|
|
1885
|
+
re-opens markup on an HTML reparse - shapes (a) and (b) documented at
|
|
1886
|
+
LITERAL_TEXT_ELEMENTS. Both are invisible to rule 1 above (which
|
|
1887
|
+
self-disables once there is an element child, and whose second probe
|
|
1888
|
+
reads the innerHTML an XML/XHTML document serializes escaped), which
|
|
1889
|
+
is why both probes here read textContent instead. Previously only
|
|
1890
|
+
`style`-with-element-child was covered; every element in
|
|
1891
|
+
LITERAL_TEXT_ELEMENTS shares this literal serialization and is
|
|
1892
|
+
equally affected. */
|
|
1737
1893
|
if (
|
|
1738
1894
|
SAFE_FOR_XML &&
|
|
1739
1895
|
currentNode.namespaceURI === HTML_NAMESPACE &&
|
|
1740
|
-
tagName
|
|
1741
|
-
_isNode(currentNode.firstElementChild)
|
|
1896
|
+
LITERAL_TEXT_ELEMENTS[tagName] &&
|
|
1897
|
+
(_isNode(currentNode.firstElementChild) ||
|
|
1898
|
+
(typeof currentNode.textContent === 'string' &&
|
|
1899
|
+
regExpTest(LITERAL_TEXT_CLOSE[tagName], currentNode.textContent)))
|
|
1742
1900
|
) {
|
|
1743
1901
|
return true;
|
|
1744
1902
|
}
|
|
@@ -1760,6 +1918,34 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1760
1918
|
return false;
|
|
1761
1919
|
};
|
|
1762
1920
|
|
|
1921
|
+
/**
|
|
1922
|
+
* Evaluate a CUSTOM_ELEMENT_HANDLING check (a RegExp or a predicate
|
|
1923
|
+
* function, per the validation in _parseConfig) against a name.
|
|
1924
|
+
* Additional arguments are forwarded to predicate functions - the
|
|
1925
|
+
* attributeNameCheck predicate receives the tag name as its second
|
|
1926
|
+
* argument. A null/absent check never matches.
|
|
1927
|
+
*
|
|
1928
|
+
* @param check the configured tagNameCheck / attributeNameCheck value
|
|
1929
|
+
* @param name the name to test
|
|
1930
|
+
* @param args extra arguments forwarded to a predicate function
|
|
1931
|
+
* @return true if the check matches the name
|
|
1932
|
+
*/
|
|
1933
|
+
const _matchesNameCheck = function (
|
|
1934
|
+
check: unknown,
|
|
1935
|
+
name: string,
|
|
1936
|
+
...args: unknown[]
|
|
1937
|
+
): boolean {
|
|
1938
|
+
if (check instanceof RegExp) {
|
|
1939
|
+
return regExpTest(check, name);
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
if (check instanceof Function) {
|
|
1943
|
+
return Boolean(check(name, ...args));
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
return false;
|
|
1947
|
+
};
|
|
1948
|
+
|
|
1763
1949
|
/**
|
|
1764
1950
|
* Handle a node whose tag is forbidden or not allowlisted: keep
|
|
1765
1951
|
* allowed custom elements (false return exits _sanitizeElements
|
|
@@ -1779,23 +1965,16 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1779
1965
|
*/
|
|
1780
1966
|
const _sanitizeDisallowedNode = function (
|
|
1781
1967
|
currentNode: any,
|
|
1782
|
-
tagName: string
|
|
1968
|
+
tagName: string,
|
|
1969
|
+
root: Node
|
|
1783
1970
|
): boolean {
|
|
1784
1971
|
/* Check if we have a custom element to handle */
|
|
1785
|
-
if (
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
}
|
|
1792
|
-
|
|
1793
|
-
if (
|
|
1794
|
-
CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function &&
|
|
1795
|
-
CUSTOM_ELEMENT_HANDLING.tagNameCheck(tagName)
|
|
1796
|
-
) {
|
|
1797
|
-
return false;
|
|
1798
|
-
}
|
|
1972
|
+
if (
|
|
1973
|
+
!FORBID_TAGS[tagName] &&
|
|
1974
|
+
_isBasicCustomElement(tagName) &&
|
|
1975
|
+
_matchesNameCheck(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)
|
|
1976
|
+
) {
|
|
1977
|
+
return false;
|
|
1799
1978
|
}
|
|
1800
1979
|
|
|
1801
1980
|
/* Keep content except for bad-listed elements.
|
|
@@ -1813,31 +1992,31 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1813
1992
|
if (childNodes && parentNode) {
|
|
1814
1993
|
const childCount = childNodes.length;
|
|
1815
1994
|
|
|
1816
|
-
/*
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
event to neutralise.
|
|
1995
|
+
/* Hoist by moving each child up one level rather than deep-cloning
|
|
1996
|
+
it. Moving transfers every descendant exactly once, so a chain of
|
|
1997
|
+
nested disallowed elements costs O(n) instead of the O(n^2) that
|
|
1998
|
+
re-cloning the shrinking subtree at each level produced; it also
|
|
1999
|
+
empties the removed original, so `DOMPurify.removed` no longer
|
|
2000
|
+
pins whole subtrees. Moving preserves the in-place guarantee too:
|
|
2001
|
+
an original carrying already-queued resource events (`<img
|
|
2002
|
+
onerror>`, `<video>`/`<audio>` error, lazy/`onload`, …) is
|
|
2003
|
+
relocated and sanitised rather than left detached but still armed.
|
|
2004
|
+
|
|
2005
|
+
The sole case that must clone is removing the walk root itself.
|
|
2006
|
+
The result is serialised from the root's subtree, so a restrictive
|
|
2007
|
+
ALLOWED_TAGS that strips the root (`body` on the string path) must
|
|
2008
|
+
leave the content inside it, which only cloning does. In IN_PLACE
|
|
2009
|
+
the root is pre-validated as an allowed tag and so is never removed
|
|
2010
|
+
here, so that path always takes the move branch.
|
|
1833
2011
|
|
|
1834
2012
|
`childNodes` is live; a tail-to-head walk keeps `childNodes[i]`
|
|
1835
2013
|
valid whether we move (drops the trailing entry) or clone (leaves
|
|
1836
2014
|
the list intact). */
|
|
1837
2015
|
for (let i = childCount - 1; i >= 0; --i) {
|
|
1838
|
-
const hoisted =
|
|
1839
|
-
|
|
1840
|
-
|
|
2016
|
+
const hoisted =
|
|
2017
|
+
currentNode === root
|
|
2018
|
+
? cloneNode(childNodes[i], true)
|
|
2019
|
+
: childNodes[i];
|
|
1841
2020
|
parentNode.insertBefore(hoisted, getNextSibling(currentNode));
|
|
1842
2021
|
}
|
|
1843
2022
|
}
|
|
@@ -1847,6 +2026,79 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1847
2026
|
return true;
|
|
1848
2027
|
};
|
|
1849
2028
|
|
|
2029
|
+
/**
|
|
2030
|
+
* Fork a hook-mutable allowlist off its shared binding the first time a
|
|
2031
|
+
* (possibly lazily-installed) uponSanitize* hook is about to see it, so the
|
|
2032
|
+
* hook cannot widen the per-instance default or the setConfig binding by
|
|
2033
|
+
* reference and leak past the call. Returns the set unchanged once it is
|
|
2034
|
+
* already call-local, so repeated calls across elements are idempotent.
|
|
2035
|
+
*
|
|
2036
|
+
* @param hookList the uponSanitize* hook array for this event
|
|
2037
|
+
* @param set the current ALLOWED_TAGS / ALLOWED_ATTR binding
|
|
2038
|
+
* @param defaultSet the per-instance DEFAULT_ALLOWED_* constant
|
|
2039
|
+
* @param setConfigSet the captured setConfig() binding, or null
|
|
2040
|
+
* @return a call-local clone if a hook is present and set is still shared,
|
|
2041
|
+
* else set unchanged
|
|
2042
|
+
*/
|
|
2043
|
+
const _forkSharedAllowlist = function <T extends Record<string, any>>(
|
|
2044
|
+
hookList: unknown[],
|
|
2045
|
+
set: T,
|
|
2046
|
+
defaultSet: T,
|
|
2047
|
+
setConfigSet: T | null
|
|
2048
|
+
): T {
|
|
2049
|
+
if (hookList.length === 0) {
|
|
2050
|
+
return set;
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
return set === defaultSet || set === setConfigSet ? clone(set) : set;
|
|
2054
|
+
};
|
|
2055
|
+
|
|
2056
|
+
/**
|
|
2057
|
+
* Shared guard for a node that a hook has detached from the walk tree,
|
|
2058
|
+
* used after each element-hook site in _sanitizeElements. Detaching is a
|
|
2059
|
+
* long-standing user pattern (issue #469; draw.io-style foreignObject
|
|
2060
|
+
* filtering). Per the cached, unclobberable parentNode getter the node is
|
|
2061
|
+
* genuinely out of the tree, so it can reach neither the serialized
|
|
2062
|
+
* output nor an IN_PLACE live tree; treat it as removed and stop
|
|
2063
|
+
* processing it. Without this guard, the unsafe-node / namespace checks
|
|
2064
|
+
* would call _forceRemove on a parentless node and hit the REPORT-3
|
|
2065
|
+
* fail-closed throw — which exists for nodes DOMPurify wants gone but
|
|
2066
|
+
* *cannot* detach (clobbered / parentless roots), the opposite of a node
|
|
2067
|
+
* that is already safely gone. The walk root is exempt: a detached
|
|
2068
|
+
* IN_PLACE root is legitimate input and must still be fully sanitized,
|
|
2069
|
+
* and a kill-decision on it must keep hitting the REPORT-3 throw.
|
|
2070
|
+
*
|
|
2071
|
+
* Nodes detached by hooks stay the hook's responsibility for placement:
|
|
2072
|
+
* they are not recorded in DOMPurify.removed, so the post-walk IN_PLACE
|
|
2073
|
+
* pass (which iterates DOMPurify.removed) does not reach them. But a
|
|
2074
|
+
* hook-detached subtree can still hold a queued resource-event handler -
|
|
2075
|
+
* e.g. an <img onload> that began loading when the caller built the live
|
|
2076
|
+
* tree - which fires in page scope after sanitize returns even though the
|
|
2077
|
+
* handler never reached the returned tree. That is the audit-5 F1 hazard,
|
|
2078
|
+
* and the documented node.remove() hook pattern walks straight into it.
|
|
2079
|
+
* So on the IN_PLACE path we neutralize the detached subtree inline,
|
|
2080
|
+
* stripping its non-allow-listed attributes before returning, exactly as
|
|
2081
|
+
* the post-walk pass does for _forceRemove'd subtrees.
|
|
2082
|
+
*
|
|
2083
|
+
* @param currentNode the node a hook may have detached
|
|
2084
|
+
* @param root the current walk root
|
|
2085
|
+
* @return true if the node is detached and now handled, false otherwise
|
|
2086
|
+
*/
|
|
2087
|
+
const _handleHookDetachedNode = function (
|
|
2088
|
+
currentNode: Node,
|
|
2089
|
+
root: Node
|
|
2090
|
+
): boolean {
|
|
2091
|
+
if (currentNode === root || getParentNode(currentNode) !== null) {
|
|
2092
|
+
return false;
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
if (IN_PLACE) {
|
|
2096
|
+
_neutralizeSubtree(currentNode);
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
return true;
|
|
2100
|
+
};
|
|
2101
|
+
|
|
1850
2102
|
/**
|
|
1851
2103
|
* _sanitizeElements
|
|
1852
2104
|
*
|
|
@@ -1856,14 +2108,13 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1856
2108
|
* @param currentNode to check for permission to exist
|
|
1857
2109
|
* @return true if node was killed, false if left alive
|
|
1858
2110
|
*/
|
|
1859
|
-
// eslint-disable-next-line complexity
|
|
1860
2111
|
const _sanitizeElements = function (currentNode: any, root: Node): boolean {
|
|
1861
2112
|
/* Execute a hook if present */
|
|
1862
2113
|
_executeHooks(hooks.beforeSanitizeElements, currentNode, null);
|
|
1863
2114
|
|
|
1864
|
-
/* A hook may have detached the node
|
|
1865
|
-
|
|
1866
|
-
if (currentNode
|
|
2115
|
+
/* A hook may have detached the node - treat it as removed (see
|
|
2116
|
+
_handleHookDetachedNode for the full rationale). */
|
|
2117
|
+
if (_handleHookDetachedNode(currentNode, root)) {
|
|
1867
2118
|
return true;
|
|
1868
2119
|
}
|
|
1869
2120
|
|
|
@@ -1874,8 +2125,18 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1874
2125
|
}
|
|
1875
2126
|
|
|
1876
2127
|
/* Now let's check the element's type and name */
|
|
1877
|
-
const tagName = transformCaseFunc(
|
|
1878
|
-
|
|
2128
|
+
const tagName = transformCaseFunc(_readNodeName(currentNode));
|
|
2129
|
+
|
|
2130
|
+
/* Close the pre-walk clone-guard's timing gap: an uponSanitizeElement
|
|
2131
|
+
hook may have been installed after that guard sampled the hook arrays
|
|
2132
|
+
(e.g. lazily from beforeSanitizeElements), leaving ALLOWED_TAGS still
|
|
2133
|
+
aliasing a shared binding that a widening hook would mutate by
|
|
2134
|
+
reference. Fork it before exposing it to the hook. */
|
|
2135
|
+
ALLOWED_TAGS = _forkSharedAllowlist(
|
|
2136
|
+
hooks.uponSanitizeElement,
|
|
2137
|
+
ALLOWED_TAGS,
|
|
2138
|
+
DEFAULT_ALLOWED_TAGS,
|
|
2139
|
+
SET_CONFIG_ALLOWED_TAGS
|
|
1879
2140
|
);
|
|
1880
2141
|
|
|
1881
2142
|
/* Execute a hook if present */
|
|
@@ -1884,22 +2145,9 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1884
2145
|
allowedTags: ALLOWED_TAGS,
|
|
1885
2146
|
});
|
|
1886
2147
|
|
|
1887
|
-
/*
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
genuinely out of the tree, so it can reach neither the serialized
|
|
1891
|
-
output nor an IN_PLACE live tree; treat it as removed and stop
|
|
1892
|
-
processing it. Without this guard, the unsafe-node / namespace
|
|
1893
|
-
checks below would call _forceRemove on a parentless node and hit
|
|
1894
|
-
the REPORT-3 fail-closed throw — which exists for nodes DOMPurify
|
|
1895
|
-
wants gone but *cannot* detach (clobbered / parentless roots), the
|
|
1896
|
-
opposite of a node that is already safely gone. The walk root is
|
|
1897
|
-
exempt: a detached IN_PLACE root is legitimate input and must still
|
|
1898
|
-
be fully sanitized, and a kill-decision on it must keep hitting the
|
|
1899
|
-
REPORT-3 throw. Nodes detached by hooks are the hook's
|
|
1900
|
-
responsibility: they are not recorded in DOMPurify.removed and are
|
|
1901
|
-
not neutralized by the post-walk IN_PLACE pass. */
|
|
1902
|
-
if (currentNode !== root && getParentNode(currentNode) === null) {
|
|
2148
|
+
/* The uponSanitizeElement hook may have detached the node, exactly as
|
|
2149
|
+
above (see _handleHookDetachedNode for the full rationale). */
|
|
2150
|
+
if (_handleHookDetachedNode(currentNode, root)) {
|
|
1903
2151
|
return true;
|
|
1904
2152
|
}
|
|
1905
2153
|
|
|
@@ -1918,7 +2166,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1918
2166
|
) &&
|
|
1919
2167
|
!ALLOWED_TAGS[tagName])
|
|
1920
2168
|
) {
|
|
1921
|
-
const removed = _sanitizeDisallowedNode(currentNode, tagName);
|
|
2169
|
+
const removed = _sanitizeDisallowedNode(currentNode, tagName, root);
|
|
1922
2170
|
|
|
1923
2171
|
/* A false return means the node is a custom element kept via
|
|
1924
2172
|
CUSTOM_ELEMENT_HANDLING - the only keep path through
|
|
@@ -1944,7 +2192,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
1944
2192
|
bound and short-circuits to false for any node minted in a different
|
|
1945
2193
|
realm — letting a foreign-realm element with a forbidden namespace
|
|
1946
2194
|
slip past the namespace check entirely. */
|
|
1947
|
-
const nt =
|
|
2195
|
+
const nt = _readNodeType(currentNode);
|
|
1948
2196
|
if (nt === NODE_TYPE.element && !_checkValidNamespace(currentNode)) {
|
|
1949
2197
|
_forceRemove(currentNode);
|
|
1950
2198
|
return true;
|
|
@@ -2020,16 +2268,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2020
2268
|
other structural-threat checks and stays overridable, consistent with
|
|
2021
2269
|
the rest of the codebase. PI range markers are already removed by
|
|
2022
2270
|
_isUnsafeNode. */
|
|
2023
|
-
if (
|
|
2024
|
-
return false;
|
|
2025
|
-
}
|
|
2026
|
-
|
|
2027
|
-
if (
|
|
2028
|
-
SAFE_FOR_XML &&
|
|
2029
|
-
lcName === 'for' &&
|
|
2030
|
-
lcTag !== 'label' &&
|
|
2031
|
-
lcTag !== 'output'
|
|
2032
|
-
) {
|
|
2271
|
+
if (_isPatchLinkageAttribute(lcName, lcTag)) {
|
|
2033
2272
|
return false;
|
|
2034
2273
|
}
|
|
2035
2274
|
|
|
@@ -2052,73 +2291,76 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2052
2291
|
XML-compatible (https://html.spec.whatwg.org/multipage/infrastructure.html#xml-compatible and http://www.w3.org/TR/xml/#d0e804)
|
|
2053
2292
|
We don't need to check the value; it's always URI safe. */
|
|
2054
2293
|
if (ALLOW_DATA_ATTR && regExpTest(DATA_ATTR, lcName)) {
|
|
2055
|
-
|
|
2056
|
-
}
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2294
|
+
return true;
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
/* Allow valid aria-* attributes, the value is always URI safe */
|
|
2298
|
+
if (ALLOW_ARIA_ATTR && regExpTest(ARIA_ATTR, lcName)) {
|
|
2299
|
+
return true;
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
/* A name outside the allowlist is acceptable on custom-element terms
|
|
2303
|
+
only. The value checks below are intentionally skipped in that case:
|
|
2304
|
+
if the user supplied a tagNameCheck we also allow derived custom
|
|
2305
|
+
elements using the same test, and attributes passing the configured
|
|
2306
|
+
attributeNameCheck are allowed as custom elements define these at
|
|
2307
|
+
their own discretion. */
|
|
2308
|
+
if (!nameIsPermitted) {
|
|
2309
|
+
return (
|
|
2310
|
+
// Condition a) covers a basically valid custom element tag name whose
|
|
2311
|
+
// tag passes the configured tagNameCheck and whose attribute name
|
|
2312
|
+
// passes the configured attributeNameCheck ...
|
|
2064
2313
|
(_isBasicCustomElement(lcTag) &&
|
|
2065
|
-
(
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
//
|
|
2074
|
-
// the value passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck
|
|
2314
|
+
_matchesNameCheck(CUSTOM_ELEMENT_HANDLING.tagNameCheck, lcTag) &&
|
|
2315
|
+
_matchesNameCheck(
|
|
2316
|
+
CUSTOM_ELEMENT_HANDLING.attributeNameCheck,
|
|
2317
|
+
lcName,
|
|
2318
|
+
lcTag
|
|
2319
|
+
)) ||
|
|
2320
|
+
// Condition b) covers an `is` attribute whose value passes the
|
|
2321
|
+
// configured tagNameCheck while customized built-in elements are
|
|
2322
|
+
// allowed.
|
|
2075
2323
|
(lcName === 'is' &&
|
|
2076
2324
|
CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements &&
|
|
2077
|
-
(
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
/* Check value is safe. First, is attr inert? If so, is safe */
|
|
2088
|
-
} else if (URI_SAFE_ATTRIBUTES[lcName]) {
|
|
2089
|
-
// This attribute is safe
|
|
2090
|
-
/* Check no script, data or unknown possibly unsafe URI
|
|
2325
|
+
_matchesNameCheck(CUSTOM_ELEMENT_HANDLING.tagNameCheck, value))
|
|
2326
|
+
);
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
/* Check value is safe. First, is attr inert? If so, is safe */
|
|
2330
|
+
if (URI_SAFE_ATTRIBUTES[lcName]) {
|
|
2331
|
+
return true;
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
/* Check no script, data or unknown possibly unsafe URI
|
|
2091
2335
|
unless we know URI values are safe for that attribute */
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2336
|
+
if (regExpTest(IS_ALLOWED_URI, stringReplace(value, ATTR_WHITESPACE, ''))) {
|
|
2337
|
+
return true;
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
/* Keep image data URIs alive if src/xlink:href is allowed */
|
|
2341
|
+
/* Further prevent gadget XSS for dynamically built script tags */
|
|
2342
|
+
if (
|
|
2099
2343
|
(lcName === 'src' || lcName === 'xlink:href' || lcName === 'href') &&
|
|
2100
2344
|
lcTag !== 'script' &&
|
|
2101
2345
|
stringIndexOf(value, 'data:') === 0 &&
|
|
2102
2346
|
DATA_URI_TAGS[lcTag]
|
|
2103
2347
|
) {
|
|
2104
|
-
|
|
2105
|
-
|
|
2348
|
+
return true;
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
/* Allow unknown protocols: This provides support for links that
|
|
2106
2352
|
are handled by protocol handlers which may be unknown ahead of
|
|
2107
2353
|
time, e.g. fb:, spotify: */
|
|
2108
|
-
|
|
2354
|
+
if (
|
|
2109
2355
|
ALLOW_UNKNOWN_PROTOCOLS &&
|
|
2110
2356
|
!regExpTest(IS_SCRIPT_OR_DATA, stringReplace(value, ATTR_WHITESPACE, ''))
|
|
2111
2357
|
) {
|
|
2112
|
-
|
|
2113
|
-
/* Check for binary attributes */
|
|
2114
|
-
} else if (value) {
|
|
2115
|
-
return false;
|
|
2116
|
-
} else {
|
|
2117
|
-
// Binary attributes are safe at this point
|
|
2118
|
-
/* Anything else, presume unsafe, do not add it back */
|
|
2358
|
+
return true;
|
|
2119
2359
|
}
|
|
2120
2360
|
|
|
2121
|
-
|
|
2361
|
+
/* Only an empty (binary) value remains safe at this point;
|
|
2362
|
+
anything else is presumed unsafe, do not add it back */
|
|
2363
|
+
return !value;
|
|
2122
2364
|
};
|
|
2123
2365
|
|
|
2124
2366
|
/* Names the HTML spec reserves from valid-custom-element-name; these must
|
|
@@ -2250,6 +2492,15 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2250
2492
|
return;
|
|
2251
2493
|
}
|
|
2252
2494
|
|
|
2495
|
+
/* Same lazy-install guard as uponSanitizeElement (see there): fork the
|
|
2496
|
+
attribute allowlist off its shared binding before a hook can see it. */
|
|
2497
|
+
ALLOWED_ATTR = _forkSharedAllowlist(
|
|
2498
|
+
hooks.uponSanitizeAttribute,
|
|
2499
|
+
ALLOWED_ATTR,
|
|
2500
|
+
DEFAULT_ALLOWED_ATTR,
|
|
2501
|
+
SET_CONFIG_ALLOWED_ATTR
|
|
2502
|
+
);
|
|
2503
|
+
|
|
2253
2504
|
const hookEvent = {
|
|
2254
2505
|
attrName: '',
|
|
2255
2506
|
attrValue: '',
|
|
@@ -2286,7 +2537,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2286
2537
|
stringIndexOf(value, SANITIZE_NAMED_PROPS_PREFIX) !== 0
|
|
2287
2538
|
) {
|
|
2288
2539
|
// Remove the attribute with this value
|
|
2289
|
-
_removeAttribute(name, currentNode);
|
|
2540
|
+
_removeAttribute(name, currentNode, attr);
|
|
2290
2541
|
// Prefix the value and later re-create the attribute with the sanitized value
|
|
2291
2542
|
value = SANITIZE_NAMED_PROPS_PREFIX + value;
|
|
2292
2543
|
}
|
|
@@ -2301,13 +2552,13 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2301
2552
|
value
|
|
2302
2553
|
)
|
|
2303
2554
|
) {
|
|
2304
|
-
_removeAttribute(name, currentNode);
|
|
2555
|
+
_removeAttribute(name, currentNode, attr);
|
|
2305
2556
|
continue;
|
|
2306
2557
|
}
|
|
2307
2558
|
|
|
2308
2559
|
/* Make sure we cannot easily use animated hrefs, even if animations are allowed */
|
|
2309
2560
|
if (lcName === 'attributename' && stringMatch(value, 'href')) {
|
|
2310
|
-
_removeAttribute(name, currentNode);
|
|
2561
|
+
_removeAttribute(name, currentNode, attr);
|
|
2311
2562
|
continue;
|
|
2312
2563
|
}
|
|
2313
2564
|
|
|
@@ -2318,7 +2569,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2318
2569
|
|
|
2319
2570
|
/* Did the hooks approve of the attribute? */
|
|
2320
2571
|
if (!hookEvent.keepAttr) {
|
|
2321
|
-
_removeAttribute(name, currentNode);
|
|
2572
|
+
_removeAttribute(name, currentNode, attr);
|
|
2322
2573
|
continue;
|
|
2323
2574
|
}
|
|
2324
2575
|
|
|
@@ -2327,7 +2578,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2327
2578
|
!ALLOW_SELF_CLOSE_IN_ATTR &&
|
|
2328
2579
|
regExpTest(EXPRESSIONS.SELF_CLOSING_TAG, value)
|
|
2329
2580
|
) {
|
|
2330
|
-
_removeAttribute(name, currentNode);
|
|
2581
|
+
_removeAttribute(name, currentNode, attr);
|
|
2331
2582
|
continue;
|
|
2332
2583
|
}
|
|
2333
2584
|
|
|
@@ -2338,7 +2589,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2338
2589
|
|
|
2339
2590
|
/* Is `value` valid for this attribute? */
|
|
2340
2591
|
if (!_isValidAttribute(lcTag, lcName, value)) {
|
|
2341
|
-
_removeAttribute(name, currentNode);
|
|
2592
|
+
_removeAttribute(name, currentNode, attr);
|
|
2342
2593
|
continue;
|
|
2343
2594
|
}
|
|
2344
2595
|
|
|
@@ -2395,10 +2646,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2395
2646
|
Walk it explicitly. The nodeType guard avoids reading
|
|
2396
2647
|
shadowRoot off text / comment / CDATA / PI nodes that the
|
|
2397
2648
|
iterator also surfaces. */
|
|
2398
|
-
|
|
2399
|
-
? getNodeType(shadowNode)
|
|
2400
|
-
: shadowNode.nodeType;
|
|
2401
|
-
if (shadowNodeType === NODE_TYPE.element) {
|
|
2649
|
+
if (_readNodeType(shadowNode) === NODE_TYPE.element) {
|
|
2402
2650
|
const innerSr = getShadowRoot(shadowNode);
|
|
2403
2651
|
if (_isDocumentFragment(innerSr)) {
|
|
2404
2652
|
_sanitizeAttachedShadowRoots(innerSr);
|
|
@@ -2460,7 +2708,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2460
2708
|
}
|
|
2461
2709
|
|
|
2462
2710
|
const node = item.node;
|
|
2463
|
-
const nodeType =
|
|
2711
|
+
const nodeType = _readNodeType(node);
|
|
2464
2712
|
const isElement = nodeType === NODE_TYPE.element;
|
|
2465
2713
|
|
|
2466
2714
|
/* (pushed last → processed first) Children, snapshotted in reverse so
|
|
@@ -2589,9 +2837,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2589
2837
|
child named "nodeName" on the form root would otherwise shadow
|
|
2590
2838
|
the property and let this check skip the root-allowlist
|
|
2591
2839
|
validation entirely. */
|
|
2592
|
-
const nn =
|
|
2593
|
-
? getNodeName(dirty as Node)
|
|
2594
|
-
: (dirty as Node).nodeName;
|
|
2840
|
+
const nn = _readNodeName(dirty as Node);
|
|
2595
2841
|
if (typeof nn === 'string') {
|
|
2596
2842
|
const tagName = transformCaseFunc(nn);
|
|
2597
2843
|
if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) {
|
|
@@ -2692,18 +2938,21 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
|
|
|
2692
2938
|
|
|
2693
2939
|
/* Get node iterator */
|
|
2694
2940
|
const walkRoot: Node = inPlace ? (dirty as Node) : body;
|
|
2695
|
-
const nodeIterator = _createNodeIterator(walkRoot);
|
|
2696
2941
|
|
|
2697
2942
|
/* Now start iterating over the created document.
|
|
2698
2943
|
The walk runs inside an exception barrier (campaign-3 F2): a re-entrant
|
|
2699
2944
|
engine/custom-element mutation can detach a node mid-walk so
|
|
2700
2945
|
`_forceRemove`'s parentless guard throws, aborting the loop. Without the
|
|
2701
2946
|
barrier the caller's in-place tree would be left half-sanitized with the
|
|
2702
|
-
unvisited tail still armed.
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2947
|
+
unvisited tail still armed. _createNodeIterator itself is inside the
|
|
2948
|
+
barrier too: constructing the iterator dereferences the root's document,
|
|
2949
|
+
and any failure there (e.g. an exotic/clobbered root) must still fail
|
|
2950
|
+
closed rather than skip the neutralize. On any throw we fail closed -
|
|
2951
|
+
strip the in-place root bare - then rethrow so the existing throw
|
|
2952
|
+
contract is preserved. (String/DOM-copy paths never return the partial
|
|
2953
|
+
body, so the propagating throw is already fail-closed there.) */
|
|
2706
2954
|
try {
|
|
2955
|
+
const nodeIterator = _createNodeIterator(walkRoot);
|
|
2707
2956
|
while ((currentNode = nodeIterator.nextNode())) {
|
|
2708
2957
|
/* Sanitize tags and elements */
|
|
2709
2958
|
_sanitizeElements(currentNode, walkRoot);
|