@@ -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 }
0 commit comments