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
94 changes: 94 additions & 0 deletions src/__tests__/complexity.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import ava from 'ava';
import parser from '../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');
});
19 changes: 13 additions & 6 deletions src/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,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 {
Expand Down Expand Up @@ -881,8 +881,15 @@ 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;
Expand All @@ -899,14 +906,14 @@ export default class Parser {
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,
Expand Down