Skip to content

Commit 54f47de

Browse files
committed
fix: addressed a possible DoS with IN_PLACE and KEEP_CONTENT, thanks @n2duc
fix: fixed an OSV scanner false alert by updating the toml
1 parent ced539a commit 54f47de

10 files changed

Lines changed: 100 additions & 90 deletions

dist/purify.cjs.js

Lines changed: 18 additions & 19 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: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1580,7 +1580,7 @@ function createDOMPurify() {
15801580
* @param tagName the node's transformCaseFunc'd tag name
15811581
* @return true if the node was removed, false if kept
15821582
*/
1583-
const _sanitizeDisallowedNode = function _sanitizeDisallowedNode(currentNode, tagName) {
1583+
const _sanitizeDisallowedNode = function _sanitizeDisallowedNode(currentNode, tagName, root) {
15841584
/* Check if we have a custom element to handle */
15851585
if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName)) {
15861586
if (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)) {
@@ -1603,27 +1603,26 @@ function createDOMPurify() {
16031603
const childNodes = getChildNodes(currentNode);
16041604
if (childNodes && parentNode) {
16051605
const childCount = childNodes.length;
1606-
/* In-place: hoist the *original* children so the iterator visits
1607-
and sanitises them through the same allowlist pass as every other
1608-
node. The caller built the tree in the live document, so the
1609-
originals carry already-queued resource events (`<img onerror>`,
1610-
`<video>`/`<audio>` error, lazy/`onload`, …); cloning would leave
1611-
those originals detached but still armed, firing in page scope
1612-
while the returned tree looked clean. Moving is safe in-place: the
1613-
root is pre-validated as an allowed tag and so is never the node
1614-
being removed, which keeps `parentNode` inside the iterator root
1615-
and the relocated child inside the serialised tree.
1616-
Otherwise (string / DOM-copy paths): clone. The iterator is rooted
1617-
at — and the result serialised from — `body`, so a restrictive
1618-
ALLOWED_TAGS that removes `body` itself must leave its content in
1619-
place, which only cloning does; and those paths parse into an
1620-
inert document, so their discarded originals never had a queued
1621-
event to neutralise.
1606+
/* Hoist by moving each child up one level rather than deep-cloning
1607+
it. Moving transfers every descendant exactly once, so a chain of
1608+
nested disallowed elements costs O(n) instead of the O(n^2) that
1609+
re-cloning the shrinking subtree at each level produced; it also
1610+
empties the removed original, so `DOMPurify.removed` no longer
1611+
pins whole subtrees. Moving preserves the in-place guarantee too:
1612+
an original carrying already-queued resource events (`<img
1613+
onerror>`, `<video>`/`<audio>` error, lazy/`onload`, …) is
1614+
relocated and sanitised rather than left detached but still armed.
1615+
The sole case that must clone is removing the walk root itself.
1616+
The result is serialised from the root's subtree, so a restrictive
1617+
ALLOWED_TAGS that strips the root (`body` on the string path) must
1618+
leave the content inside it, which only cloning does. In IN_PLACE
1619+
the root is pre-validated as an allowed tag and so is never removed
1620+
here, so that path always takes the move branch.
16221621
`childNodes` is live; a tail-to-head walk keeps `childNodes[i]`
16231622
valid whether we move (drops the trailing entry) or clone (leaves
16241623
the list intact). */
16251624
for (let i = childCount - 1; i >= 0; --i) {
1626-
const hoisted = IN_PLACE ? childNodes[i] : cloneNode(childNodes[i], true);
1625+
const hoisted = currentNode === root ? cloneNode(childNodes[i], true) : childNodes[i];
16271626
parentNode.insertBefore(hoisted, getNextSibling(currentNode));
16281627
}
16291628
}
@@ -1686,7 +1685,7 @@ function createDOMPurify() {
16861685
}
16871686
/* Remove element if anything forbids its presence */
16881687
if (FORBID_TAGS[tagName] || !(EXTRA_ELEMENT_HANDLING.tagCheck instanceof Function && EXTRA_ELEMENT_HANDLING.tagCheck(tagName)) && !ALLOWED_TAGS[tagName]) {
1689-
const removed = _sanitizeDisallowedNode(currentNode, tagName);
1688+
const removed = _sanitizeDisallowedNode(currentNode, tagName, root);
16901689
/* A false return means the node is a custom element kept via
16911690
CUSTOM_ELEMENT_HANDLING - the only keep path through
16921691
_sanitizeDisallowedNode. Run afterSanitizeElements on it so the

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: 18 additions & 19 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.

osv-scanner.toml

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,19 @@
1919
# The legacy-browser harness pins 1.30-1.50.1 on purpose and keeps its own
2020
# suppression at test/legacy-playwright/osv-scanner.toml.
2121
#
22-
# 2026-07: the two entries below are STOPGAPS, not verdicts. Both advisories
23-
# already have published fixes that the current dependency ranges accept,
24-
# and `npm update js-yaml brace-expansion` moves the tree to
25-
# 1.1.16 / 2.1.2 / 5.0.7 and 4.3.0 with the build and jsdom suite green.
26-
# Per the policy above, drop both entries once that lockfile refresh lands
27-
# rather than carrying them to the expiry date.
22+
# 2026-07: the first two entries below are STOPGAPS. Both advisories have
23+
# published fixes the current ranges accept, and `npm update js-yaml
24+
# brace-expansion` moves the tree to 1.1.16 / 2.1.2 / 5.0.8 and 4.3.0 with the
25+
# build and jsdom suite green. Per the policy above, drop those two once that
26+
# lockfile refresh lands rather than carrying them to the expiry date.
27+
#
28+
# The third entry (GHSA-mh99-v99m-4gvg) is different and is NOT cleared by a
29+
# refresh: its only fix is on the 5.x line (5.0.8). The top-level
30+
# brace-expansion updates to 5.0.8 and clears, but the transitive 1.x / 2.x
31+
# copies pulled in by eslint / typescript-estree / test-exclude top out at
32+
# 1.1.16 / 2.1.2 - both below 5.0.8, with no in-line backport - so they stay
33+
# flagged until those parents adopt brace-expansion 5.x (or the copies are
34+
# deduped out). Drop this entry then, not on a version bump of the dep alone.
2835

2936
[[IgnoredVulns]]
3037
id = "GHSA-3jxr-9vmj-r5cp"
@@ -35,3 +42,8 @@ reason = "brace-expansion (CVE-2026-13149): exponential-time expansion of consec
3542
id = "GHSA-52cp-r559-cp3m"
3643
ignoreUntil = 2027-07-22
3744
reason = "js-yaml (CVE-2026-59869): quadratic CPU time on a chain of mappings using merge keys, availability-only. Dev-only path - pulled in by eslint / cosmiconfig / nyc config loading at 4.2.0 (package.json already pins an override of ^4.2.0, which admits the fix). Only repo-owned config files are parsed, never untrusted YAML, and it is absent from the published artifact. Fixed in 4.3.0, and in 3.15.0 on the 3.x line."
45+
46+
[[IgnoredVulns]]
47+
id = "GHSA-mh99-v99m-4gvg"
48+
ignoreUntil = 2027-07-22
49+
reason = "brace-expansion (CVE-2026-14257): unbounded expansion length causing an out-of-memory process crash, availability-only (CVSS 7.5). OSV range is [0, 5.0.8) with the fix on the 5.x line only. Dev-only path - the top-level copy updates to 5.0.8 and clears, but the transitive 1.1.16 / 2.1.2 copies (eslint / typescript-estree / test-exclude) have no in-line backport and stay below 5.0.8 until those parents adopt brace-expansion 5.x. DOMPurify ships no runtime dependencies, so this is absent from the published artifact, and CI only ever expands repo-owned glob patterns, never attacker-controlled input."

src/purify.ts

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1779,7 +1779,8 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
17791779
*/
17801780
const _sanitizeDisallowedNode = function (
17811781
currentNode: any,
1782-
tagName: string
1782+
tagName: string,
1783+
root: Node
17831784
): boolean {
17841785
/* Check if we have a custom element to handle */
17851786
if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName)) {
@@ -1813,31 +1814,31 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
18131814
if (childNodes && parentNode) {
18141815
const childCount = childNodes.length;
18151816

1816-
/* In-place: hoist the *original* children so the iterator visits
1817-
and sanitises them through the same allowlist pass as every other
1818-
node. The caller built the tree in the live document, so the
1819-
originals carry already-queued resource events (`<img onerror>`,
1820-
`<video>`/`<audio>` error, lazy/`onload`, …); cloning would leave
1821-
those originals detached but still armed, firing in page scope
1822-
while the returned tree looked clean. Moving is safe in-place: the
1823-
root is pre-validated as an allowed tag and so is never the node
1824-
being removed, which keeps `parentNode` inside the iterator root
1825-
and the relocated child inside the serialised tree.
1826-
1827-
Otherwise (string / DOM-copy paths): clone. The iterator is rooted
1828-
at — and the result serialised from — `body`, so a restrictive
1829-
ALLOWED_TAGS that removes `body` itself must leave its content in
1830-
place, which only cloning does; and those paths parse into an
1831-
inert document, so their discarded originals never had a queued
1832-
event to neutralise.
1817+
/* Hoist by moving each child up one level rather than deep-cloning
1818+
it. Moving transfers every descendant exactly once, so a chain of
1819+
nested disallowed elements costs O(n) instead of the O(n^2) that
1820+
re-cloning the shrinking subtree at each level produced; it also
1821+
empties the removed original, so `DOMPurify.removed` no longer
1822+
pins whole subtrees. Moving preserves the in-place guarantee too:
1823+
an original carrying already-queued resource events (`<img
1824+
onerror>`, `<video>`/`<audio>` error, lazy/`onload`, …) is
1825+
relocated and sanitised rather than left detached but still armed.
1826+
1827+
The sole case that must clone is removing the walk root itself.
1828+
The result is serialised from the root's subtree, so a restrictive
1829+
ALLOWED_TAGS that strips the root (`body` on the string path) must
1830+
leave the content inside it, which only cloning does. In IN_PLACE
1831+
the root is pre-validated as an allowed tag and so is never removed
1832+
here, so that path always takes the move branch.
18331833
18341834
`childNodes` is live; a tail-to-head walk keeps `childNodes[i]`
18351835
valid whether we move (drops the trailing entry) or clone (leaves
18361836
the list intact). */
18371837
for (let i = childCount - 1; i >= 0; --i) {
1838-
const hoisted = IN_PLACE
1839-
? childNodes[i]
1840-
: cloneNode(childNodes[i], true);
1838+
const hoisted =
1839+
currentNode === root
1840+
? cloneNode(childNodes[i], true)
1841+
: childNodes[i];
18411842
parentNode.insertBefore(hoisted, getNextSibling(currentNode));
18421843
}
18431844
}
@@ -1918,7 +1919,7 @@ function createDOMPurify(window: WindowLike = getGlobal()): DOMPurify {
19181919
) &&
19191920
!ALLOWED_TAGS[tagName])
19201921
) {
1921-
const removed = _sanitizeDisallowedNode(currentNode, tagName);
1922+
const removed = _sanitizeDisallowedNode(currentNode, tagName, root);
19221923

19231924
/* A false return means the node is a custom element kept via
19241925
CUSTOM_ELEMENT_HANDLING - the only keep path through

0 commit comments

Comments
 (0)