Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 16 additions & 2 deletions postcss-selector-parser.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,20 @@ 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;
}
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,
Expand Down Expand Up @@ -201,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<Node>;
Expand Down Expand Up @@ -296,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;

Expand Down
91 changes: 91 additions & 0 deletions src/__tests__/recursion.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
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.
//
// 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);

// 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 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 a deeply nested AST throws instead of overflowing the stack', t => {
const deep = buildDeepAst(1000);
const error = t.throws(() => deep.toString(), {instanceOf: Error});
t.false(error instanceof RangeError, 'should be a controlled error, not a stack overflow');
});

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(input, {maxNestingDepth: 10}),
{instanceOf: Error}
);
t.regex(error.message, /\b10\b/);
// ...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`);
}
});
43 changes: 30 additions & 13 deletions src/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,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,
Expand Down Expand Up @@ -117,6 +117,8 @@ export default class Parser {
this.rule = rule;
this.options = Object.assign({lossy: false, safe: false}, options);
this.position = 0;
this.nestingDepth = 0;
this.maxNestingDepth = resolveMaxNestingDepth(this.options.maxNestingDepth);

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

Expand Down Expand Up @@ -700,20 +702,35 @@ 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;
} else {
Expand Down
19 changes: 14 additions & 5 deletions src/processor.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,17 @@ export default class Processor {
}

_parseOptions (options) {
let merged = Object.assign({}, this.options, options);
return {
lossy: this._isLossy(merged),
maxNestingDepth: merged.maxNestingDepth,
};
Comment thread
MoOx marked this conversation as resolved.
}

_stringifyOptions (options) {
let merged = Object.assign({}, this.options, options);
return {
lossy: this._isLossy(options),
maxNestingDepth: merged.maxNestingDepth,
};
}

Expand All @@ -43,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};
Expand All @@ -63,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};
Expand Down Expand Up @@ -122,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)));
}

/**
Expand All @@ -134,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));
}
}
9 changes: 7 additions & 2 deletions src/selectors/container.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {resolveMaxNestingDepth} from '../util';
import Node from './node';
import * as types from './types';

Expand Down Expand Up @@ -324,7 +325,11 @@ export default class Container extends Node {
return this.nodes.sort(callback);
}

toString () {
return this.map(String).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('');
}
}
7 changes: 7 additions & 0 deletions src/selectors/node.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
11 changes: 9 additions & 2 deletions src/selectors/pseudo.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,15 @@ export default class Pseudo extends Container {
this.type = PSEUDO;
}

toString () {
let params = this.length ? '(' + this.map(String).join(',') + ')' : '';
_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._stringify(options, depth + 1, max)).join(',') + ')'
: '';
return [
this.rawSpaceBefore,
this.stringifyProperty("value"),
Expand Down
4 changes: 2 additions & 2 deletions src/selectors/root.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ export default class Root extends Container {
this.type = ROOT;
}

toString () {
_stringify (options, depth, max) {
let str = this.reduce((memo, selector) => {
memo.push(String(selector));
memo.push(selector._stringify(options, depth, max));
return memo;
}, []).join(',');
return this.trailingComma ? str + ',' : str;
Expand Down
1 change: 1 addition & 0 deletions src/util/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
21 changes: 21 additions & 0 deletions src/util/maxNestingDepth.js
Original file line number Diff line number Diff line change
@@ -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;
}
Comment on lines +19 to +21
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't clamp. maxNestingDepth is trusted developer configuration, never derived from the parsed CSS, so it isn't reachable by an attacker (whose only input is the selector string, already capped by the default of 256). There's also no universally correct ceiling — the safe stack depth is environment-dependent — so a hard clamp would either reject legitimate raised limits or still be unsafe on small stacks. Documented the caveat in the README instead.

Loading