From 62b191792df0a0bc56062e5a875bc74aae2a51cd Mon Sep 17 00:00:00 2001 From: Max Thirouin Date: Thu, 3 Sep 2026 10:54:00 +0200 Subject: [PATCH 1/2] fix: parse flat selectors in linear time, closing a CPU exhaustion vulnerability --- src/__tests__/complexity.mjs | 98 ++++++++++++++++++++++++++++++++++++ src/parser.js | 18 ++++--- 2 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/complexity.mjs diff --git a/src/__tests__/complexity.mjs b/src/__tests__/complexity.mjs new file mode 100644 index 0000000..f224a53 --- /dev/null +++ b/src/__tests__/complexity.mjs @@ -0,0 +1,98 @@ +import ava from "./util/runner.mjs"; +import parser from "../../dist/index.js"; + +// Regression tests for the quadratic parsing of flat selectors: `.` and `#` are +// not word delimiters, so `.a.a.a...` reaches splitWord as a single word token +// carrying n class indexes. Three passes used to scan those index arrays +// linearly for every index, making the parse O(n^2): +// +// 1. uniqs(), deduplicating with filter + indexOf +// 2. the indices.forEach loop, calling hasClass.indexOf / hasId.indexOf +// 3. the Sass-interpolation filter, calling interpolations.indexOf +// +// Nesting depth is 0 for such a selector, so maxNestingDepth offers no +// protection. All three now use Set membership tests. + +const N = 60000; + +// Fastest of a few runs, to shrug off GC pauses and scheduling noise. +function fastest(input) { + let best = Infinity; + for (let i = 0; i < 3; i++) { + const start = process.hrtime.bigint(); + parser().astSync(input); + best = Math.min(best, Number(process.hrtime.bigint() - start) / 1e6); + } + return best; +} + +// Compare the hostile payload (one word token holding N indexes) against a +// benign control of the same repeated atom, space separated, which forces the +// same amount of real parsing work spread over N word tokens. Deriving the +// budget from a measurement taken on the same machine keeps this stable across +// CI runners and under coverage instrumentation, where an absolute time budget +// or a raw input-scaling ratio would not be. +// +// The control does strictly more work than the payload once parsing is linear, +// so a healthy ratio sits below 1. It reached 4-8 while the passes were +// quadratic. The threshold sits well clear of both. +function costRatio(atom) { + const hostile = fastest(atom.repeat(N)); + const benign = fastest(`${atom} `.repeat(N)); + return hostile / Math.max(benign, 1); +} + +for (const [label, atom] of [ + ["flat class selectors", ".a"], + ["flat id selectors", "#a"], + ["repeated Sass interpolations", "#{a}"], +]) { + ava(`${label} parse in time proportional to their size`, (t) => { + const ratio = costRatio(atom); + t.true( + ratio < 2, + `parsing "${atom}" repeated ${N} times as one token cost ${ratio.toFixed(1)}x the same atoms parsed separately, which suggests a super-linear pass`, + ); + }); +} + +ava("a large flat selector still produces one node per class", (t) => { + const tree = parser().astSync(".a".repeat(20000)); + const nodes = tree.first.nodes; + t.is(nodes.length, 20000); + t.true(nodes.every((node) => node.type === "class" && node.value === "a")); +}); + +// The leading 0 pushed into the index list collides with hasClass[0] or +// hasId[0] whenever the word starts with `.` or `#`, so the deduplication in +// uniqs() is load-bearing: dropping it emits a spurious first node covering the +// whole word. These pin that down. +ava("deduplication keeps a leading class from being emitted twice", (t) => { + t.deepEqual( + parser() + .astSync(".a.b") + .first.nodes.map((node) => [node.type, node.value]), + [ + ["class", "a"], + ["class", "b"], + ], + ); +}); + +ava("deduplication keeps a leading id from being emitted twice", (t) => { + t.deepEqual( + parser() + .astSync("#x.y") + .first.nodes.map((node) => [node.type, node.value]), + [ + ["id", "x"], + ["class", "y"], + ], + ); +}); + +ava("a single leading class is emitted once", (t) => { + const nodes = parser().astSync(".a").first.nodes; + t.is(nodes.length, 1); + t.is(nodes[0].type, "class"); +}); diff --git a/src/parser.js b/src/parser.js index bcec5ac..edf5330 100644 --- a/src/parser.js +++ b/src/parser.js @@ -106,9 +106,9 @@ function indexesOf(array, item) { } function uniqs() { - const list = Array.prototype.concat.apply([], arguments); - - return list.filter((item, i) => i === list.indexOf(item)); + // A Set keeps the first-occurrence order the previous filter/indexOf pass + // produced, without its quadratic cost on long index lists. + return Array.from(new Set(Array.prototype.concat.apply([], arguments))); } export default class Parser { @@ -949,8 +949,14 @@ export default class Parser { // Eliminate Sass interpolations from the list of id indexes const interpolations = indexesOf(word, "#{"); if (interpolations.length) { - hasId = hasId.filter((hashIndex) => !~interpolations.indexOf(hashIndex)); + const interpolationIndexes = new Set(interpolations); + hasId = hasId.filter((hashIndex) => !interpolationIndexes.has(hashIndex)); } + // Membership tests are built once. Scanning the arrays per index would make + // a flat selector such as `.a.a.a...` quadratic in its number of class/id + // indexes, which is enough to pin a CPU core on attacker-supplied input. + const classIndexes = new Set(hasClass); + const idIndexes = new Set(hasId); let indices = sortAsc(uniqs([0, ...hasClass, ...hasId])); indices.forEach((ind, i) => { const index = indices[i + 1] || word.length; @@ -962,14 +968,14 @@ export default class Parser { const current = this.currToken; const sourceIndex = current[TOKEN.START_POS] + indices[i]; const source = getSource(current[1], current[2] + ind, current[3], current[2] + (index - 1)); - if (~hasClass.indexOf(ind)) { + if (classIndexes.has(ind)) { let classNameOpts = { value: value.slice(1), source, sourceIndex, }; node = new ClassName(unescapeProp(classNameOpts, "value")); - } else if (~hasId.indexOf(ind)) { + } else if (idIndexes.has(ind)) { let idOpts = { value: value.slice(1), source, From 4eb3468e754f9f94d2092d103767bae98aa41438 Mon Sep 17 00:00:00 2001 From: Max Thirouin Date: Thu, 3 Sep 2026 10:54:58 +0200 Subject: [PATCH 2/2] 7.1.6 --- CHANGELOG.md | 4 ++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f83f578..a4a7fd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog of `postcss-selector-parser` +## 7.1.6 - 2026-09-03 + +- fix: parse flat selectors in linear time, closing a CPU exhaustion vulnerability ([GHSA-rj75-hqrm-r3gf](https://github.com/advisories/GHSA-rj75-hqrm-r3gf), reported by Wayde Shi) + ## 7.1.5 - 2026-08-07 - fix: don't treat a non-prefix token before `|` as a namespace ([#324](https://github.com/postcss/postcss-selector-parser/pull/324) by [@spokodev](https://github.com/spokodev)) diff --git a/package-lock.json b/package-lock.json index 911bb3b..a562c69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "postcss-selector-parser", - "version": "7.1.4", + "version": "7.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "postcss-selector-parser", - "version": "7.1.4", + "version": "7.1.6", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", diff --git a/package.json b/package.json index 2bb6c15..5a7f199 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "postcss-selector-parser", "description": "Selector parser with built in methods for working with selector strings.", - "version": "7.1.5", + "version": "7.1.6", "devDependencies": { "oxfmt": "^0.54.0", "oxlint": "^1.69.0",