Skip to content

Commit d74e630

Browse files
fix: v1 backport for CVE-2026-13149 (#122)
* fix: backport for CVE-2026-13149 * fix: backport for CVE-2026-13149 * Consistency with v5 patch * Consistency with v5 patch * Consistency with v5 patch * Update test/unbound-recursion.js * Update test/unbound-recursion.js --------- Co-authored-by: Julian Gruber <julian@juliangruber.com>
1 parent 2203f4f commit d74e630

2 files changed

Lines changed: 106 additions & 84 deletions

File tree

index.js

Lines changed: 90 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -103,101 +103,107 @@ function gte(i, y) {
103103
function expand(str, max, isTop) {
104104
var expansions = [];
105105

106-
var m = balanced('{', '}', str);
107-
if (!m || /\$$/.test(m.pre)) return [str];
108-
109-
var isNumericSequence = /^-?\d+\.\.-?\d+(?:\.\.-?\d+)?$/.test(m.body);
110-
var isAlphaSequence = /^[a-zA-Z]\.\.[a-zA-Z](?:\.\.-?\d+)?$/.test(m.body);
111-
var isSequence = isNumericSequence || isAlphaSequence;
112-
var isOptions = m.body.indexOf(',') >= 0;
113-
if (!isSequence && !isOptions) {
114-
// {a},b}
115-
if (m.post.match(/,(?!,).*\}/)) {
116-
str = m.pre + '{' + m.body + escClose + m.post;
117-
return expand(str, max, true);
106+
// The `{a},b}` rewrite below restarts expansion on a rewritten string with
107+
// the same `max` and `isTop = true`. Loop instead of recursing so a long run
108+
// of non-expanding `{}` groups can't exhaust the call stack.
109+
for (;;) {
110+
var m = balanced('{', '}', str);
111+
if (!m || /\$$/.test(m.pre)) return [str];
112+
113+
var isNumericSequence = /^-?\d+\.\.-?\d+(?:\.\.-?\d+)?$/.test(m.body);
114+
var isAlphaSequence = /^[a-zA-Z]\.\.[a-zA-Z](?:\.\.-?\d+)?$/.test(m.body);
115+
var isSequence = isNumericSequence || isAlphaSequence;
116+
var isOptions = m.body.indexOf(',') >= 0;
117+
if (!isSequence && !isOptions) {
118+
// {a},b}
119+
if (m.post.match(/,(?!,).*\}/)) {
120+
str = m.pre + '{' + m.body + escClose + m.post;
121+
isTop = true
122+
continue
123+
}
124+
return [str];
118125
}
119-
return [str];
120-
}
121126

122-
var n;
123-
if (isSequence) {
124-
n = m.body.split(/\.\./);
125-
} else {
126-
n = parseCommaParts(m.body);
127-
if (n.length === 1) {
128-
// x{{a,b}}y ==> x{a}y x{b}y
129-
n = expand(n[0], max, false).map(embrace);
127+
var n;
128+
if (isSequence) {
129+
n = m.body.split(/\.\./);
130+
} else {
131+
n = parseCommaParts(m.body);
130132
if (n.length === 1) {
131-
var post = m.post.length
132-
? expand(m.post, max, false)
133-
: [''];
134-
return post.map(function(p) {
135-
return m.pre + n[0] + p;
136-
});
133+
// x{{a,b}}y ==> x{a}y x{b}y
134+
n = expand(n[0], max, false).map(embrace);
135+
if (n.length === 1) {
136+
var post = m.post.length
137+
? expand(m.post, max, false)
138+
: [''];
139+
return post.map(function(p) {
140+
return m.pre + n[0] + p;
141+
});
142+
}
137143
}
138144
}
139-
}
140-
141-
// at this point, n is the parts, and we know it's not a comma set
142-
// with a single entry.
143145

144-
// no need to expand pre, since it is guaranteed to be free of brace-sets
145-
var pre = m.pre;
146-
var post = m.post.length
147-
? expand(m.post, max, false)
148-
: [''];
149-
150-
var N;
151-
152-
if (isSequence) {
153-
var x = numeric(n[0]);
154-
var y = numeric(n[1]);
155-
var width = Math.max(n[0].length, n[1].length)
156-
var incr = n.length == 3
157-
? Math.max(Math.abs(numeric(n[2])), 1)
158-
: 1;
159-
var test = lte;
160-
var reverse = y < x;
161-
if (reverse) {
162-
incr *= -1;
163-
test = gte;
164-
}
165-
var pad = n.some(isPadded);
166-
167-
N = [];
168-
169-
for (var i = x; test(i, y) && N.length < max; i += incr) {
170-
var c;
171-
if (isAlphaSequence) {
172-
c = String.fromCharCode(i);
173-
if (c === '\\')
174-
c = '';
175-
} else {
176-
c = String(i);
177-
if (pad) {
178-
var need = width - c.length;
179-
if (need > 0) {
180-
var z = new Array(need + 1).join('0');
181-
if (i < 0)
182-
c = '-' + z + c.slice(1);
183-
else
184-
c = z + c;
146+
// at this point, n is the parts, and we know it's not a comma set
147+
// with a single entry.
148+
149+
// no need to expand pre, since it is guaranteed to be free of brace-sets
150+
var pre = m.pre;
151+
var post = m.post.length
152+
? expand(m.post, max, false)
153+
: [''];
154+
155+
var N;
156+
157+
if (isSequence) {
158+
var x = numeric(n[0]);
159+
var y = numeric(n[1]);
160+
var width = Math.max(n[0].length, n[1].length)
161+
var incr = n.length == 3
162+
? Math.max(Math.abs(numeric(n[2])), 1)
163+
: 1;
164+
var test = lte;
165+
var reverse = y < x;
166+
if (reverse) {
167+
incr *= -1;
168+
test = gte;
169+
}
170+
var pad = n.some(isPadded);
171+
172+
N = [];
173+
174+
for (var i = x; test(i, y) && N.length < max; i += incr) {
175+
var c;
176+
if (isAlphaSequence) {
177+
c = String.fromCharCode(i);
178+
if (c === '\\')
179+
c = '';
180+
} else {
181+
c = String(i);
182+
if (pad) {
183+
var need = width - c.length;
184+
if (need > 0) {
185+
var z = new Array(need + 1).join('0');
186+
if (i < 0)
187+
c = '-' + z + c.slice(1);
188+
else
189+
c = z + c;
190+
}
185191
}
186192
}
193+
N.push(c);
187194
}
188-
N.push(c);
195+
} else {
196+
N = concatMap(n, function(el) { return expand(el, max, false) });
189197
}
190-
} else {
191-
N = concatMap(n, function(el) { return expand(el, max, false) });
192-
}
193198

194-
for (var j = 0; j < N.length; j++) {
195-
for (var k = 0; k < post.length && expansions.length < max; k++) {
196-
var expansion = pre + N[j] + post[k];
197-
if (!isTop || isSequence || expansion)
198-
expansions.push(expansion);
199+
for (var j = 0; j < N.length; j++) {
200+
for (var k = 0; k < post.length && expansions.length < max; k++) {
201+
var expansion = pre + N[j] + post[k];
202+
if (!isTop || isSequence || expansion)
203+
expansions.push(expansion);
204+
}
199205
}
200-
}
201206

202-
return expansions;
207+
return expansions;
208+
}
203209
}

test/unbound-recursion.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
var test = require('tape');
2+
var expand = require('..');
3+
4+
test('unbound recursion', function (t) {
5+
const n = 5000
6+
const parts = []
7+
for (let i = 0; i < n; i++) parts.push('{}')
8+
const str = parts.join(',')
9+
const startTime = performance.now()
10+
const expanded = expand(str)
11+
const endTime = performance.now()
12+
const duration = endTime - startTime
13+
t.deepEqual(expanded, [str], 'does not expand')
14+
t.ok(duration < 5000, `expected expansion to be less than 5000ms: ${duration}ms`)
15+
t.end()
16+
})

0 commit comments

Comments
 (0)