Skip to content

Commit 28d3dec

Browse files
authored
Cure53 fix hook detach (#1523)
* * 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
1 parent ba0474d commit 28d3dec

10 files changed

Lines changed: 170 additions & 21 deletions

File tree

dist/purify.cjs.js

Lines changed: 29 additions & 4 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: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1551,9 +1551,15 @@ function createDOMPurify() {
15511551
* @param currentNode to check for permission to exist
15521552
* @return true if node was killed, false if left alive
15531553
*/
1554-
const _sanitizeElements = function _sanitizeElements(currentNode) {
1554+
// eslint-disable-next-line complexity
1555+
const _sanitizeElements = function _sanitizeElements(currentNode, root) {
15551556
/* Execute a hook if present */
15561557
_executeHooks(hooks.beforeSanitizeElements, currentNode, null);
1558+
/* A hook may have detached the node — treat it as removed (see the
1559+
detached-node comment after the uponSanitizeElement hook below). */
1560+
if (currentNode !== root && getParentNode(currentNode) === null) {
1561+
return true;
1562+
}
15571563
/* Check if element is clobbered or can clobber */
15581564
if (_isClobbered(currentNode)) {
15591565
_forceRemove(currentNode);
@@ -1566,6 +1572,24 @@ function createDOMPurify() {
15661572
tagName,
15671573
allowedTags: ALLOWED_TAGS
15681574
});
1575+
/* A hook may have detached the node from the tree — a long-standing
1576+
user pattern (issue #469; draw.io-style foreignObject filtering).
1577+
Per the cached, unclobberable parentNode getter the node is
1578+
genuinely out of the tree, so it can reach neither the serialized
1579+
output nor an IN_PLACE live tree; treat it as removed and stop
1580+
processing it. Without this guard, the unsafe-node / namespace
1581+
checks below would call _forceRemove on a parentless node and hit
1582+
the REPORT-3 fail-closed throw — which exists for nodes DOMPurify
1583+
wants gone but *cannot* detach (clobbered / parentless roots), the
1584+
opposite of a node that is already safely gone. The walk root is
1585+
exempt: a detached IN_PLACE root is legitimate input and must still
1586+
be fully sanitized, and a kill-decision on it must keep hitting the
1587+
REPORT-3 throw. Nodes detached by hooks are the hook's
1588+
responsibility: they are not recorded in DOMPurify.removed and are
1589+
not neutralized by the post-walk IN_PLACE pass. */
1590+
if (currentNode !== root && getParentNode(currentNode) === null) {
1591+
return true;
1592+
}
15691593
/* Remove mXSS vectors, processing instructions and risky comments */
15701594
if (_isUnsafeNode(currentNode, tagName)) {
15711595
_forceRemove(currentNode);
@@ -1847,7 +1871,7 @@ function createDOMPurify() {
18471871
/* Execute a hook if present */
18481872
_executeHooks(hooks.uponSanitizeShadowNode, shadowNode, null);
18491873
/* Sanitize tags and elements */
1850-
_sanitizeElements(shadowNode);
1874+
_sanitizeElements(shadowNode, fragment);
18511875
/* Check attributes next */
18521876
_sanitizeAttributes(shadowNode);
18531877
/* Deep shadow DOM detected.
@@ -2113,7 +2137,8 @@ function createDOMPurify() {
21132137
_forceRemove(body.firstChild);
21142138
}
21152139
/* Get node iterator */
2116-
const nodeIterator = _createNodeIterator(inPlace ? dirty : body);
2140+
const walkRoot = inPlace ? dirty : body;
2141+
const nodeIterator = _createNodeIterator(walkRoot);
21172142
/* Now start iterating over the created document.
21182143
The walk runs inside an exception barrier (campaign-3 F2): a re-entrant
21192144
engine/custom-element mutation can detach a node mid-walk so
@@ -2126,7 +2151,7 @@ function createDOMPurify() {
21262151
try {
21272152
while (currentNode = nodeIterator.nextNode()) {
21282153
/* Sanitize tags and elements */
2129-
_sanitizeElements(currentNode);
2154+
_sanitizeElements(currentNode, walkRoot);
21302155
/* Check attributes next */
21312156
_sanitizeAttributes(currentNode);
21322157
/* Shadow DOM detected, sanitize it.

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.

dist/purify.js

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

dist/purify.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.min.js

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.min.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.

src/purify.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1748,10 +1748,17 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
17481748
* @param currentNode to check for permission to exist
17491749
* @return true if node was killed, false if left alive
17501750
*/
1751-
const _sanitizeElements = function (currentNode: any): boolean {
1751+
// eslint-disable-next-line complexity
1752+
const _sanitizeElements = function (currentNode: any, root: Node): boolean {
17521753
/* Execute a hook if present */
17531754
_executeHooks(hooks.beforeSanitizeElements, currentNode, null);
17541755

1756+
/* A hook may have detached the node — treat it as removed (see the
1757+
detached-node comment after the uponSanitizeElement hook below). */
1758+
if (currentNode !== root && getParentNode(currentNode) === null) {
1759+
return true;
1760+
}
1761+
17551762
/* Check if element is clobbered or can clobber */
17561763
if (_isClobbered(currentNode)) {
17571764
_forceRemove(currentNode);
@@ -1769,6 +1776,25 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
17691776
allowedTags: ALLOWED_TAGS,
17701777
});
17711778

1779+
/* A hook may have detached the node from the tree — a long-standing
1780+
user pattern (issue #469; draw.io-style foreignObject filtering).
1781+
Per the cached, unclobberable parentNode getter the node is
1782+
genuinely out of the tree, so it can reach neither the serialized
1783+
output nor an IN_PLACE live tree; treat it as removed and stop
1784+
processing it. Without this guard, the unsafe-node / namespace
1785+
checks below would call _forceRemove on a parentless node and hit
1786+
the REPORT-3 fail-closed throw — which exists for nodes DOMPurify
1787+
wants gone but *cannot* detach (clobbered / parentless roots), the
1788+
opposite of a node that is already safely gone. The walk root is
1789+
exempt: a detached IN_PLACE root is legitimate input and must still
1790+
be fully sanitized, and a kill-decision on it must keep hitting the
1791+
REPORT-3 throw. Nodes detached by hooks are the hook's
1792+
responsibility: they are not recorded in DOMPurify.removed and are
1793+
not neutralized by the post-walk IN_PLACE pass. */
1794+
if (currentNode !== root && getParentNode(currentNode) === null) {
1795+
return true;
1796+
}
1797+
17721798
/* Remove mXSS vectors, processing instructions and risky comments */
17731799
if (_isUnsafeNode(currentNode, tagName)) {
17741800
_forceRemove(currentNode);
@@ -2210,7 +2236,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
22102236
_executeHooks(hooks.uponSanitizeShadowNode, shadowNode, null);
22112237

22122238
/* Sanitize tags and elements */
2213-
_sanitizeElements(shadowNode);
2239+
_sanitizeElements(shadowNode, fragment);
22142240

22152241
/* Check attributes next */
22162242
_sanitizeAttributes(shadowNode);
@@ -2514,7 +2540,8 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
25142540
}
25152541

25162542
/* Get node iterator */
2517-
const nodeIterator = _createNodeIterator(inPlace ? dirty : body);
2543+
const walkRoot: Node = inPlace ? (dirty as Node) : body;
2544+
const nodeIterator = _createNodeIterator(walkRoot);
25182545

25192546
/* Now start iterating over the created document.
25202547
The walk runs inside an exception barrier (campaign-3 F2): a re-entrant
@@ -2528,7 +2555,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
25282555
try {
25292556
while ((currentNode = nodeIterator.nextNode())) {
25302557
/* Sanitize tags and elements */
2531-
_sanitizeElements(currentNode);
2558+
_sanitizeElements(currentNode, walkRoot);
25322559

25332560
/* Check attributes next */
25342561
_sanitizeAttributes(currentNode);

test/test-suite.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3254,6 +3254,53 @@
32543254

32553255
QUnit.module('Hooks — addHook / removeHook');
32563256

3257+
QUnit.test(
3258+
'uponSanitizeElement hook may detach the current node; it is treated as removed (issue #469 pattern, foreignObject filtering)',
3259+
(assert) => {
3260+
// A hook that removes the current node via parentNode.removeChild()
3261+
// must not trip the REPORT-3 parentless-node throw in _forceRemove:
3262+
// the throw exists for nodes DOMPurify wants gone but cannot detach,
3263+
// not for nodes a hook has already safely detached.
3264+
DOMPurify.addHook('uponSanitizeElement', (node) => {
3265+
if (node.nodeName === 'foreignObject' && node.parentNode) {
3266+
node.parentNode.removeChild(node);
3267+
}
3268+
});
3269+
const clean = DOMPurify.sanitize(
3270+
'<svg><switch><foreignObject><div>content</div></foreignObject></switch></svg>',
3271+
{
3272+
ADD_TAGS: ['foreignObject'],
3273+
USE_PROFILES: { svg: true, html: true },
3274+
}
3275+
);
3276+
assert.equal(clean, '<svg><switch></switch></svg>');
3277+
assert.notOk(
3278+
DOMPurify.removed.some(
3279+
(entry) =>
3280+
entry.element && entry.element.nodeName === 'foreignObject'
3281+
),
3282+
'hook-detached nodes are not claimed in DOMPurify.removed'
3283+
);
3284+
DOMPurify.removeHooks('uponSanitizeElement');
3285+
}
3286+
);
3287+
3288+
QUnit.test(
3289+
'beforeSanitizeElements hook may detach the current node; it is treated as removed',
3290+
(assert) => {
3291+
DOMPurify.addHook('beforeSanitizeElements', (node) => {
3292+
if (node.nodeName === 'ARTICLE' && node.parentNode) {
3293+
node.parentNode.removeChild(node);
3294+
}
3295+
});
3296+
assert.equal(
3297+
DOMPurify.sanitize('<div><article><b>x</b></article><i>y</i></div>'),
3298+
'<div><i>y</i></div>'
3299+
);
3300+
DOMPurify.removeHooks('beforeSanitizeElements');
3301+
}
3302+
);
3303+
32573304
QUnit.test(
32583305
'hook can add allowed tags / attributes on the fly',
32593306
(assert) => {

0 commit comments

Comments
 (0)