fix: address stack overflow and performance issues in brace expansion

This commit is contained in:
chiranjib-swain
2026-10-14 19:19:51 +05:30
parent d14726c875
commit ac574740dd
2 changed files with 80 additions and 24 deletions

102
dist/index.js vendored
View File

@@ -10247,6 +10247,26 @@ var EXPANSION_MAX = 100000
// characters) so legitimate input is unaffected.
var EXPANSION_MAX_LENGTH = 4000000
// `expand` recurses once per level of brace *nesting* - both when expanding a
// set's comma members and when re-wrapping a set whose body is a single part.
// The CVE-2026-14257 fix made the *tail* iterative (recursion on `m.post`, one
// level per chained group), which left nesting depth unbounded: about 3,100
// levels of `{{{...a,b...}}}` - only ~6KB of input - exhausted the native stack
// and crashed the process. `EXPANSION_MAX_DEPTH` bounds how deep the parser
// will follow nesting. It sits far above any realistic pattern and well below
// the depth at which the stack runs out.
var EXPANSION_MAX_DEPTH = 1000
// Bash keeps a quirk where a brace group followed by a comma set still expands
// (`{a},b}`). The parser implements it by rewriting the string and restarting
// the scan, absorbing one `}` per pass. `n` trailing braces therefore cost `n`
// full passes over a string that itself grows by one `escClose` sentinel each
// time - quadratic in `n`, with a ~26x constant from the sentinel's length.
// 128KB of `'{a}' + '}'.repeat(n) + ',z}'` blocked the event loop for 27
// seconds to produce two results. `EXPANSION_MAX_REWRITES` bounds how many
// times the scan may restart. Real `{a},b}` input needs a handful.
var EXPANSION_MAX_REWRITES = 1000
function numeric(str) {
return parseInt(str, 10) == str
? parseInt(str, 10)
@@ -10270,34 +10290,54 @@ function unescapeBraces(str) {
}
// Like `target.push(...items)` but doesn't overflow the stack
function pushAll(target, items) {
for (var i = 0; i < items.length; i++) {
target.push(items[i]);
}
}
// Basically just str.split(","), but handling cases
// where we have nested braced sections, which should be
// treated as individual members, like {a,{b,c},d}
function parseCommaParts(str) {
if (!str)
return [''];
var parts = [];
var m = balanced('{', '}', str);
if (!m)
return str.split(',');
// Walk the brace groups iteratively. Recursing on `post` once per group let a
// chain of them exhaust the stack - the parsing-side counterpart to
// the `expand` overflow fixed for CVE-2026-14257, and not something `max` or
// `maxLength` can bound, since it happens before expansion.
//
// The part the next chunk continues
var carry = '';
var pre = m.pre;
var body = m.body;
var post = m.post;
var p = pre.split(',');
for (;;) {
var m = balanced('{', '}', str);
p[p.length-1] += '{' + body + '}';
var postParts = parseCommaParts(post);
if (post.length) {
p[p.length-1] += postParts.shift();
p.push.apply(p, postParts);
if (!m) {
var tail = str.split(',');
tail[0] = carry + tail[0];
pushAll(parts, tail);
return parts;
}
var pre = m.pre;
var body = m.body;
var post = m.post;
var p = pre.split(',');
p[0] = carry + p[0];
p[p.length-1] += '{' + body + '}';
if (!post.length) {
pushAll(parts, p);
return parts;
}
carry = p.pop();
pushAll(parts, p);
str = post;
}
parts.push.apply(parts, p);
return parts;
}
function expandTop(str, options) {
@@ -10307,6 +10347,8 @@ function expandTop(str, options) {
options = options || {};
var max = options.max == null ? EXPANSION_MAX : options.max;
var maxLength = options.maxLength == null ? EXPANSION_MAX_LENGTH : options.maxLength;
var maxDepth = options.maxDepth == null ? EXPANSION_MAX_DEPTH : options.maxDepth;
var maxRewrites = options.maxRewrites == null ? EXPANSION_MAX_REWRITES : options.maxRewrites;
// I don't know why Bash 4.3 does this, but it does.
// Anything starting with {} will have the first two bytes preserved
@@ -10318,7 +10360,7 @@ function expandTop(str, options) {
str = '\\{\\}' + str.substr(2);
}
return expand(escapeBraces(str), max, maxLength, true).map(unescapeBraces);
return expand(escapeBraces(str), max, maxLength, maxDepth, 0, maxRewrites, true).map(unescapeBraces);
}
function identity(e) {
@@ -10442,8 +10484,18 @@ function expand(
str,
max,
maxLength,
maxDepth,
depth,
maxRewrites,
isTop
) {
// Too deeply nested to keep following: treat the rest as literal, the same
// way a group that cannot expand is already handled. Truncating rather than
// throwing keeps expansion total, matching `max` and `maxLength`.
if (depth > maxDepth) {
return [str];
}
// Consume the string's top-level brace groups left to right, threading a
// running set of combined prefixes (`acc`). Expanding the tail iteratively -
// rather than recursing on `m.post` once per group - keeps the native stack
@@ -10464,6 +10516,9 @@ function expand(
// `accBase[a]` records how much of `acc[a]` predates the current run;
// `combine` treats an expansion as empty when it adds nothing past that.
var accBase = [0]
// How many times the `{a},b}` rewrite below has restarted the scan. Each pass
// re-reads the whole string, so leaving this unbounded is quadratic.
var rewrites = 0
var dropEmpties = false
var firstGroup = true
var nextBase
@@ -10495,7 +10550,8 @@ function expand(
var isOptions = m.body.indexOf(',') >= 0;
if (!isSequence && !isOptions) {
// {a},b}
if (m.post.match(/,(?!,).*\}/)) {
if (rewrites < maxRewrites && m.post.match(/,(?!,).*\}/)) {
rewrites++;
str = m.pre + '{' + m.body + escClose + m.post;
// The rewritten string is expanded as if it were a fresh top-level one,
// so start a new empty-drop run: anchor the baseline at what `acc`
@@ -10534,7 +10590,7 @@ function expand(
var n = parseCommaParts(m.body);
if (n.length === 1 && n[0] !== undefined) {
// x{{a,b}}y ==> x{a}y x{b}y
n = expand(n[0], max, maxLength, false).map(embrace);
n = expand(n[0], max, maxLength, maxDepth, depth + 1, maxRewrites, false).map(embrace);
//XXX is this necessary? Can't seem to hit it in tests.
/* c8 ignore start */
if (n.length === 1) {
@@ -10573,7 +10629,7 @@ function expand(
values = []
var valuesLength = 0
outer: for (var j = 0; j < n.length; j++) {
var expanded = expand(n[j], max, maxLength, false)
var expanded = expand(n[j], max, maxLength, maxDepth, depth + 1, maxRewrites, false)
for (var k = 0; k < expanded.length; k++) {
var v = expanded[k]
if (dropsEmpties && !v) continue