The Hidden Bomb in Your Dependency Tree
Not every security vulnerability involves a clever attacker bypassing authentication or injecting SQL. Some of the most impactful attacks are embarrassingly simple: send a single, carefully shaped string and watch a server grind to a halt. CVE-2026-13149 is exactly that kind of vulnerability — lurking inside a transitive npm dependency called brace-expansion, waiting for a string like {a,b}{c,d}{e,f}{g,h}{i,j}{k,l}{m,n}{o,p} to arrive.
This post walks through exactly what went wrong in package-lock.json, why the vulnerable version 2.0.3 of brace-expansion was silently nested inside node_modules/filelist and node_modules/glob, and how the upgrade to 2.1.2 closes the door.
The Vulnerability Explained
What is brace expansion?
Brace expansion is the shell-style feature that turns file{1,2,3}.txt into file1.txt file2.txt file3.txt. The brace-expansion npm package implements this for JavaScript, and it is a transitive dependency of widely-used tools like minimatch and glob — meaning it ends up in almost every Node.js project that does any file-matching.
The exponential-time trap in version 2.0.3
In brace-expansion@2.0.3, the expansion algorithm does not guard against combinatorial explosion. When you provide a pattern with multiple independent brace groups, the number of output strings grows as a product of each group's options. For example:
{a,b,c}{d,e,f}{g,h,i}{j,k,l}{m,n,o}{p,q,r}
This single string expands to 3⁶ = 729 results — still manageable. But add more groups:
{a,b,c,d,e,f,g,h,i,j}{a,b,c,d,e,f,g,h,i,j}{a,b,c,d,e,f,g,h,i,j}...
With enough groups, the expansion count grows past millions and billions. Because Node.js runs JavaScript on a single-threaded event loop, a synchronous computation that takes seconds — or minutes — blocks every other request on the server.
The vulnerable version had no maximum output count, no depth guard, and no timeout. An attacker who can influence a string that eventually reaches brace-expansion's expand() function can trigger this with a payload as short as a few hundred characters.
Where the vulnerable version was hiding
The critical detail in this PR is where 2.0.3 lived. Look at the diff:
- "node_modules/filelist/node_modules/brace-expansion": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
- "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
- "dependencies": {
- "balanced-match": "^1.0.0"
- }
- },
npm's nested node_modules resolution means that filelist (and glob) had their own private copy of brace-expansion@2.0.3 installed at node_modules/filelist/node_modules/brace-expansion. Even if the top-level brace-expansion had been safe, these nested copies would have been loaded by those packages — and they were the vulnerable ones.
Attack scenario
Imagine a build tool or file-watcher that accepts a user-supplied glob pattern via a configuration file or API endpoint, passes it to minimatch or glob, which internally calls brace-expansion. An attacker submits:
{1,2,3,4,5,6,7,8,9,0}{1,2,3,4,5,6,7,8,9,0}{1,2,3,4,5,6,7,8,9,0}{1,2,3,4,5,6,7,8,9,0}{1,2,3,4,5,6,7,8,9,0}{1,2,3,4,5,6,7,8,9,0}{1,2,3,4,5,6,7,8,9,0}
That's 10⁷ = 10 million expansions from a 70-character string. The server's event loop stalls, health checks time out, and the service becomes unavailable — a textbook DoS with zero authentication required.
The Fix
The PR makes two coordinated changes in package-lock.json:
1. Hoist a safe top-level resolution
A new top-level entry for brace-expansion@2.1.2 is added:
+ "node_modules/brace-expansion": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
+ "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
This ensures that any package resolving brace-expansion from the top of the tree gets 2.1.2.
2. Remove the nested vulnerable copies
The nested node_modules/filelist/node_modules/brace-expansion entry pinned at 2.0.3 is deleted entirely, as is the corresponding node_modules/glob/node_modules/brace-expansion block. With those overrides gone, npm's resolution algorithm walks up the tree and finds the safe 2.1.2 at the top level.
Before (vulnerable nested copy):
"node_modules/filelist/node_modules/brace-expansion": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-MCV/...",
"dependencies": {
"balanced-match": "^1.0.0"
}
}
After (resolved to safe top-level version):
"node_modules/brace-expansion": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZc...",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
}
The patched 2.1.2 release introduces an expansion guard that limits combinatorial output, preventing the exponential blowup regardless of what pattern is provided.
Prevention & Best Practices
1. Audit your transitive dependency tree regularly
The vulnerable version 2.0.3 was not a direct dependency — it was nested two levels deep under filelist and glob. Tools like npm audit, trivy, and snyk scan the full package-lock.json tree, not just your package.json direct dependencies. Run them in CI on every pull request.
npm audit
# or
trivy fs --scanners vuln .
2. Never trust user-supplied glob/brace patterns without sanitization
If your application accepts glob patterns from users (configuration files, API parameters, CLI arguments), validate them before passing them to minimatch, glob, filelist, or any brace-expansion consumer:
const MAX_PATTERN_LENGTH = 256;
function safeGlob(pattern, options) {
if (typeof pattern !== 'string' || pattern.length > MAX_PATTERN_LENGTH) {
throw new Error('Invalid or oversized glob pattern');
}
return glob(pattern, options);
}
This is defense-in-depth — even on a patched version, limiting input complexity is good hygiene.
3. Lock files are security artifacts
package-lock.json is not just a reproducibility tool — it is a security document. Commit it, review changes to it in PRs, and treat unexpected version bumps in nested node_modules/* entries as potential supply-chain signals.
4. Use overrides in package.json for persistent control
For projects where you cannot immediately update a direct dependency, npm's overrides field forces a safe version across the entire tree:
{
"overrides": {
"brace-expansion": ">=2.1.2"
}
}
5. Relevant standards
- CWE-1333: Inefficient Regular Expression Complexity
- CWE-400: Uncontrolled Resource Consumption
- OWASP: Denial of Service Cheat Sheet
Key Takeaways
- Nested
node_modulescopies are invisible to casual inspection:brace-expansion@2.0.3was pinned insidenode_modules/filelist/node_modules/andnode_modules/glob/node_modules/, meaning a top-level upgrade alone would not have fixed it — the nested overrides had to be explicitly removed. - Exponential complexity DoS requires no authentication: The attack payload for CVE-2026-13149 is a short, valid-looking string — no credentials, no special permissions, no exploit chain.
package-lock.jsondiffs deserve security review: The entire fix lives inpackage-lock.json, not application code. Teams that skip lock-file review in PRs miss this class of vulnerability entirely.- Hoisting a safe version is the right pattern: Adding a top-level
node_modules/brace-expansion@2.1.2entry and removing nested overrides is the canonical npm fix for transitive dependency vulnerabilities — not just patching the direct dependency inpackage.json. - Trivy caught what manual review would likely miss: Static analysis of the full dependency manifest (not just direct deps) is essential for catching deeply nested vulnerable packages like this one.
How Orbis AppSec Detected This
- Source: User-influenced string input (e.g., glob pattern from configuration or API) passed to
glob,minimatch, orfilelist - Sink:
brace-expansion'sexpand()function called internally byfilelistandglob, resolved from the vulnerable nested copy atnode_modules/filelist/node_modules/brace-expansionversion2.0.3 - Missing control: No expansion complexity guard or output-count limit in
brace-expansion@2.0.3; no input validation before the pattern reaches the expansion function - CWE: CWE-1333 (Inefficient Regular Expression Complexity) / CWE-400 (Uncontrolled Resource Consumption)
- Fix: Removed the nested
brace-expansion@2.0.3entries underfilelistandglobinpackage-lock.jsonand added a top-level resolution to the patchedbrace-expansion@2.1.2
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
CVE-2026-13149 is a reminder that the most dangerous vulnerabilities are sometimes the quietest ones. brace-expansion is a utility so small and ubiquitous that most developers never think about it — yet a single crafted string routed through it could bring down a Node.js service. The fix here is surgical: hoist a safe version to the top of the dependency tree and remove the nested overrides that were keeping the vulnerable 2.0.3 alive for filelist and glob.
More broadly, this case illustrates why dependency security cannot stop at your direct package.json entries. The real attack surface lives in the full transitive tree locked in package-lock.json, and keeping that tree audited — automatically, on every commit — is the only reliable defense.