Visitar URL original
fix: parse flat selectors in linear time, closing a CPU exhaustion vu… · postcss/postcss-selector-parser@70b0779 · GitHub
Skip to content

Commit 70b0779

Browse files
authored
fix: parse flat selectors in linear time, closing a CPU exhaustion vulnerability (#346)
Backport of 62b1917 (7.1.6) to 6.x.
1 parent fb22e12 commit 70b0779

2 files changed

Lines changed: 107 additions & 6 deletions

File tree

‎src/__tests__/complexity.mjs‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import ava from 'ava';
2+
import parser from '../index.js';
3+
4+
// Regression tests for the quadratic parsing of flat selectors: `.` and `#` are
5+
// not word delimiters, so `.a.a.a...` reaches splitWord as a single word token
6+
// carrying n class indexes. Three passes used to scan those index arrays
7+
// linearly for every index, making the parse O(n^2):
8+
//
9+
// 1. uniqs(), deduplicating with filter + indexOf
10+
// 2. the indices.forEach loop, calling hasClass.indexOf / hasId.indexOf
11+
// 3. the Sass-interpolation filter, calling interpolations.indexOf
12+
//
13+
// Nesting depth is 0 for such a selector, so maxNestingDepth offers no
14+
// protection. All three now use Set membership tests.
15+
16+
const N = 60000;
17+
18+
// Fastest of a few runs, to shrug off GC pauses and scheduling noise.
19+
function fastest (input) {
20+
let best = Infinity;
21+
for (let i = 0; i < 3; i++) {
22+
const start = process.hrtime.bigint();
23+
parser().astSync(input);
24+
best = Math.min(best, Number(process.hrtime.bigint() - start) / 1e6);
25+
}
26+
return best;
27+
}
28+
29+
// Compare the hostile payload (one word token holding N indexes) against a
30+
// benign control of the same repeated atom, space separated, which forces the
31+
// same amount of real parsing work spread over N word tokens. Deriving the
32+
// budget from a measurement taken on the same machine keeps this stable across
33+
// CI runners and under coverage instrumentation, where an absolute time budget
34+
// or a raw input-scaling ratio would not be.
35+
//
36+
// The control does strictly more work than the payload once parsing is linear,
37+
// so a healthy ratio sits below 1. It reached 4-8 while the passes were
38+
// quadratic. The threshold sits well clear of both.
39+
function costRatio (atom) {
40+
const hostile = fastest(atom.repeat(N));
41+
const benign = fastest(`${atom} `.repeat(N));
42+
return hostile / Math.max(benign, 1);
43+
}
44+
45+
for (const [label, atom] of [
46+
['flat class selectors', '.a'],
47+
['flat id selectors', '#a'],
48+
['repeated Sass interpolations', '#{a}'],
49+
]) {
50+
ava(`${label} parse in time proportional to their size`, t => {
51+
const ratio = costRatio(atom);
52+
t.true(
53+
ratio < 2,
54+
`parsing "${atom}" repeated ${N} times as one token cost ${ratio.toFixed(1)}x the same atoms parsed separately, which suggests a super-linear pass`
55+
);
56+
});
57+
}
58+
59+
ava('a large flat selector still produces one node per class', t => {
60+
const tree = parser().astSync('.a'.repeat(20000));
61+
const nodes = tree.first.nodes;
62+
t.is(nodes.length, 20000);
63+
t.true(nodes.every(node => node.type === 'class' && node.value === 'a'));
64+
});
65+
66+
// The leading 0 pushed into the index list collides with hasClass[0] or
67+
// hasId[0] whenever the word starts with `.` or `#`, so the deduplication in
68+
// uniqs() is load-bearing: dropping it emits a spurious first node covering the
69+
// whole word. These pin that down.
70+
ava('deduplication keeps a leading class from being emitted twice', t => {
71+
t.deepEqual(
72+
parser().astSync('.a.b').first.nodes.map(node => [node.type, node.value]),
73+
[
74+
['class', 'a'],
75+
['class', 'b'],
76+
]
77+
);
78+
});
79+
80+
ava('deduplication keeps a leading id from being emitted twice', t => {
81+
t.deepEqual(
82+
parser().astSync('#x.y').first.nodes.map(node => [node.type, node.value]),
83+
[
84+
['id', 'x'],
85+
['class', 'y'],
86+
]
87+
);
88+
});
89+
90+
ava('a single leading class is emitted once', t => {
91+
const nodes = parser().astSync('.a').first.nodes;
92+
t.is(nodes.length, 1);
93+
t.is(nodes[0].type, 'class');
94+
});

‎src/parser.js‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,9 @@ function indexesOf (array, item) {
107107
}
108108

109109
function uniqs () {
110-
const list = Array.prototype.concat.apply([], arguments);
111-
112-
return list.filter((item, i) => i === list.indexOf(item));
110+
// A Set keeps the first-occurrence order the previous filter/indexOf pass
111+
// produced, without its quadratic cost on long index lists.
112+
return Array.from(new Set(Array.prototype.concat.apply([], arguments)));
113113
}
114114

115115
export default class Parser {
@@ -881,8 +881,15 @@ export default class Parser {
881881
// Eliminate Sass interpolations from the list of id indexes
882882
const interpolations = indexesOf(word, '#{');
883883
if (interpolations.length) {
884-
hasId = hasId.filter(hashIndex => !~interpolations.indexOf(hashIndex));
884+
const interpolationIndexes = new Set(interpolations);
885+
hasId = hasId.filter(hashIndex => !interpolationIndexes.has(hashIndex));
885886
}
887+
// Membership tests are built once. Scanning the arrays per index would
888+
// make a flat selector such as `.a.a.a...` quadratic in its number of
889+
// class/id indexes, which is enough to pin a CPU core on
890+
// attacker-supplied input.
891+
const classIndexes = new Set(hasClass);
892+
const idIndexes = new Set(hasId);
886893
let indices = sortAsc(uniqs([0, ...hasClass, ...hasId]));
887894
indices.forEach((ind, i) => {
888895
const index = indices[i + 1] || word.length;
@@ -899,14 +906,14 @@ export default class Parser {
899906
current[3],
900907
current[2] + (index - 1)
901908
);
902-
if (~hasClass.indexOf(ind)) {
909+
if (classIndexes.has(ind)) {
903910
let classNameOpts = {
904911
value: value.slice(1),
905912
source,
906913
sourceIndex,
907914
};
908915
node = new ClassName(unescapeProp(classNameOpts, "value"));
909-
} else if (~hasId.indexOf(ind)) {
916+
} else if (idIndexes.has(ind)) {
910917
let idOpts = {
911918
value: value.slice(1),
912919
source,

0 commit comments

Comments
 (0)