diff --git a/dist/index.js b/dist/index.js index 3d187bf..41bdc53 100644 --- a/dist/index.js +++ b/dist/index.js @@ -18499,6 +18499,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) @@ -18522,34 +18542,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 + '}'; - parts.push.apply(parts, p); + if (!post.length) { + pushAll(parts, p); + return parts; + } - return parts; + carry = p.pop(); + pushAll(parts, p); + str = post; + } } function expandTop(str, options) { @@ -18559,6 +18599,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 @@ -18570,7 +18612,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 embrace(str) { @@ -18682,8 +18724,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 @@ -18696,6 +18748,9 @@ function expand( // comma set - a sequence like `{a..\}` may legitimately yield ''. The drop // is on the final strings, so it is applied to whichever `combine` produces // them (the one with no brace set left in the tail). + // 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 @@ -18731,7 +18786,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; isTop = true; continue; @@ -18759,7 +18815,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) { @@ -18793,7 +18849,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 @@ -66827,6 +66883,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) @@ -66850,34 +66926,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 + '}'; - parts.push.apply(parts, p); + if (!post.length) { + pushAll(parts, p); + return parts; + } - return parts; + carry = p.pop(); + pushAll(parts, p); + str = post; + } } function expandTop(str, options) { @@ -66887,6 +66983,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 @@ -66898,7 +66996,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 embrace(str) { @@ -67010,8 +67108,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 @@ -67024,6 +67132,9 @@ function expand( // comma set - a sequence like `{a..\}` may legitimately yield ''. The drop // is on the final strings, so it is applied to whichever `combine` produces // them (the one with no brace set left in the tail). + // 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 @@ -67059,7 +67170,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; isTop = true; continue; @@ -67087,7 +67199,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) { @@ -67121,7 +67233,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 diff --git a/package-lock.json b/package-lock.json index 431b93d..ac1a2ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3059,9 +3059,10 @@ } }, "node_modules/archiver-utils/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -3370,9 +3371,10 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -4645,9 +4647,10 @@ } }, "node_modules/readdir-glob/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -7539,9 +7542,9 @@ }, "dependencies": { "brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "requires": { "balanced-match": "^1.0.0" } @@ -7755,9 +7758,9 @@ "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==" }, "brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -8594,9 +8597,9 @@ }, "dependencies": { "brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "requires": { "balanced-match": "^1.0.0" }