From 35d84710b08c2514ea01e3a7fe0374ca1c4a25de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Arboleda?= Date: Fri, 5 Jun 2026 12:21:31 -0500 Subject: [PATCH] fix: replace recursive toString and clone with iterative implementations Deeply nested selector trees (built programmatically or via parsing) could crash Node.js with a RangeError from exceeding the call stack limit. - Container/Root/Pseudo toString() now uses an explicit stack instead of recursive String(child) calls. - cloneNode() now processes the nodes array via a work queue instead of recursing. Adds regression tests for both code paths Refs: CVE-2026-9358 --- src/__tests__/security.mjs | 36 ++++++++++++++++++++++++ src/selectors/container.js | 57 +++++++++++++++++++++++++++++++++++++- src/selectors/node.js | 41 ++++++++++++++++++--------- src/selectors/pseudo.js | 10 ++----- src/selectors/root.js | 8 ++---- 5 files changed, 124 insertions(+), 28 deletions(-) create mode 100644 src/__tests__/security.mjs diff --git a/src/__tests__/security.mjs b/src/__tests__/security.mjs new file mode 100644 index 0000000..a0f25d7 --- /dev/null +++ b/src/__tests__/security.mjs @@ -0,0 +1,36 @@ +// PoC scripts adapted from: https://gist.github.com/bx33661/581e3a38134601c04e19b4dfc9b459b9 +// Credit: https://github.com/bx33661 + +import test from 'ava'; +import postcss from 'postcss'; + +import parser from '../index.js'; + +function buildDeepPseudoTree (depth) { + const { pseudo, selector, tag } = parser; + let current = selector({ nodes: [tag({value: 'a'})] }); + for (let i = 0; i < depth; i++) { + const p = pseudo({ value: ':not', nodes: [current] }); + current = selector({ nodes: [p] }); + } + return current; +} + +test('toString() does not stack overflow on deeply nested programmatic tree', t => { + const deep = buildDeepPseudoTree(1000); + t.notThrows(() => deep.toString()); +}); + +test('processSync() with deep selector does not stack overflow', t => { + const depth = 1000; + const malicious = ':where('.repeat(depth) + 'a' + ')'.repeat(depth); + const rule = postcss.parse(malicious + ' {}').first; + t.notThrows(() => + parser((selectors) => {}).processSync(rule, { updateSelector: true }) + ); +}); + +test('clone() does not stack overflow on deeply nested programmatic tree', t => { + const deep = buildDeepPseudoTree(1100); + t.notThrows(() => deep.clone()); +}); diff --git a/src/selectors/container.js b/src/selectors/container.js index ee347b4..48d8766 100644 --- a/src/selectors/container.js +++ b/src/selectors/container.js @@ -1,6 +1,61 @@ import Node from './node'; import * as types from './types'; +export function toStringIterative (root) { + const parts = []; + const stack = [root]; + + while (stack.length > 0) { + const item = stack.pop(); + + if (typeof item === 'string') { + parts.push(item); + continue; + } + + const node = item; + + if (!node.nodes) { + parts.push(node.toString()); + continue; + } + + const children = node.nodes; + + if (node.type === types.ROOT) { + if (node.trailingComma) { + stack.push(','); + } + for (let i = children.length - 1; i >= 0; i--) { + if (i < children.length - 1) { + stack.push(','); + } + stack.push(children[i]); + } + } else if (node.type === types.PSEUDO) { + stack.push(node.rawSpaceAfter); + if (children.length > 0) { + stack.push(')'); + for (let i = children.length - 1; i >= 0; i--) { + if (i < children.length - 1) { + stack.push(','); + } + stack.push(children[i]); + } + stack.push('('); + } + stack.push(node.stringifyProperty('value')); + stack.push(node.rawSpaceBefore); + } else { + for (let i = children.length - 1; i >= 0; i--) { + stack.push(children[i]); + } + } + } + + return parts.join(''); +} + export default class Container extends Node { constructor (opts) { super(opts); @@ -325,6 +380,6 @@ export default class Container extends Node { } toString () { - return this.map(String).join(''); + return toStringIterative(this); } } diff --git a/src/selectors/node.js b/src/selectors/node.js index 9df343e..3bddb81 100644 --- a/src/selectors/node.js +++ b/src/selectors/node.js @@ -6,22 +6,37 @@ let cloneNode = function (obj, parent) { } let cloned = new obj.constructor(); + const workQueue = [{source: obj, cloned, parent}]; + let head = 0; - for ( let i in obj ) { - if ( !obj.hasOwnProperty(i) ) { - continue; - } - let value = obj[i]; - let type = typeof value; + while (head < workQueue.length) { + const {source: src, cloned: dst, parent: p} = workQueue[head++]; - if ( i === 'parent' && type === 'object' ) { - if (parent) { - cloned[i] = parent; + for (let i in src) { + if (!src.hasOwnProperty(i)) { + continue; + } + let value = src[i]; + let type = typeof value; + + if (i === 'parent' && type === 'object') { + if (p) { + dst[i] = p; + } + } else if (i === 'nodes' && value instanceof Array) { + dst[i] = value.map(j => { + if (typeof j !== 'object' || j === null) { + return j; + } + const childCloned = new j.constructor(); + workQueue.push({source: j, cloned: childCloned, parent: dst}); + return childCloned; + }); + } else if (value instanceof Array) { + dst[i] = value.map(j => cloneNode(j, dst)); + } else { + dst[i] = cloneNode(value, dst); } - } else if ( value instanceof Array ) { - cloned[i] = value.map( j => cloneNode(j, cloned) ); - } else { - cloned[i] = cloneNode(value, cloned); } } diff --git a/src/selectors/pseudo.js b/src/selectors/pseudo.js index 8d1e7cf..035e6ae 100644 --- a/src/selectors/pseudo.js +++ b/src/selectors/pseudo.js @@ -1,4 +1,4 @@ -import Container from './container'; +import Container, {toStringIterative} from './container'; import {PSEUDO} from './types'; export default class Pseudo extends Container { @@ -8,12 +8,6 @@ export default class Pseudo extends Container { } toString () { - let params = this.length ? '(' + this.map(String).join(',') + ')' : ''; - return [ - this.rawSpaceBefore, - this.stringifyProperty("value"), - params, - this.rawSpaceAfter, - ].join(''); + return toStringIterative(this); } } diff --git a/src/selectors/root.js b/src/selectors/root.js index 965d426..b5ddec2 100644 --- a/src/selectors/root.js +++ b/src/selectors/root.js @@ -1,4 +1,4 @@ -import Container from './container'; +import Container, {toStringIterative} from './container'; import {ROOT} from './types'; export default class Root extends Container { @@ -8,11 +8,7 @@ export default class Root extends Container { } toString () { - let str = this.reduce((memo, selector) => { - memo.push(String(selector)); - return memo; - }, []).join(','); - return this.trailingComma ? str + ',' : str; + return toStringIterative(this); } error (message, options) {