index.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. var balanced = require('balanced-match');
  2. module.exports = expandTop;
  3. var escSlash = '\0SLASH'+Math.random()+'\0';
  4. var escOpen = '\0OPEN'+Math.random()+'\0';
  5. var escClose = '\0CLOSE'+Math.random()+'\0';
  6. var escComma = '\0COMMA'+Math.random()+'\0';
  7. var escPeriod = '\0PERIOD'+Math.random()+'\0';
  8. var EXPANSION_MAX = 100000
  9. // `EXPANSION_MAX` caps the *number* of expansions, but not their length. An
  10. // input like `'{a,b}'.repeat(1500)` stays under that count - its output is
  11. // truncated to 100k results - while making every result ~1500 characters
  12. // long. The result set, and the intermediate arrays built while combining
  13. // brace sets, then grow large enough to exhaust memory and crash the process
  14. // (CVE-2026-14257). `EXPANSION_MAX_LENGTH` bounds the total number of
  15. // characters the accumulator may hold at any point, so memory stays flat no
  16. // matter how many brace groups are chained. The limit sits well above any
  17. // realistic expansion (100k results hitting `EXPANSION_MAX` measure ~1M
  18. // characters) so legitimate input is unaffected.
  19. var EXPANSION_MAX_LENGTH = 4000000
  20. // `expand` recurses once per level of brace *nesting* - both when expanding a
  21. // set's comma members and when re-wrapping a set whose body is a single part.
  22. // The CVE-2026-14257 fix made the *tail* iterative (recursion on `m.post`, one
  23. // level per chained group), which left nesting depth unbounded: about 3,100
  24. // levels of `{{{...a,b...}}}` - only ~6KB of input - exhausted the native stack
  25. // and crashed the process. `EXPANSION_MAX_DEPTH` bounds how deep the parser
  26. // will follow nesting. It sits far above any realistic pattern and well below
  27. // the depth at which the stack runs out.
  28. var EXPANSION_MAX_DEPTH = 1000
  29. // Bash keeps a quirk where a brace group followed by a comma set still expands
  30. // (`{a},b}`). The parser implements it by rewriting the string and restarting
  31. // the scan, absorbing one `}` per pass. `n` trailing braces therefore cost `n`
  32. // full passes over a string that itself grows by one `escClose` sentinel each
  33. // time - quadratic in `n`, with a ~26x constant from the sentinel's length.
  34. // 128KB of `'{a}' + '}'.repeat(n) + ',z}'` blocked the event loop for 27
  35. // seconds to produce two results. `EXPANSION_MAX_REWRITES` bounds how many
  36. // times the scan may restart. Real `{a},b}` input needs a handful.
  37. var EXPANSION_MAX_REWRITES = 1000
  38. function numeric(str) {
  39. return parseInt(str, 10) == str
  40. ? parseInt(str, 10)
  41. : str.charCodeAt(0);
  42. }
  43. function escapeBraces(str) {
  44. return str.split('\\\\').join(escSlash)
  45. .split('\\{').join(escOpen)
  46. .split('\\}').join(escClose)
  47. .split('\\,').join(escComma)
  48. .split('\\.').join(escPeriod);
  49. }
  50. function unescapeBraces(str) {
  51. return str.split(escSlash).join('\\')
  52. .split(escOpen).join('{')
  53. .split(escClose).join('}')
  54. .split(escComma).join(',')
  55. .split(escPeriod).join('.');
  56. }
  57. // Like `target.push(...items)` but doesn't overflow the stack
  58. function pushAll(target, items) {
  59. for (var i = 0; i < items.length; i++) {
  60. target.push(items[i]);
  61. }
  62. }
  63. // Basically just str.split(","), but handling cases
  64. // where we have nested braced sections, which should be
  65. // treated as individual members, like {a,{b,c},d}
  66. function parseCommaParts(str) {
  67. var parts = [];
  68. // Walk the brace groups iteratively. Recursing on `post` once per group let a
  69. // chain of them exhaust the stack - the parsing-side counterpart to
  70. // the `expand` overflow fixed for CVE-2026-14257, and not something `max` or
  71. // `maxLength` can bound, since it happens before expansion.
  72. //
  73. // The part the next chunk continues
  74. var carry = '';
  75. for (;;) {
  76. var m = balanced('{', '}', str);
  77. if (!m) {
  78. var tail = str.split(',');
  79. tail[0] = carry + tail[0];
  80. pushAll(parts, tail);
  81. return parts;
  82. }
  83. var pre = m.pre;
  84. var body = m.body;
  85. var post = m.post;
  86. var p = pre.split(',');
  87. p[0] = carry + p[0];
  88. p[p.length-1] += '{' + body + '}';
  89. if (!post.length) {
  90. pushAll(parts, p);
  91. return parts;
  92. }
  93. carry = p.pop();
  94. pushAll(parts, p);
  95. str = post;
  96. }
  97. }
  98. function expandTop(str, options) {
  99. if (!str)
  100. return [];
  101. options = options || {};
  102. var max = options.max == null ? EXPANSION_MAX : options.max;
  103. var maxLength = options.maxLength == null ? EXPANSION_MAX_LENGTH : options.maxLength;
  104. var maxDepth = options.maxDepth == null ? EXPANSION_MAX_DEPTH : options.maxDepth;
  105. var maxRewrites = options.maxRewrites == null ? EXPANSION_MAX_REWRITES : options.maxRewrites;
  106. // I don't know why Bash 4.3 does this, but it does.
  107. // Anything starting with {} will have the first two bytes preserved
  108. // but *only* at the top level, so {},a}b will not expand to anything,
  109. // but a{},b}c will be expanded to [a}c,abc].
  110. // One could argue that this is a bug in Bash, but since the goal of
  111. // this module is to match Bash's rules, we escape a leading {}
  112. if (str.substr(0, 2) === '{}') {
  113. str = '\\{\\}' + str.substr(2);
  114. }
  115. return expand(escapeBraces(str), max, maxLength, maxDepth, 0, maxRewrites, true).map(unescapeBraces);
  116. }
  117. function embrace(str) {
  118. return '{' + str + '}';
  119. }
  120. function isPadded(el) {
  121. return /^-?0\d/.test(el);
  122. }
  123. function lte(i, y) {
  124. return i <= y;
  125. }
  126. function gte(i, y) {
  127. return i >= y;
  128. }
  129. // Build `{ acc[a] + pre + values[v] }` for every combination, capping the
  130. // number of results at `max` and the total number of characters at `maxLength`.
  131. // This is the one place output grows, so bounding it here keeps the single
  132. // accumulator - and therefore memory - flat regardless of how many brace groups
  133. // are combined (CVE-2026-14257).
  134. function combine(
  135. acc,
  136. pre,
  137. values,
  138. max,
  139. maxLength,
  140. dropEmpties
  141. ) {
  142. var out = []
  143. var length = 0
  144. for (var a = 0; a < acc.length; a++) {
  145. for (var v = 0; v < values.length; v++) {
  146. if (out.length >= max) return out
  147. var expansion = acc[a] + pre + values[v]
  148. // Bash drops empty results at the top level. Skip them before they count
  149. // against `max`, so `max` bounds the number of *kept* results.
  150. if (dropEmpties && !expansion) continue
  151. if (length + expansion.length > maxLength) return out
  152. out.push(expansion)
  153. length += expansion.length
  154. }
  155. }
  156. return out
  157. }
  158. // The expansion values of a single numeric (`1..5`) or alphabetic (`a..e..2`)
  159. // sequence body.
  160. function expandSequence(
  161. body,
  162. isAlphaSequence,
  163. max,
  164. maxLength
  165. ) {
  166. var n = body.split(/\.\./)
  167. var N = []
  168. // A sequence body always splits into two or three parts, but the compiler
  169. // can't know that.
  170. /* c8 ignore start */
  171. if (n[0] === undefined || n[1] === undefined) {
  172. return N
  173. }
  174. /* c8 ignore stop */
  175. var x = numeric(n[0])
  176. var y = numeric(n[1])
  177. var width = Math.max(n[0].length, n[1].length)
  178. var incr =
  179. n.length === 3 && n[2] !== undefined ?
  180. Math.max(Math.abs(numeric(n[2])), 1)
  181. : 1
  182. var test = lte
  183. var reverse = y < x
  184. if (reverse) {
  185. incr *= -1
  186. test = gte
  187. }
  188. var pad = n.some(isPadded)
  189. var length = 0
  190. for (var i = x; test(i, y) && N.length < max; i += incr) {
  191. var c
  192. if (isAlphaSequence) {
  193. c = String.fromCharCode(i)
  194. if (c === '\\') {
  195. c = ''
  196. }
  197. } else {
  198. c = String(i)
  199. if (pad) {
  200. var need = width - c.length
  201. if (need > 0) {
  202. var z = new Array(need + 1).join('0')
  203. if (i < 0) {
  204. c = '-' + z + c.slice(1)
  205. } else {
  206. c = z + c
  207. }
  208. }
  209. }
  210. }
  211. if (length + c.length > maxLength) break
  212. N.push(c)
  213. length += c.length
  214. }
  215. return N
  216. }
  217. function expand(
  218. str,
  219. max,
  220. maxLength,
  221. maxDepth,
  222. depth,
  223. maxRewrites,
  224. isTop
  225. ) {
  226. // Too deeply nested to keep following: treat the rest as literal, the same
  227. // way a group that cannot expand is already handled. Truncating rather than
  228. // throwing keeps expansion total, matching `max` and `maxLength`.
  229. if (depth > maxDepth) {
  230. return [str];
  231. }
  232. // Consume the string's top-level brace groups left to right, threading a
  233. // running set of combined prefixes (`acc`). Expanding the tail iteratively -
  234. // rather than recursing on `m.post` once per group - keeps the native stack
  235. // depth constant, so deeply chained input (`'{a,b}'.repeat(3000)`) can no
  236. // longer overflow the stack, and leaves a single accumulator whose size
  237. // `maxLength` bounds directly (CVE-2026-14257).
  238. var acc = ['']
  239. // Bash drops empty results, but only when the *first* top-level group is a
  240. // comma set - a sequence like `{a..\}` may legitimately yield ''. The drop
  241. // is on the final strings, so it is applied to whichever `combine` produces
  242. // them (the one with no brace set left in the tail).
  243. // How many times the `{a},b}` rewrite below has restarted the scan. Each pass
  244. // re-reads the whole string, so leaving this unbounded is quadratic.
  245. var rewrites = 0
  246. var dropEmpties = false
  247. var firstGroup = true
  248. for (;;) {
  249. const m = balanced('{', '}', str)
  250. // No brace set left: the rest of the string is literal.
  251. if (!m) {
  252. return combine(acc, str, [''], max, maxLength, dropEmpties)
  253. }
  254. // no need to expand pre, since it is guaranteed to be free of brace-sets
  255. const pre = m.pre
  256. if (/\$$/.test(pre)) {
  257. acc = combine(
  258. acc,
  259. pre + '{' + m.body + '}',
  260. [''],
  261. max,
  262. maxLength,
  263. dropEmpties && !m.post.length
  264. )
  265. firstGroup = false
  266. if (!m.post.length) break
  267. str = m.post
  268. continue
  269. }
  270. var isNumericSequence = /^-?\d+\.\.-?\d+(?:\.\.-?\d+)?$/.test(m.body);
  271. var isAlphaSequence = /^[a-zA-Z]\.\.[a-zA-Z](?:\.\.-?\d+)?$/.test(m.body);
  272. var isSequence = isNumericSequence || isAlphaSequence;
  273. var isOptions = m.body.indexOf(',') >= 0;
  274. if (!isSequence && !isOptions) {
  275. // {a},b}
  276. if (rewrites < maxRewrites && m.post.match(/,(?!,).*\}/)) {
  277. rewrites++;
  278. str = m.pre + '{' + m.body + escClose + m.post;
  279. isTop = true;
  280. continue;
  281. }
  282. // Nothing here expands, so the whole remaining string is literal.
  283. return combine(
  284. acc,
  285. pre + '{' + m.body + '}' + m.post,
  286. [''],
  287. max,
  288. maxLength,
  289. dropEmpties
  290. )
  291. }
  292. if (firstGroup) {
  293. dropEmpties = isTop && !isSequence
  294. firstGroup = false
  295. }
  296. var values;
  297. if (isSequence) {
  298. values = expandSequence(m.body, isAlphaSequence, max, maxLength);
  299. } else {
  300. var n = parseCommaParts(m.body);
  301. if (n.length === 1 && n[0] !== undefined) {
  302. // x{{a,b}}y ==> x{a}y x{b}y
  303. n = expand(n[0], max, maxLength, maxDepth, depth + 1, maxRewrites, false).map(embrace);
  304. //XXX is this necessary? Can't seem to hit it in tests.
  305. /* c8 ignore start */
  306. if (n.length === 1) {
  307. acc = combine(
  308. acc,
  309. pre + n[0],
  310. [''],
  311. max,
  312. maxLength,
  313. dropEmpties && !m.post.length
  314. )
  315. if (!m.post.length) break
  316. str = m.post
  317. continue
  318. }
  319. /* c8 ignore stop */
  320. }
  321. // Values that `combine` is going to drop as empty produce no result, so
  322. // they must not count against `max` - otherwise `{a,,b}` with `max: 2`
  323. // would stop at `['a', '']` and yield one result instead of two. Skipping
  324. // them outright keeps `values` bounded while leaving `max` a bound on
  325. // *kept* results.
  326. var dropsEmpties = dropEmpties && !m.post.length && !pre
  327. for (var d = 0; dropsEmpties && d < acc.length; d++) {
  328. if (acc[d]) {
  329. dropsEmpties = false
  330. }
  331. }
  332. values = []
  333. var valuesLength = 0
  334. outer: for (var j = 0; j < n.length; j++) {
  335. var expanded = expand(n[j], max, maxLength, maxDepth, depth + 1, maxRewrites, false)
  336. for (var k = 0; k < expanded.length; k++) {
  337. var v = expanded[k]
  338. if (dropsEmpties && !v) continue
  339. if (values.length >= max || valuesLength + v.length > maxLength) {
  340. break outer
  341. }
  342. values.push(v)
  343. valuesLength += v.length
  344. }
  345. }
  346. }
  347. acc = combine(acc, pre, values, max, maxLength, dropEmpties && !m.post.length)
  348. if (!m.post.length) break
  349. str = m.post
  350. }
  351. return acc
  352. }