Skip to content

Commit 053e949

Browse files
committed
Fix CVE-2026-9358 (NVD) / SNYK-JS-POSTCSSSELECTORPARSER-16873882 (#316)
* Add failing tests for #315 * Fix CVE-2026-9358 (NVD) / SNYK-JS-POSTCSSSELECTORPARSER-16873882 * Improve recursion testing following @copilot review * Improve recursion implementation following @copilot review * Improve recursion implementation (resolveMaxNestingDepth) following @copilot review * Last improvements on recursion issue implementation * Add a note in README about CVE-2026-9358 limitation
1 parent 1b1e9c3 commit 053e949

11 files changed

Lines changed: 231 additions & 26 deletions

File tree

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,39 @@ with the resulting selector string.
3939

4040
Please see [API.md](API.md).
4141

42+
## Security
43+
44+
### Selector nesting depth (CVE-2026-9358)
45+
46+
The parser walks the selector AST recursively, both when parsing and when
47+
serializing it back to a string (`.toString()`). In versions up to and
48+
including `7.1.1`, a selector with extreme nesting — for example thousands of
49+
nested `:not(...)` — could recurse deeply enough to overflow the call stack and
50+
throw `RangeError: Maximum call stack size exceeded`, a potential
51+
denial-of-service when processing untrusted CSS.
52+
53+
This is now bounded by a maximum nesting depth (default: `256`). Beyond that
54+
depth, parsing and serialization throw a regular, catchable `Error` at a
55+
predictable point instead of relying on the runtime hitting its stack limit.
56+
The default is far above any realistic selector, so it does not affect normal
57+
use.
58+
59+
**Practical impact is low.** The only attacker-controlled input is the selector
60+
string itself, which is now capped by the default limit. The limit is
61+
adjustable through the `maxNestingDepth` option, but that option is trusted
62+
configuration provided by the integrating code — it is never derived from the
63+
parsed CSS, so a malicious selector cannot change it:
64+
65+
```js
66+
// Tighten the limit when parsing untrusted input:
67+
parser().processSync(untrustedSelector, {maxNestingDepth: 128});
68+
```
69+
70+
Raising `maxNestingDepth` to a very large value is an explicit, informed choice
71+
and can reintroduce the stack-overflow risk in environments with a small call
72+
stack (e.g. browser workers). The default is recommended unless you have a
73+
specific need.
74+
4275
## Credits
4376

4477
* Huge thanks to Andrey Sitnik (@ai) for work on PostCSS which helped

postcss-selector-parser.d.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,20 @@ declare namespace parser {
9494
* processing back onto the rule when done. Default: false.
9595
*/
9696
updateSelector: boolean;
97+
/**
98+
* The maximum selector nesting depth allowed while parsing. Selectors
99+
* nested deeper than this (e.g. `:not(:not(:not(…)))`) raise an error
100+
* instead of overflowing the call stack. Default: 256.
101+
*/
102+
maxNestingDepth: number;
103+
}
104+
interface StringifyOptions {
105+
/**
106+
* The maximum selector nesting depth allowed while serializing.
107+
* Serializing an AST nested deeper than this raises an error instead of
108+
* overflowing the call stack. Default: 256.
109+
*/
110+
maxNestingDepth?: number;
97111
}
98112
class Processor<
99113
TransformType = never,
@@ -201,7 +215,7 @@ declare namespace parser {
201215
* @param {string} valueEscaped optional. the escaped value of the property.
202216
*/
203217
appendToPropertyAndEscape(name: string, value: any, valueEscaped: string): void;
204-
toString(): string;
218+
toString(options?: StringifyOptions): string;
205219
}
206220
interface ContainerOptions extends NodeOptions {
207221
nodes?: Array<Node>;
@@ -295,7 +309,7 @@ declare namespace parser {
295309
some(callback: (node: Child) => boolean): boolean;
296310
filter(callback: (node: Child) => boolean): Child[];
297311
sort(callback: (nodeA: Child, nodeB: Child) => number): Child[];
298-
toString(): string;
312+
toString(options?: StringifyOptions): string;
299313
}
300314
function isContainer(node: any): node is Root | Selector | Pseudo;
301315

src/__tests__/recursion.mjs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import ava from 'ava';
2+
import parser from '../index.js';
3+
4+
// Regression tests for CVE-2026-9358 / SNYK-JS-POSTCSSSELECTORPARSER-16873882:
5+
// uncontrolled recursion when parsing or serializing deeply nested selectors
6+
// must surface as a catchable Error instead of overflowing the call stack.
7+
//
8+
// The default-limit tests assert only the stable contract — a controlled Error
9+
// that is NOT a RangeError stack overflow — so they don't break if the default
10+
// is tuned. Tests that assert on a specific limit always set it explicitly via
11+
// the `maxNestingDepth` option, so they never depend on the default value.
12+
13+
// Build a selector string of `depth` nested `:not(...)` pseudo classes.
14+
const nest = depth => ':not('.repeat(depth) + 'a' + ')'.repeat(depth);
15+
16+
// Build a deeply nested AST programmatically, bypassing the parse-time guard,
17+
// so the serialization (toString) guard is exercised on its own.
18+
function buildDeepAst (depth) {
19+
const root = parser.root({});
20+
const top = parser.selector({});
21+
root.append(top);
22+
let current = top;
23+
for (let i = 0; i < depth; i++) {
24+
const pseudo = parser.pseudo({value: ':not'});
25+
const sel = parser.selector({});
26+
pseudo.append(sel);
27+
current.append(pseudo);
28+
current = sel;
29+
}
30+
current.append(parser.tag({value: 'a'}));
31+
return root;
32+
}
33+
34+
ava('reasonably nested selectors still round-trip', t => {
35+
const input = nest(10);
36+
t.is(parser().processSync(input), input);
37+
});
38+
39+
ava('parsing a deeply nested hostile selector throws instead of overflowing the stack', t => {
40+
const error = t.throws(() => parser().astSync(nest(1000)), {instanceOf: Error});
41+
t.false(error instanceof RangeError, 'should be a controlled error, not a stack overflow');
42+
});
43+
44+
ava('serializing a deeply nested AST throws instead of overflowing the stack', t => {
45+
const deep = buildDeepAst(1000);
46+
const error = t.throws(() => deep.toString(), {instanceOf: Error});
47+
t.false(error instanceof RangeError, 'should be a controlled error, not a stack overflow');
48+
});
49+
50+
ava('maxNestingDepth option controls the limit in both directions', t => {
51+
const input = nest(40);
52+
// A low limit rejects it and reports the configured value...
53+
const error = t.throws(
54+
() => parser().astSync(input, {maxNestingDepth: 10}),
55+
{instanceOf: Error}
56+
);
57+
t.regex(error.message, /\b10\b/);
58+
// ...while a high limit accepts the very same selector.
59+
t.notThrows(() => parser().astSync(input, {maxNestingDepth: 100}));
60+
});
61+
62+
ava('the parse and serialize limits stay in sync through processSync', t => {
63+
const input = nest(40);
64+
// With a raised limit, parsing AND the implicit toString() in processSync
65+
// must both succeed and round-trip the selector unchanged.
66+
t.is(parser().processSync(input, {maxNestingDepth: 100}), input);
67+
// With a low limit, the same call fails (at parse time) instead of crashing.
68+
t.throws(() => parser().processSync(input, {maxNestingDepth: 10}), {instanceOf: Error});
69+
});
70+
71+
ava('toString accepts an explicit maxNestingDepth for programmatic ASTs', t => {
72+
const deep = buildDeepAst(40);
73+
// Default limit (256) serializes it fine.
74+
t.notThrows(() => deep.toString());
75+
// A tightened limit rejects it with a controlled error...
76+
const error = t.throws(() => deep.toString({maxNestingDepth: 10}), {instanceOf: Error});
77+
t.false(error instanceof RangeError);
78+
t.regex(error.message, /\b10\b/);
79+
});
80+
81+
ava('invalid maxNestingDepth values fall back to the safe default', t => {
82+
// NaN, Infinity, negatives and non-numbers must not disable the guard:
83+
// a hostile payload still throws a controlled error rather than crashing.
84+
for (const bad of [NaN, Infinity, -1, '256', null]) {
85+
const error = t.throws(
86+
() => parser().astSync(nest(1000), {maxNestingDepth: bad}),
87+
{instanceOf: Error}
88+
);
89+
t.false(error instanceof RangeError, `value ${String(bad)} should keep the guard active`);
90+
}
91+
});

src/parser.js

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import tokenize, {FIELDS as TOKEN} from './tokenize';
1616

1717
import * as tokens from './tokenTypes';
1818
import * as types from './selectors/types';
19-
import {unesc, getProp, ensureObject} from './util';
19+
import {unesc, getProp, ensureObject, resolveMaxNestingDepth} from './util';
2020

2121
const WHITESPACE_TOKENS = {
2222
[tokens.space]: true,
@@ -117,6 +117,8 @@ export default class Parser {
117117
this.rule = rule;
118118
this.options = Object.assign({lossy: false, safe: false}, options);
119119
this.position = 0;
120+
this.nestingDepth = 0;
121+
this.maxNestingDepth = resolveMaxNestingDepth(this.options.maxNestingDepth);
120122

121123
this.css = typeof this.rule === 'string' ? this.rule : this.rule.selector;
122124

@@ -700,20 +702,35 @@ export default class Parser {
700702
const cache = this.current;
701703
last.append(selector);
702704
this.current = selector;
703-
while (this.position < this.tokens.length && unbalanced) {
704-
if (this.currToken[TOKEN.TYPE] === tokens.openParenthesis) {
705-
unbalanced ++;
706-
}
707-
if (this.currToken[TOKEN.TYPE] === tokens.closeParenthesis) {
708-
unbalanced --;
705+
// Track nesting depth so deeply nested pseudo selectors raise a
706+
// catchable error instead of overflowing the call stack. The
707+
// counter is restored in `finally` so the parser is never left in
708+
// an inconsistent state, even on the error path.
709+
this.nestingDepth ++;
710+
try {
711+
if (this.nestingDepth > this.maxNestingDepth) {
712+
this.error(
713+
`Cannot parse selector: nesting depth exceeds the maximum of ${this.maxNestingDepth}.`,
714+
{index: this.currToken[TOKEN.START_POS]}
715+
);
709716
}
710-
if (unbalanced) {
711-
this.parse();
712-
} else {
713-
this.current.source.end = tokenEnd(this.currToken);
714-
this.current.parent.source.end = tokenEnd(this.currToken);
715-
this.position ++;
717+
while (this.position < this.tokens.length && unbalanced) {
718+
if (this.currToken[TOKEN.TYPE] === tokens.openParenthesis) {
719+
unbalanced ++;
720+
}
721+
if (this.currToken[TOKEN.TYPE] === tokens.closeParenthesis) {
722+
unbalanced --;
723+
}
724+
if (unbalanced) {
725+
this.parse();
726+
} else {
727+
this.current.source.end = tokenEnd(this.currToken);
728+
this.current.parent.source.end = tokenEnd(this.currToken);
729+
this.position ++;
730+
}
716731
}
732+
} finally {
733+
this.nestingDepth --;
717734
}
718735
this.current = cache;
719736
} else {

src/processor.js

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,17 @@ export default class Processor {
3131
}
3232

3333
_parseOptions (options) {
34+
let merged = Object.assign({}, this.options, options);
35+
return {
36+
lossy: this._isLossy(merged),
37+
maxNestingDepth: merged.maxNestingDepth,
38+
};
39+
}
40+
41+
_stringifyOptions (options) {
42+
let merged = Object.assign({}, this.options, options);
3443
return {
35-
lossy: this._isLossy(options),
44+
maxNestingDepth: merged.maxNestingDepth,
3645
};
3746
}
3847

@@ -43,7 +52,7 @@ export default class Processor {
4352
Promise.resolve(this.func(root)).then(transform => {
4453
let string = undefined;
4554
if (this._shouldUpdateSelector(rule, options)) {
46-
string = root.toString();
55+
string = root.toString(this._stringifyOptions(options));
4756
rule.selector = string;
4857
}
4958
return {transform, root, string};
@@ -63,7 +72,7 @@ export default class Processor {
6372
}
6473
let string = undefined;
6574
if (options.updateSelector && typeof rule !== "string") {
66-
string = root.toString();
75+
string = root.toString(this._stringifyOptions(options));
6776
rule.selector = string;
6877
}
6978
return {transform, root, string};
@@ -122,7 +131,7 @@ export default class Processor {
122131
*/
123132
process (rule, options) {
124133
return this._run(rule, options)
125-
.then((result) => result.string || result.root.toString());
134+
.then((result) => result.string || result.root.toString(this._stringifyOptions(options)));
126135
}
127136

128137
/**
@@ -134,6 +143,6 @@ export default class Processor {
134143
*/
135144
processSync (rule, options) {
136145
let result = this._runSync(rule, options);
137-
return result.string || result.root.toString();
146+
return result.string || result.root.toString(this._stringifyOptions(options));
138147
}
139148
}

src/selectors/container.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {resolveMaxNestingDepth} from '../util';
12
import Node from './node';
23
import * as types from './types';
34

@@ -313,7 +314,11 @@ export default class Container extends Node {
313314
return this.nodes.sort(callback);
314315
}
315316

316-
toString () {
317-
return this.map(String).join('');
317+
toString (options = {}) {
318+
return this._stringify(options, 0, resolveMaxNestingDepth(options.maxNestingDepth));
319+
}
320+
321+
_stringify (options, depth, max) {
322+
return this.map(child => child._stringify(options, depth, max)).join('');
318323
}
319324
}

src/selectors/node.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,4 +188,11 @@ export default class Node {
188188
this.rawSpaceAfter,
189189
].join('');
190190
}
191+
192+
// Internal recursion entry point used by Container serialization. Leaf
193+
// nodes don't recurse, so they ignore the depth/limit and stringify
194+
// themselves. Containers override this to thread the nesting depth.
195+
_stringify () {
196+
return this.toString();
197+
}
191198
}

src/selectors/pseudo.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,15 @@ export default class Pseudo extends Container {
77
this.type = PSEUDO;
88
}
99

10-
toString () {
11-
let params = this.length ? '(' + this.map(String).join(',') + ')' : '';
10+
_stringify (options, depth, max) {
11+
if (depth >= max) {
12+
throw new Error(
13+
`Cannot serialize selector: nesting depth exceeds the maximum of ${max}.`
14+
);
15+
}
16+
let params = this.length
17+
? '(' + this.map(child => child._stringify(options, depth + 1, max)).join(',') + ')'
18+
: '';
1219
return [
1320
this.rawSpaceBefore,
1421
this.stringifyProperty("value"),

src/selectors/root.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ export default class Root extends Container {
77
this.type = ROOT;
88
}
99

10-
toString () {
10+
_stringify (options, depth, max) {
1111
let str = this.reduce((memo, selector) => {
12-
memo.push(String(selector));
12+
memo.push(selector._stringify(options, depth, max));
1313
return memo;
1414
}, []).join(',');
1515
return this.trailingComma ? str + ',' : str;

src/util/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export {default as unesc} from './unesc';
22
export {default as getProp} from './getProp';
33
export {default as ensureObject} from './ensureObject';
44
export {default as stripComments} from './stripComments';
5+
export {default as resolveMaxNestingDepth, MAX_NESTING_DEPTH} from './maxNestingDepth';

0 commit comments

Comments
 (0)