From 6a20ac03aa1e6d7e83359beb662eb98a797ff1a2 Mon Sep 17 00:00:00 2001 From: Max Thirouin Date: Thu, 4 Jun 2026 14:45:05 +0200 Subject: [PATCH 1/7] Add failing tests for #315 --- src/__tests__/recursion.mjs | 67 +++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/__tests__/recursion.mjs diff --git a/src/__tests__/recursion.mjs b/src/__tests__/recursion.mjs new file mode 100644 index 0000000..2222b9e --- /dev/null +++ b/src/__tests__/recursion.mjs @@ -0,0 +1,67 @@ +import ava from 'ava'; +import parser from '../index.js'; + +// Regression tests for CVE-2026-9358 / SNYK-JS-POSTCSSSELECTORPARSER-16873882: +// uncontrolled recursion when parsing or serializing deeply nested selectors +// must surface as a catchable Error instead of overflowing the call stack. + +const DEFAULT_MAX = 256; + +// Build a selector string of `depth` nested `:not(...)` pseudo classes. +const nest = depth => ':not('.repeat(depth) + 'a' + ')'.repeat(depth); + +// Build a deeply nested AST programmatically, bypassing the parse-time guard, +// so the serialization (toString) guard is exercised on its own. +function buildDeepAst (depth) { + const root = parser.root({}); + const top = parser.selector({}); + root.append(top); + let current = top; + for (let i = 0; i < depth; i++) { + const pseudo = parser.pseudo({value: ':not'}); + const sel = parser.selector({}); + pseudo.append(sel); + current.append(pseudo); + current = sel; + } + current.append(parser.tag({value: 'a'})); + return root; +} + +ava('reasonably nested selectors still round-trip', t => { + const input = nest(10); + t.is(parser().processSync(input), input); +}); + +ava('parsing beyond the max nesting depth throws a catchable error', t => { + const error = t.throws( + () => parser().astSync(nest(DEFAULT_MAX + 50)), + {instanceOf: Error} + ); + t.regex(error.message, /nesting depth exceeds the maximum/); + t.false(error instanceof RangeError, 'should not be a stack overflow RangeError'); +}); + +ava('serializing beyond the max nesting depth throws a catchable error', t => { + const deep = buildDeepAst(1000); + const error = t.throws(() => deep.toString(), {instanceOf: Error}); + t.regex(error.message, /nesting depth exceeds the maximum/); + t.false(error instanceof RangeError, 'should not be a stack overflow RangeError'); +}); + +ava('the original PoC payload no longer crashes the process', t => { + t.throws(() => parser().processSync(nest(50000)), {instanceOf: Error}); +}); + +ava('maxNestingDepth option can tighten the limit', t => { + const error = t.throws( + () => parser().astSync(nest(20), {maxNestingDepth: 5}), + {instanceOf: Error} + ); + t.regex(error.message, /maximum of 5/); +}); + +ava('maxNestingDepth option can loosen the limit', t => { + const input = nest(DEFAULT_MAX + 20); + t.notThrows(() => parser().astSync(input, {maxNestingDepth: DEFAULT_MAX + 100})); +}); From 9f863b22d1be70d20ab6fb9fbb591d163455baae Mon Sep 17 00:00:00 2001 From: Max Thirouin Date: Thu, 4 Jun 2026 14:48:26 +0200 Subject: [PATCH 2/7] Fix CVE-2026-9358 (NVD) / SNYK-JS-POSTCSSSELECTORPARSER-16873882 --- postcss-selector-parser.d.ts | 6 ++++++ src/parser.js | 12 ++++++++++++ src/processor.js | 2 ++ src/selectors/container.js | 13 +++++++++++-- src/selectors/pseudo.js | 13 ++++++++++--- src/selectors/root.js | 4 ++-- 6 files changed, 43 insertions(+), 7 deletions(-) diff --git a/postcss-selector-parser.d.ts b/postcss-selector-parser.d.ts index f98bde5..1fbd1e1 100644 --- a/postcss-selector-parser.d.ts +++ b/postcss-selector-parser.d.ts @@ -94,6 +94,12 @@ declare namespace parser { * processing back onto the rule when done. Default: true. */ updateSelector: boolean; + /** + * The maximum selector nesting depth allowed while parsing. Selectors + * nested deeper than this (e.g. `:not(:not(:not(…)))`) raise an error + * instead of overflowing the call stack. Default: 256. + */ + maxNestingDepth: number; } class Processor< TransformType = never, diff --git a/src/parser.js b/src/parser.js index 19fd68f..a2d198d 100644 --- a/src/parser.js +++ b/src/parser.js @@ -1,4 +1,5 @@ import Root from './selectors/root'; +import {MAX_NESTING_DEPTH} from './selectors/container'; import Selector from './selectors/selector'; import ClassName from './selectors/className'; import Comment from './selectors/comment'; @@ -117,6 +118,10 @@ export default class Parser { this.rule = rule; this.options = Object.assign({lossy: false, safe: false}, options); this.position = 0; + this.nestingDepth = 0; + this.maxNestingDepth = typeof this.options.maxNestingDepth === 'number' + ? this.options.maxNestingDepth + : MAX_NESTING_DEPTH; this.css = typeof this.rule === 'string' ? this.rule : this.rule.selector; @@ -693,6 +698,12 @@ export default class Parser { let unbalanced = 1; this.position ++; if (last && last.type === types.PSEUDO) { + if (++this.nestingDepth > this.maxNestingDepth) { + this.error( + `Cannot parse selector: nesting depth exceeds the maximum of ${this.maxNestingDepth}.`, + {index: this.currToken[TOKEN.START_POS]} + ); + } const selector = new Selector({ source: {start: tokenStart(this.tokens[this.position])}, sourceIndex: this.tokens[this.position][TOKEN.START_POS], @@ -716,6 +727,7 @@ export default class Parser { } } this.current = cache; + this.nestingDepth --; } else { // I think this case should be an error. It's used to implement a basic parse of media queries // but I don't think it's a good idea. diff --git a/src/processor.js b/src/processor.js index 83b0c8e..5ab85f7 100644 --- a/src/processor.js +++ b/src/processor.js @@ -31,8 +31,10 @@ export default class Processor { } _parseOptions (options) { + let merged = Object.assign({}, this.options, options); return { lossy: this._isLossy(options), + maxNestingDepth: merged.maxNestingDepth, }; } diff --git a/src/selectors/container.js b/src/selectors/container.js index ee347b4..e9d5f23 100644 --- a/src/selectors/container.js +++ b/src/selectors/container.js @@ -1,6 +1,15 @@ import Node from './node'; import * as types from './types'; +/** + * The default maximum selector nesting depth allowed when parsing or + * serializing a selector. Going beyond this would otherwise recurse deeply + * enough to overflow the call stack (CVE-2026-9358 / CWE-674). Real-world + * selectors never get anywhere near this, so it acts purely as a safety net + * that turns an uncatchable stack overflow into a catchable error. + */ +export const MAX_NESTING_DEPTH = 256; + export default class Container extends Node { constructor (opts) { super(opts); @@ -324,7 +333,7 @@ export default class Container extends Node { return this.nodes.sort(callback); } - toString () { - return this.map(String).join(''); + toString (depth = 0) { + return this.map(child => child.toString(depth)).join(''); } } diff --git a/src/selectors/pseudo.js b/src/selectors/pseudo.js index 8d1e7cf..97219a7 100644 --- a/src/selectors/pseudo.js +++ b/src/selectors/pseudo.js @@ -1,4 +1,4 @@ -import Container from './container'; +import Container, {MAX_NESTING_DEPTH} from './container'; import {PSEUDO} from './types'; export default class Pseudo extends Container { @@ -7,8 +7,15 @@ export default class Pseudo extends Container { this.type = PSEUDO; } - toString () { - let params = this.length ? '(' + this.map(String).join(',') + ')' : ''; + toString (depth = 0) { + if (depth >= MAX_NESTING_DEPTH) { + throw new Error( + `Cannot serialize selector: nesting depth exceeds the maximum of ${MAX_NESTING_DEPTH}.` + ); + } + let params = this.length + ? '(' + this.map(child => child.toString(depth + 1)).join(',') + ')' + : ''; return [ this.rawSpaceBefore, this.stringifyProperty("value"), diff --git a/src/selectors/root.js b/src/selectors/root.js index 965d426..5f56229 100644 --- a/src/selectors/root.js +++ b/src/selectors/root.js @@ -7,9 +7,9 @@ export default class Root extends Container { this.type = ROOT; } - toString () { + toString (depth = 0) { let str = this.reduce((memo, selector) => { - memo.push(String(selector)); + memo.push(selector.toString(depth)); return memo; }, []).join(','); return this.trailingComma ? str + ',' : str; From 21dfd4f221354cd18359b167d34c535daa3cbd86 Mon Sep 17 00:00:00 2001 From: Max Thirouin Date: Thu, 4 Jun 2026 14:56:28 +0200 Subject: [PATCH 3/7] Improve recursion testing following @copilot review --- src/__tests__/recursion.mjs | 41 +++++++++++++++---------------------- 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/src/__tests__/recursion.mjs b/src/__tests__/recursion.mjs index 2222b9e..061d777 100644 --- a/src/__tests__/recursion.mjs +++ b/src/__tests__/recursion.mjs @@ -4,8 +4,11 @@ import parser from '../index.js'; // Regression tests for CVE-2026-9358 / SNYK-JS-POSTCSSSELECTORPARSER-16873882: // uncontrolled recursion when parsing or serializing deeply nested selectors // must surface as a catchable Error instead of overflowing the call stack. - -const DEFAULT_MAX = 256; +// +// The default-limit tests assert only the stable contract — a controlled Error +// that is NOT a RangeError stack overflow — so they don't break if the default +// is tuned. Tests that assert on a specific limit always set it explicitly via +// the `maxNestingDepth` option, so they never depend on the default value. // Build a selector string of `depth` nested `:not(...)` pseudo classes. const nest = depth => ':not('.repeat(depth) + 'a' + ')'.repeat(depth); @@ -33,35 +36,25 @@ ava('reasonably nested selectors still round-trip', t => { t.is(parser().processSync(input), input); }); -ava('parsing beyond the max nesting depth throws a catchable error', t => { - const error = t.throws( - () => parser().astSync(nest(DEFAULT_MAX + 50)), - {instanceOf: Error} - ); - t.regex(error.message, /nesting depth exceeds the maximum/); - t.false(error instanceof RangeError, 'should not be a stack overflow RangeError'); +ava('parsing a deeply nested hostile selector throws instead of overflowing the stack', t => { + const error = t.throws(() => parser().astSync(nest(1000)), {instanceOf: Error}); + t.false(error instanceof RangeError, 'should be a controlled error, not a stack overflow'); }); -ava('serializing beyond the max nesting depth throws a catchable error', t => { +ava('serializing a deeply nested AST throws instead of overflowing the stack', t => { const deep = buildDeepAst(1000); const error = t.throws(() => deep.toString(), {instanceOf: Error}); - t.regex(error.message, /nesting depth exceeds the maximum/); - t.false(error instanceof RangeError, 'should not be a stack overflow RangeError'); + t.false(error instanceof RangeError, 'should be a controlled error, not a stack overflow'); }); -ava('the original PoC payload no longer crashes the process', t => { - t.throws(() => parser().processSync(nest(50000)), {instanceOf: Error}); -}); - -ava('maxNestingDepth option can tighten the limit', t => { +ava('maxNestingDepth option controls the limit in both directions', t => { + const input = nest(40); + // A low limit rejects it and reports the configured value... const error = t.throws( - () => parser().astSync(nest(20), {maxNestingDepth: 5}), + () => parser().astSync(input, {maxNestingDepth: 10}), {instanceOf: Error} ); - t.regex(error.message, /maximum of 5/); -}); - -ava('maxNestingDepth option can loosen the limit', t => { - const input = nest(DEFAULT_MAX + 20); - t.notThrows(() => parser().astSync(input, {maxNestingDepth: DEFAULT_MAX + 100})); + t.regex(error.message, /\b10\b/); + // ...while a high limit accepts the very same selector. + t.notThrows(() => parser().astSync(input, {maxNestingDepth: 100})); }); From 815b384a864c14e8082a0b826408b343ef459b9e Mon Sep 17 00:00:00 2001 From: Max Thirouin Date: Thu, 4 Jun 2026 15:11:42 +0200 Subject: [PATCH 4/7] Improve recursion implementation following @copilot review --- postcss-selector-parser.d.ts | 12 ++++++-- src/__tests__/recursion.mjs | 31 +++++++++++++++++++++ src/parser.js | 53 ++++++++++++++++++++---------------- src/processor.js | 15 +++++++--- src/selectors/container.js | 13 ++------- src/selectors/pseudo.js | 12 ++++---- src/selectors/root.js | 4 +-- src/util/index.js | 1 + src/util/maxNestingDepth.js | 21 ++++++++++++++ 9 files changed, 114 insertions(+), 48 deletions(-) create mode 100644 src/util/maxNestingDepth.js diff --git a/postcss-selector-parser.d.ts b/postcss-selector-parser.d.ts index 1fbd1e1..9f0a233 100644 --- a/postcss-selector-parser.d.ts +++ b/postcss-selector-parser.d.ts @@ -101,6 +101,14 @@ declare namespace parser { */ maxNestingDepth: number; } + interface StringifyOptions { + /** + * The maximum selector nesting depth allowed while serializing. + * Serializing an AST nested deeper than this raises an error instead of + * overflowing the call stack. Default: 256. + */ + maxNestingDepth?: number; + } class Processor< TransformType = never, SyncSelectorsType extends Selectors | never = Selectors @@ -207,7 +215,7 @@ declare namespace parser { * @param {string} valueEscaped optional. the escaped value of the property. */ appendToPropertyAndEscape(name: string, value: any, valueEscaped: string): void; - toString(): string; + toString(options?: StringifyOptions): string; } interface ContainerOptions extends NodeOptions { nodes?: Array; @@ -302,7 +310,7 @@ declare namespace parser { some(callback: (node: Child) => boolean): boolean; filter(callback: (node: Child) => boolean): Child[]; sort(callback: (nodeA: Child, nodeB: Child) => number): Child[]; - toString(): string; + toString(options?: StringifyOptions): string; } function isContainer(node: any): node is Root | Selector | Pseudo; diff --git a/src/__tests__/recursion.mjs b/src/__tests__/recursion.mjs index 061d777..924b331 100644 --- a/src/__tests__/recursion.mjs +++ b/src/__tests__/recursion.mjs @@ -58,3 +58,34 @@ ava('maxNestingDepth option controls the limit in both directions', t => { // ...while a high limit accepts the very same selector. t.notThrows(() => parser().astSync(input, {maxNestingDepth: 100})); }); + +ava('the parse and serialize limits stay in sync through processSync', t => { + const input = nest(40); + // With a raised limit, parsing AND the implicit toString() in processSync + // must both succeed and round-trip the selector unchanged. + t.is(parser().processSync(input, {maxNestingDepth: 100}), input); + // With a low limit, the same call fails (at parse time) instead of crashing. + t.throws(() => parser().processSync(input, {maxNestingDepth: 10}), {instanceOf: Error}); +}); + +ava('toString accepts an explicit maxNestingDepth for programmatic ASTs', t => { + const deep = buildDeepAst(40); + // Default limit (256) serializes it fine. + t.notThrows(() => deep.toString()); + // A tightened limit rejects it with a controlled error... + const error = t.throws(() => deep.toString({maxNestingDepth: 10}), {instanceOf: Error}); + t.false(error instanceof RangeError); + t.regex(error.message, /\b10\b/); +}); + +ava('invalid maxNestingDepth values fall back to the safe default', t => { + // NaN, Infinity, negatives and non-numbers must not disable the guard: + // a hostile payload still throws a controlled error rather than crashing. + for (const bad of [NaN, Infinity, -1, '256', null]) { + const error = t.throws( + () => parser().astSync(nest(1000), {maxNestingDepth: bad}), + {instanceOf: Error} + ); + t.false(error instanceof RangeError, `value ${String(bad)} should keep the guard active`); + } +}); diff --git a/src/parser.js b/src/parser.js index a2d198d..f0c7628 100644 --- a/src/parser.js +++ b/src/parser.js @@ -1,5 +1,4 @@ import Root from './selectors/root'; -import {MAX_NESTING_DEPTH} from './selectors/container'; import Selector from './selectors/selector'; import ClassName from './selectors/className'; import Comment from './selectors/comment'; @@ -17,7 +16,7 @@ import tokenize, {FIELDS as TOKEN} from './tokenize'; import * as tokens from './tokenTypes'; import * as types from './selectors/types'; -import {unesc, getProp, ensureObject} from './util'; +import {unesc, getProp, ensureObject, resolveMaxNestingDepth} from './util'; const WHITESPACE_TOKENS = { [tokens.space]: true, @@ -119,9 +118,7 @@ export default class Parser { this.options = Object.assign({lossy: false, safe: false}, options); this.position = 0; this.nestingDepth = 0; - this.maxNestingDepth = typeof this.options.maxNestingDepth === 'number' - ? this.options.maxNestingDepth - : MAX_NESTING_DEPTH; + this.maxNestingDepth = resolveMaxNestingDepth(this.options.maxNestingDepth); this.css = typeof this.rule === 'string' ? this.rule : this.rule.selector; @@ -698,12 +695,6 @@ export default class Parser { let unbalanced = 1; this.position ++; if (last && last.type === types.PSEUDO) { - if (++this.nestingDepth > this.maxNestingDepth) { - this.error( - `Cannot parse selector: nesting depth exceeds the maximum of ${this.maxNestingDepth}.`, - {index: this.currToken[TOKEN.START_POS]} - ); - } const selector = new Selector({ source: {start: tokenStart(this.tokens[this.position])}, sourceIndex: this.tokens[this.position][TOKEN.START_POS], @@ -711,23 +702,37 @@ export default class Parser { const cache = this.current; last.append(selector); this.current = selector; - while (this.position < this.tokens.length && unbalanced) { - if (this.currToken[TOKEN.TYPE] === tokens.openParenthesis) { - unbalanced ++; - } - if (this.currToken[TOKEN.TYPE] === tokens.closeParenthesis) { - unbalanced --; + // Track nesting depth so deeply nested pseudo selectors raise a + // catchable error instead of overflowing the call stack. The + // counter is restored in `finally` so the parser is never left in + // an inconsistent state, even on the error path. + this.nestingDepth ++; + try { + if (this.nestingDepth > this.maxNestingDepth) { + this.error( + `Cannot parse selector: nesting depth exceeds the maximum of ${this.maxNestingDepth}.`, + {index: this.currToken[TOKEN.START_POS]} + ); } - if (unbalanced) { - this.parse(); - } else { - this.current.source.end = tokenEnd(this.currToken); - this.current.parent.source.end = tokenEnd(this.currToken); - this.position ++; + while (this.position < this.tokens.length && unbalanced) { + if (this.currToken[TOKEN.TYPE] === tokens.openParenthesis) { + unbalanced ++; + } + if (this.currToken[TOKEN.TYPE] === tokens.closeParenthesis) { + unbalanced --; + } + if (unbalanced) { + this.parse(); + } else { + this.current.source.end = tokenEnd(this.currToken); + this.current.parent.source.end = tokenEnd(this.currToken); + this.position ++; + } } + } finally { + this.nestingDepth --; } this.current = cache; - this.nestingDepth --; } else { // I think this case should be an error. It's used to implement a basic parse of media queries // but I don't think it's a good idea. diff --git a/src/processor.js b/src/processor.js index 5ab85f7..b530f36 100644 --- a/src/processor.js +++ b/src/processor.js @@ -38,6 +38,13 @@ export default class Processor { }; } + _stringifyOptions (options) { + let merged = Object.assign({}, this.options, options); + return { + maxNestingDepth: merged.maxNestingDepth, + }; + } + _run (rule, options = {}) { return new Promise((resolve, reject) => { try { @@ -45,7 +52,7 @@ export default class Processor { Promise.resolve(this.func(root)).then(transform => { let string = undefined; if (this._shouldUpdateSelector(rule, options)) { - string = root.toString(); + string = root.toString(this._stringifyOptions(options)); rule.selector = string; } return {transform, root, string}; @@ -65,7 +72,7 @@ export default class Processor { } let string = undefined; if (options.updateSelector && typeof rule !== "string") { - string = root.toString(); + string = root.toString(this._stringifyOptions(options)); rule.selector = string; } return {transform, root, string}; @@ -124,7 +131,7 @@ export default class Processor { */ process (rule, options) { return this._run(rule, options) - .then((result) => result.string || result.root.toString()); + .then((result) => result.string || result.root.toString(this._stringifyOptions(options))); } /** @@ -136,6 +143,6 @@ export default class Processor { */ processSync (rule, options) { let result = this._runSync(rule, options); - return result.string || result.root.toString(); + return result.string || result.root.toString(this._stringifyOptions(options)); } } diff --git a/src/selectors/container.js b/src/selectors/container.js index e9d5f23..a97cc93 100644 --- a/src/selectors/container.js +++ b/src/selectors/container.js @@ -1,15 +1,6 @@ import Node from './node'; import * as types from './types'; -/** - * The default maximum selector nesting depth allowed when parsing or - * serializing a selector. Going beyond this would otherwise recurse deeply - * enough to overflow the call stack (CVE-2026-9358 / CWE-674). Real-world - * selectors never get anywhere near this, so it acts purely as a safety net - * that turns an uncatchable stack overflow into a catchable error. - */ -export const MAX_NESTING_DEPTH = 256; - export default class Container extends Node { constructor (opts) { super(opts); @@ -333,7 +324,7 @@ export default class Container extends Node { return this.nodes.sort(callback); } - toString (depth = 0) { - return this.map(child => child.toString(depth)).join(''); + toString (options = {}, depth = 0) { + return this.map(child => child.toString(options, depth)).join(''); } } diff --git a/src/selectors/pseudo.js b/src/selectors/pseudo.js index 97219a7..b1674ae 100644 --- a/src/selectors/pseudo.js +++ b/src/selectors/pseudo.js @@ -1,4 +1,5 @@ -import Container, {MAX_NESTING_DEPTH} from './container'; +import {resolveMaxNestingDepth} from '../util'; +import Container from './container'; import {PSEUDO} from './types'; export default class Pseudo extends Container { @@ -7,14 +8,15 @@ export default class Pseudo extends Container { this.type = PSEUDO; } - toString (depth = 0) { - if (depth >= MAX_NESTING_DEPTH) { + toString (options = {}, depth = 0) { + let max = resolveMaxNestingDepth(options.maxNestingDepth); + if (depth >= max) { throw new Error( - `Cannot serialize selector: nesting depth exceeds the maximum of ${MAX_NESTING_DEPTH}.` + `Cannot serialize selector: nesting depth exceeds the maximum of ${max}.` ); } let params = this.length - ? '(' + this.map(child => child.toString(depth + 1)).join(',') + ')' + ? '(' + this.map(child => child.toString(options, depth + 1)).join(',') + ')' : ''; return [ this.rawSpaceBefore, diff --git a/src/selectors/root.js b/src/selectors/root.js index 5f56229..8c35b82 100644 --- a/src/selectors/root.js +++ b/src/selectors/root.js @@ -7,9 +7,9 @@ export default class Root extends Container { this.type = ROOT; } - toString (depth = 0) { + toString (options = {}, depth = 0) { let str = this.reduce((memo, selector) => { - memo.push(selector.toString(depth)); + memo.push(selector.toString(options, depth)); return memo; }, []).join(','); return this.trailingComma ? str + ',' : str; diff --git a/src/util/index.js b/src/util/index.js index a0a1579..af90440 100644 --- a/src/util/index.js +++ b/src/util/index.js @@ -2,3 +2,4 @@ export {default as unesc} from './unesc'; export {default as getProp} from './getProp'; export {default as ensureObject} from './ensureObject'; export {default as stripComments} from './stripComments'; +export {default as resolveMaxNestingDepth, MAX_NESTING_DEPTH} from './maxNestingDepth'; diff --git a/src/util/maxNestingDepth.js b/src/util/maxNestingDepth.js new file mode 100644 index 0000000..7aa1ded --- /dev/null +++ b/src/util/maxNestingDepth.js @@ -0,0 +1,21 @@ +/** + * The default maximum selector nesting depth allowed when parsing or + * serializing a selector. Going beyond this would otherwise recurse deeply + * enough to overflow the call stack (CVE-2026-9358 / CWE-674). Real-world + * selectors never get anywhere near this, so it acts purely as a safety net + * that turns an uncatchable stack overflow into a catchable error. + */ +export const MAX_NESTING_DEPTH = 256; + +/** + * Coerce a user-supplied nesting-depth limit into a safe value. Anything that + * is not a non-negative safe integer (NaN, Infinity, negative numbers, or a + * non-number) would disable or break the guard, so it falls back to the + * default. + * + * @param {unknown} value the limit provided through the `maxNestingDepth` option + * @returns {number} a safe, non-negative integer limit + */ +export default function resolveMaxNestingDepth (value) { + return Number.isSafeInteger(value) && value >= 0 ? value : MAX_NESTING_DEPTH; +} From 8cf473e59605f07cd32e778a2b61cbb7b121ebcf Mon Sep 17 00:00:00 2001 From: Max Thirouin Date: Thu, 4 Jun 2026 15:18:48 +0200 Subject: [PATCH 5/7] Improve recursion implementation (resolveMaxNestingDepth) following @copilot review --- src/selectors/container.js | 5 +++-- src/selectors/pseudo.js | 5 ++--- src/selectors/root.js | 5 +++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/selectors/container.js b/src/selectors/container.js index a97cc93..c172657 100644 --- a/src/selectors/container.js +++ b/src/selectors/container.js @@ -1,3 +1,4 @@ +import {resolveMaxNestingDepth} from '../util'; import Node from './node'; import * as types from './types'; @@ -324,7 +325,7 @@ export default class Container extends Node { return this.nodes.sort(callback); } - toString (options = {}, depth = 0) { - return this.map(child => child.toString(options, depth)).join(''); + toString (options = {}, depth = 0, max = resolveMaxNestingDepth(options.maxNestingDepth)) { + return this.map(child => child.toString(options, depth, max)).join(''); } } diff --git a/src/selectors/pseudo.js b/src/selectors/pseudo.js index b1674ae..5141781 100644 --- a/src/selectors/pseudo.js +++ b/src/selectors/pseudo.js @@ -8,15 +8,14 @@ export default class Pseudo extends Container { this.type = PSEUDO; } - toString (options = {}, depth = 0) { - let max = resolveMaxNestingDepth(options.maxNestingDepth); + toString (options = {}, depth = 0, max = resolveMaxNestingDepth(options.maxNestingDepth)) { if (depth >= max) { throw new Error( `Cannot serialize selector: nesting depth exceeds the maximum of ${max}.` ); } let params = this.length - ? '(' + this.map(child => child.toString(options, depth + 1)).join(',') + ')' + ? '(' + this.map(child => child.toString(options, depth + 1, max)).join(',') + ')' : ''; return [ this.rawSpaceBefore, diff --git a/src/selectors/root.js b/src/selectors/root.js index 8c35b82..2616612 100644 --- a/src/selectors/root.js +++ b/src/selectors/root.js @@ -1,3 +1,4 @@ +import {resolveMaxNestingDepth} from '../util'; import Container from './container'; import {ROOT} from './types'; @@ -7,9 +8,9 @@ export default class Root extends Container { this.type = ROOT; } - toString (options = {}, depth = 0) { + toString (options = {}, depth = 0, max = resolveMaxNestingDepth(options.maxNestingDepth)) { let str = this.reduce((memo, selector) => { - memo.push(selector.toString(options, depth)); + memo.push(selector.toString(options, depth, max)); return memo; }, []).join(','); return this.trailingComma ? str + ',' : str; From 268261f24c9cb4f78257b1f80744f58c803b4eed Mon Sep 17 00:00:00 2001 From: Max Thirouin Date: Thu, 4 Jun 2026 15:37:25 +0200 Subject: [PATCH 6/7] Last improvements on recursion issue implementation --- src/processor.js | 2 +- src/selectors/container.js | 8 ++++++-- src/selectors/node.js | 7 +++++++ src/selectors/pseudo.js | 5 ++--- src/selectors/root.js | 5 ++--- 5 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/processor.js b/src/processor.js index b530f36..6d1c09f 100644 --- a/src/processor.js +++ b/src/processor.js @@ -33,7 +33,7 @@ export default class Processor { _parseOptions (options) { let merged = Object.assign({}, this.options, options); return { - lossy: this._isLossy(options), + lossy: this._isLossy(merged), maxNestingDepth: merged.maxNestingDepth, }; } diff --git a/src/selectors/container.js b/src/selectors/container.js index c172657..cf0b98d 100644 --- a/src/selectors/container.js +++ b/src/selectors/container.js @@ -325,7 +325,11 @@ export default class Container extends Node { return this.nodes.sort(callback); } - toString (options = {}, depth = 0, max = resolveMaxNestingDepth(options.maxNestingDepth)) { - return this.map(child => child.toString(options, depth, max)).join(''); + toString (options = {}) { + return this._stringify(options, 0, resolveMaxNestingDepth(options.maxNestingDepth)); + } + + _stringify (options, depth, max) { + return this.map(child => child._stringify(options, depth, max)).join(''); } } diff --git a/src/selectors/node.js b/src/selectors/node.js index 9df343e..81e9a30 100644 --- a/src/selectors/node.js +++ b/src/selectors/node.js @@ -188,4 +188,11 @@ export default class Node { this.rawSpaceAfter, ].join(''); } + + // Internal recursion entry point used by Container serialization. Leaf + // nodes don't recurse, so they ignore the depth/limit and stringify + // themselves. Containers override this to thread the nesting depth. + _stringify () { + return this.toString(); + } } diff --git a/src/selectors/pseudo.js b/src/selectors/pseudo.js index 5141781..12b7490 100644 --- a/src/selectors/pseudo.js +++ b/src/selectors/pseudo.js @@ -1,4 +1,3 @@ -import {resolveMaxNestingDepth} from '../util'; import Container from './container'; import {PSEUDO} from './types'; @@ -8,14 +7,14 @@ export default class Pseudo extends Container { this.type = PSEUDO; } - toString (options = {}, depth = 0, max = resolveMaxNestingDepth(options.maxNestingDepth)) { + _stringify (options, depth, max) { if (depth >= max) { throw new Error( `Cannot serialize selector: nesting depth exceeds the maximum of ${max}.` ); } let params = this.length - ? '(' + this.map(child => child.toString(options, depth + 1, max)).join(',') + ')' + ? '(' + this.map(child => child._stringify(options, depth + 1, max)).join(',') + ')' : ''; return [ this.rawSpaceBefore, diff --git a/src/selectors/root.js b/src/selectors/root.js index 2616612..2befecf 100644 --- a/src/selectors/root.js +++ b/src/selectors/root.js @@ -1,4 +1,3 @@ -import {resolveMaxNestingDepth} from '../util'; import Container from './container'; import {ROOT} from './types'; @@ -8,9 +7,9 @@ export default class Root extends Container { this.type = ROOT; } - toString (options = {}, depth = 0, max = resolveMaxNestingDepth(options.maxNestingDepth)) { + _stringify (options, depth, max) { let str = this.reduce((memo, selector) => { - memo.push(selector.toString(options, depth, max)); + memo.push(selector._stringify(options, depth, max)); return memo; }, []).join(','); return this.trailingComma ? str + ',' : str; From 3c532bc1fb2af83c0c55b9975550c24efefd178e Mon Sep 17 00:00:00 2001 From: Max Thirouin Date: Thu, 4 Jun 2026 15:51:22 +0200 Subject: [PATCH 7/7] Add a note in README about CVE-2026-9358 limitation --- README.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/README.md b/README.md index 28e44f2..e35ba12 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,39 @@ with the resulting selector string. Please see [API.md](API.md). +## Security + +### Selector nesting depth (CVE-2026-9358) + +The parser walks the selector AST recursively, both when parsing and when +serializing it back to a string (`.toString()`). In versions up to and +including `7.1.1`, a selector with extreme nesting — for example thousands of +nested `:not(...)` — could recurse deeply enough to overflow the call stack and +throw `RangeError: Maximum call stack size exceeded`, a potential +denial-of-service when processing untrusted CSS. + +This is now bounded by a maximum nesting depth (default: `256`). Beyond that +depth, parsing and serialization throw a regular, catchable `Error` at a +predictable point instead of relying on the runtime hitting its stack limit. +The default is far above any realistic selector, so it does not affect normal +use. + +**Practical impact is low.** The only attacker-controlled input is the selector +string itself, which is now capped by the default limit. The limit is +adjustable through the `maxNestingDepth` option, but that option is trusted +configuration provided by the integrating code — it is never derived from the +parsed CSS, so a malicious selector cannot change it: + +```js +// Tighten the limit when parsing untrusted input: +parser().processSync(untrustedSelector, {maxNestingDepth: 128}); +``` + +Raising `maxNestingDepth` to a very large value is an explicit, informed choice +and can reintroduce the stack-overflow risk in environments with a small call +stack (e.g. browser workers). The default is recommended unless you have a +specific need. + ## Credits * Huge thanks to Andrey Sitnik (@ai) for work on PostCSS which helped