|
| 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 | +}); |
0 commit comments