Skip to content

Commit a35824c

Browse files
authored
Cure53 inplace hardening 2 (#1524)
* * feat: block declarative-partial-updates patch directives * feat: harden style mXSS check against foreign-content teleport * fefend against declarative partial updates (Chrome 150) * test: add tests for partial-update patch directives * feat: loosened the style check a bit again test: added correct test coverage for template scrubbing * fix: slightly changed behavior of node removal to avoid breakage, see #1521 * chore: regenerated dist files to be in sync again * fix: made sure that _neutralizeRoot calls _neutralizeSubtree first * test: added tests for the formerly addressed IN_PLACE behavior * chore: fixed the out-of-sync dist files * chore: hardened against several smaller declarative patching crashes * test: extended tests to cover more declarative patching cases
1 parent 28d3dec commit a35824c

10 files changed

Lines changed: 853 additions & 49 deletions

File tree

dist/purify.cjs.js

Lines changed: 123 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/purify.cjs.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/purify.es.mjs

Lines changed: 123 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1106,6 +1106,13 @@ function createDOMPurify() {
11061106
* @param root the in-place root to empty
11071107
*/
11081108
const _neutralizeRoot = function _neutralizeRoot(root) {
1109+
/* Strip every disallowed attribute (on* handlers included) off the whole
1110+
subtree BEFORE detaching anything. Detaching first would hand back
1111+
handler-bearing originals (e.g. an already-loading `<img onerror>`)
1112+
whose queued resource event still fires in page scope after we throw.
1113+
Clobber-safe reads; a doomed clobbered node's own attributes are
1114+
irrelevant while its non-clobbered descendants are reached and scrubbed. */
1115+
_neutralizeSubtree(root);
11091116
const childNodes = getChildNodes(root);
11101117
if (childNodes) {
11111118
const snapshot = [];
@@ -1233,6 +1240,82 @@ function createDOMPurify() {
12331240
}
12341241
}
12351242
};
1243+
/**
1244+
* _neutralizePatchLinkage
1245+
*
1246+
* IN_PLACE entry pre-pass (declarative-partial-updates / streaming
1247+
* hardening, https://github.com/WICG/declarative-partial-updates).
1248+
*
1249+
* The main walk strips patch linkage (`for`/`patchsrc`) and removes range
1250+
* markers (PIs / markup comments) node-by-node, in document order, AS it
1251+
* reaches each node. On a live in-place root that leaves a window: from the
1252+
* moment the root is connected until the walk arrives at a given node, that
1253+
* node's linkage is live. A patch applied on connection/stream can fire as
1254+
* a microtask during the walk and inject or teleport an unsanitized DOM
1255+
* range into a region the iterator has already passed and will not revisit,
1256+
* so the post-return "tree is sanitized" contract is violated. Sweep the
1257+
* whole tree once up front and sever every linkage before the walk begins,
1258+
* closing that window.
1259+
*
1260+
* This CANNOT undo a patch that already fired before sanitize ran — that is
1261+
* the irreducible "do not IN_PLACE a live-connected attacker tree" caveat —
1262+
* but it closes everything from sanitize-start onward. Gated on SAFE_FOR_XML
1263+
* to group with the rest of the declarative-partial-updates handling and
1264+
* stay overridable, consistent with the codebase.
1265+
*
1266+
* Clobber-safe traversal (cached childNodes getter); per-node try/catch so a
1267+
* clobbered root cannot defeat the sweep of its non-clobbered descendants.
1268+
*
1269+
* NOTE (pending real-Chrome confirmation, see test/declarative-patch-probe
1270+
* .html Q1): this mirrors the existing policy of keeping `for` on
1271+
* <label>/<output>. If the shipping feature can drive a patch through a
1272+
* surviving `for`-on-label/output + `id` pair, this pre-pass and the
1273+
* attribute check at _isBasicCustomElement's caller must additionally drop
1274+
* that pair on the IN_PLACE path. Left as-is until the taxonomy is verified.
1275+
*
1276+
* @param root the in-place root to sweep
1277+
*/
1278+
const _neutralizePatchLinkage = function _neutralizePatchLinkage(root) {
1279+
if (!SAFE_FOR_XML) {
1280+
return;
1281+
}
1282+
const stack = [root];
1283+
while (stack.length > 0) {
1284+
const node = stack.pop();
1285+
const nodeType = getNodeType ? getNodeType(node) : node.nodeType;
1286+
/* Remove range markers (the target side of a patch linkage): every
1287+
processing instruction, and any markup-bearing comment. */
1288+
if (nodeType === NODE_TYPE.processingInstruction || nodeType === NODE_TYPE.comment && regExpTest(COMMENT_MARKUP_PROBE, node.data)) {
1289+
try {
1290+
remove(node);
1291+
} catch (_) {
1292+
/* Best-effort */
1293+
}
1294+
continue;
1295+
}
1296+
/* Strip patch-source attributes (the source side) off elements. */
1297+
if (nodeType === NODE_TYPE.element) {
1298+
const element = node;
1299+
const lcTag = transformCaseFunc(getNodeName ? getNodeName(node) : node.nodeName);
1300+
try {
1301+
if (element.hasAttribute && element.hasAttribute('patchsrc')) {
1302+
element.removeAttribute('patchsrc');
1303+
}
1304+
if (element.hasAttribute && element.hasAttribute('for') && lcTag !== 'label' && lcTag !== 'output') {
1305+
element.removeAttribute('for');
1306+
}
1307+
} catch (_) {
1308+
/* Clobbered removeAttribute/hasAttribute on a doomed node — ignore */
1309+
}
1310+
}
1311+
const childNodes = getChildNodes(node);
1312+
if (childNodes) {
1313+
for (let i = childNodes.length - 1; i >= 0; --i) {
1314+
stack.push(childNodes[i]);
1315+
}
1316+
}
1317+
}
1318+
};
12361319
/**
12371320
* _initDocument
12381321
*
@@ -1645,17 +1728,26 @@ function createDOMPurify() {
16451728
return false;
16461729
}
16471730
/* Reject declarative-partial-updates patch-linkage attributes
1648-
(https://github.com/WICG/declarative-partial-updates). These turn a
1649-
surviving element into an out-of-band DOM-mutation primitive that a
1650-
parse-time sanitizer cannot model: the patch is applied on connection/
1651-
stream, after sanitization has already run over a detached fragment.
1652-
`for` is legitimate only on <label>/<output>; anywhere else (notably
1653-
<template for>) it links the element to a patch target and teleports or
1654-
removes an arbitrary DOM range by id/marker name. `patchsrc` fetches
1655-
remote markup and is treated as a script-loading mechanism (CSP). Gated
1656-
on SAFE_FOR_XML so the removal groups with the other structural-threat
1657-
checks and stays overridable, consistent with the rest of the codebase.
1658-
PI range markers are already removed by _isUnsafeNode. */
1731+
(https://github.com/WICG/declarative-partial-updates).
1732+
Empirical note (Chrome 150, verified — see
1733+
test/declarative-patch-probe-v3.html): expansion is NOT applied after
1734+
sanitization. For the string path it fires during sanitize()'s own
1735+
parse, so the walk sees and sanitizes the fully materialized expanded
1736+
tree — teleports into MathML/SVG integration points included; a
1737+
weaponized `<template for>`->`<img onerror>` comes back with the handler
1738+
stripped. For the IN_PLACE path it fires on connection, before the walk.
1739+
Either way DOMPurify is NOT blind to the patch.
1740+
This removal is therefore defense-in-depth rather than the sole barrier:
1741+
it prevents live linkage from surviving into the OUTPUT and re-expanding
1742+
in the caller's context, and keeps behaviour deterministic if a future
1743+
engine defers expansion. `for` is legitimate only on <label>/<output>;
1744+
anywhere else (notably <template for>) it links the element to a patch
1745+
target and teleports or removes an arbitrary DOM range by id/marker name.
1746+
`patchsrc` fetches remote markup and is treated as a script-loading
1747+
mechanism (CSP). Gated on SAFE_FOR_XML so the removal groups with the
1748+
other structural-threat checks and stays overridable, consistent with
1749+
the rest of the codebase. PI range markers are already removed by
1750+
_isUnsafeNode. */
16591751
if (SAFE_FOR_XML && lcName === 'patchsrc') {
16601752
return false;
16611753
}
@@ -2062,6 +2154,11 @@ function createDOMPurify() {
20622154
keep using and whose return value they ignore — unsanitized. REPORT-2. */
20632155
const inPlace = IN_PLACE && typeof dirty !== 'string' && _isNode(dirty);
20642156
if (inPlace) {
2157+
/* Declarative-partial-updates / streaming pre-pass: sever every patch
2158+
linkage across the live tree BEFORE the walk, so no patch can fire
2159+
mid-walk and inject into an already-processed region. Runs first, so
2160+
it also covers the forbidden/clobbered roots that throw below. */
2161+
_neutralizePatchLinkage(dirty);
20652162
/* Do some early pre-sanitization to avoid unsafe root nodes.
20662163
Read nodeName through the cached prototype getter — a clobbering
20672164
child named "nodeName" on the form root would otherwise shadow
@@ -2071,6 +2168,9 @@ function createDOMPurify() {
20712168
if (typeof nn === 'string') {
20722169
const tagName = transformCaseFunc(nn);
20732170
if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) {
2171+
/* Fail closed on a live root: neutralize handlers/children before
2172+
throwing, exactly as the mid-walk abort path does. */
2173+
_neutralizeRoot(dirty);
20742174
throw typeErrorCreate('root node is forbidden and cannot be sanitized in-place');
20752175
}
20762176
}
@@ -2085,6 +2185,10 @@ function createDOMPurify() {
20852185
the application unsanitized. Refuse to sanitize such a root
20862186
the same way we refuse a forbidden tag. GHSA-r47g-fvhr-h676. */
20872187
if (_isClobbered(dirty)) {
2188+
/* Fail closed on a live clobbered root before throwing.
2189+
_neutralizeRoot's reads are clobber-safe (cached getters); the
2190+
form's non-clobbered descendants, e.g. an armed <img>, are scrubbed. */
2191+
_neutralizeRoot(dirty);
20882192
throw typeErrorCreate('root node is clobbered and cannot be sanitized in-place');
20892193
}
20902194
/* Sanitize attached shadow roots before the main iterator runs.
@@ -2165,6 +2269,14 @@ function createDOMPurify() {
21652269
} catch (error) {
21662270
if (inPlace) {
21672271
_neutralizeRoot(dirty);
2272+
/* Nodes _forceRemove'd earlier in the aborted walk are already
2273+
detached from the root, so _neutralizeRoot's subtree pass does not
2274+
reach them. Defuse them too, mirroring the success-path loop below. */
2275+
arrayForEach(DOMPurify.removed, entry => {
2276+
if (entry.element) {
2277+
_neutralizeSubtree(entry.element);
2278+
}
2279+
});
21682280
}
21692281
throw error;
21702282
}

dist/purify.es.mjs.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)