Brace Yourself: Denial-of-Service in a Billion-Download Dependency

brace-expansion is a very popular npm package with over 38 billion all-time downloads (yeah, over 38,000,000,000) and used by tooling almost every JavaScript project relies on - eslint, glob, and npm itself.
Despite being in the public eye for a while, we found a new Denial-of-Service vulnerability that could affect millions. This post walks through what the package does, existing issues that were fixed, and the new one we found - CVE-2026-13149.

How does this affect you?
Looking at the relatively low number of dependents of brace-expansion (npm lists only 2,802 dependents), and as the chart below shows, most users reach brace-expansion through other packages - the millions of weekly downloads are almost entirely transitive.

What even is brace-expansion?
Brace expansion is a Bash feature. Before the shell runs anything, it rewrites brace patterns into the list of strings they stand for - combinations of comma groups, numeric and alphabetic ranges.
$ echo {1..3}
1 2 3
$ echo {a,b}.{js,ts}
a.js a.ts b.js b.tsThe brace-expansion package reimplements those exact rules in JavaScript, giving us the option to use the previous expressions in node too:
const { expand } = require('brace-expansion');
expand('{1..3}');
// → ['1', '2', '3']
expand('{a,b}.{js,ts}');
// → ['a.js', 'a.ts', 'b.js', 'b.ts']Internally the library leans on balanced-match to find the first {…} group and split the string into three pieces:
pre- everything before the opening{body- what's inside the bracespost- the rest of the string after the matching}
Take this example a{1..3}b -
"a{1..3}b"
├─ pre = "a"
├─ body = "1..3"
└─ post = "b" The body is the only part that gets interpreted. So in this case 1..3 becomes 1, 2, 3 - while pre and post are glued on unchanged around each value: ['a1b', 'a2b', 'a3b'].
What happens if we have more braces after the body? Let's check on "^{1..3}{a..c}&":
"^{1..3}{a..c}&"
├─ pre = "^"
├─ body = "1..3"
└─ post = "{a..c}&"Since balanced-match splits the input at the first {…} group, everything trailing its closing } becomes the post - braces and all. Therefore it has to be expanded too, recursively:
"{a..c}&"
├─ pre = ""
├─ body = "a..c"
└─ post = "&"This is then used to generate a cartesian product with the first call body's expansion. Performing something like:
result = [ pre + b + p for every b in body, for every p in post ]And in human terms - every result will have pre followed by one body part b and one post part p, for all the combinations of body and post parts, recursively.
Domain expansion
This is very neat, but creating something out of nothing has its price - namely several vulnerabilities ranging from memory exhaustion to CPU hogging as recently reported in CVE-2026-45149 and CVE-2026-33750.
This might appear obvious, since the output can be dangerously larger than the input: something like {a,b}{a,b}{a,b}… doubles with every group, while {1..1000000000} simply expands to a very large array of strings.
Fret not, an option to limit the expansion was added - max, which defaults to 100,000 entries:
expand('{1..100000000}', { max: 100 }); // returns at most 100 strings
CVE-2026-13149 - All about that brace
This new bug is about how the expression is being expanded, not into what it is being expanded to, making the max option irrelevant.
In practice, expand_() computes post - the recursive expansion of everything after the first brace group - at the top of the function, unconditionally, before the branches that decide whether post is even needed:
// https://github.com/juliangruber/brace-expansion/blob/v5.0.6/src/index.ts#L115-L227
function expand_(str, max, isTop) {
// ...
const m = balanced('{', '}', str); // splits -> m.pre / m.body / m.post
// ...
const pre = m.pre;
// ...
const post = m.post.length ? expand_(m.post, max, false) : [''];
// ...
if (!isSequence && !isOptions) { // body is neither range nor list of options
if (m.post.match(/,(?!,).*\}/)) {
str = m.pre + '{' + m.body + escClose + m.post; // escape the first '}' so that {} goes literal
return expand_(str, max, true); // restart from the top; re-expands post
}
return [str];
}
// ...
}The cause is the order of operations inside expand_. post - the expansion of everything after the first group - is computed at the very top. When the body is an empty {}, the function never uses it: it escapes that group's closing brace (} becomes the literal escClose) so the {} can no longer match as a brace set, and restarts expand_ from the top on the rebuilt string. That only 'eats' one pair of braces, and re-expands the entire tail after it - the same tail the discarded post just expanded.
So every empty pair of braces is paid for twice: once by the eager post recursion whose result is thrown away, and once by the restart. Each of those two branches then reaches the next group and splits the same way, so the work doubles with every added pair - 2ⁿ for n pairs.
While the result is still correct - it takes a very long time due to its algorithmic complexity, passing merely 32 pairs blocks the runtime for ~13 minutes while the code is crunching nothing:

For more information see advisory CVE-2026-13149.
Proof of concept
The PoC is quite simple - a bunch of empty pairs of braces
const { expand } = require('brace-expansion');
expand('a{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}');
Impact & Remediation
Any application that passes attacker-influenced strings to expand() - directly, or transitively via a brace pattern that reaches minimatch/glob - can be driven into a multi-minute-to-indefinite CPU hang by a tiny request.
This highlights the difficulty of actually remediating vulnerabilities in your projects. When the vulnerable code is several levels down your tree, you can't simply upgrade it - you wait for the maintainers in between to release updated package requirements. For example, the most downloaded version of minimatch - v3.1.5 requires a still-vulnerable version of brace-expansion from major 1.x.
Seal Security ships the patched version directly into your dependencies, without waiting for dependent packages to upgrade.
Disclosure timeline
2026-06-24 Seal Security reported the vulnerability to the maintainer
2026-06-25 Maintainer confirmed the issue
2026-06-29 Fixed version published

%20copy.jpg)
.png)
.png)
.png)
