How Regular Expression Denial of Service happens in Node.js picomatch extglob patterns and how to fix it
Summary
A Regular Expression Denial of Service (ReDoS) vulnerability in picomatch — one of the most widely used glob-matching libraries in the Node.js ecosystem — allowed attackers to craft malicious extglob patterns that triggered catastrophic backtracking in the JavaScript regex engine. Tracked as CVE-2026-33671 with a HIGH severity rating, this flaw affected picomatch versions prior to 2.3.2, 3.0.2, and 4.0.4. The fix pins picomatch explicitly in package.json and upgrades all resolved versions in pnpm-lock.yaml to eliminate the vulnerable code paths.
Introduction
The pnpm-lock.yaml file in this repository was resolving picomatch@2.3.1 as a transitive dependency — a version containing a regex engine flaw that could be weaponized with a single crafted string. Picomatch is responsible for converting glob patterns (like **/*.js or !(foo|bar)) into regular expressions that Node.js can evaluate. The vulnerability lives specifically in its extglob handling: the extended glob syntax that supports patterns like +(foo), *(bar), and !(baz).
When picomatch processes these extglob patterns, it constructs a regular expression internally. In versions prior to the fix, certain combinations of nested or repeated extglob quantifiers produced regex structures susceptible to catastrophic backtracking — a condition where the regex engine's backtracking algorithm explores an exponentially growing number of possible match paths for a carefully constructed non-matching string.
For developers using build tools, bundlers, test runners, or file watchers (virtually any modern Node.js toolchain), picomatch is almost certainly somewhere in your dependency tree. That makes this a particularly broad-impact vulnerability.
The Vulnerability Explained
What Are Extglob Patterns?
Extglob (extended globbing) patterns are a superset of standard glob syntax. They support quantifier-like constructs:
| Pattern | Meaning |
|---|---|
?(pattern) |
Match zero or one occurrence |
*(pattern) |
Match zero or more occurrences |
+(pattern) |
Match one or more occurrences |
@(pattern) |
Match exactly one occurrence |
!(pattern) |
Match anything except the pattern |
These are powerful for file matching, but converting them to regular expressions is non-trivial. The bug in picomatch 2.3.1 arises from how it generates the internal regex for these patterns.
The Root Cause: Catastrophic Backtracking
Consider a simplified version of what picomatch 2.3.1 might generate for a nested extglob like +(a+(b)):
// Conceptual regex generated by vulnerable picomatch for nested extglob
/^(?:a(?:b)+)+$/
This kind of nested quantifier structure — (?:...)+ inside another (?:...)+ — is the classic recipe for catastrophic backtracking. When the regex engine tries to match a string like "aaaaaaaaaaaaaaaaaaaac" (many as followed by a non-matching character), it explores all possible ways to partition the as between the outer and inner groups before concluding there's no match. The number of paths explored grows exponentially with the length of the input.
The Vulnerable Lockfile Entry
Before the fix, pnpm-lock.yaml resolved picomatch to the vulnerable version:
# BEFORE (vulnerable)
picomatch@2.3.1:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'}
And the vulnerable version 4.0.2 was also present:
# BEFORE (also vulnerable)
picomatch@4.0.2:
resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==}
engines: {node: '>=12'}
Attack Scenario
Imagine a web application that accepts user-supplied glob patterns for filtering files or assets — for example, a CI/CD dashboard, a file browser, or a build configuration API. The application passes user input through picomatch to evaluate matches:
const picomatch = require('picomatch'); // v2.3.1 — vulnerable
app.post('/filter-files', (req, res) => {
const userPattern = req.body.pattern; // attacker-controlled
const isMatch = picomatch(userPattern); // triggers regex compilation
const results = files.filter(f => isMatch(f));
res.json(results);
});
An attacker sends a POST request with a crafted extglob pattern:
POST /filter-files
{ "pattern": "+(a+(b+(c+(d+(e+(f+(g))))))))" }
When picomatch compiles this nested extglob into a regex and attempts to match it against even a short non-matching string, the JavaScript event loop blocks completely. Since Node.js is single-threaded, this freezes the entire server — no other requests are processed until the regex evaluation completes (which could take minutes or longer).
Even without direct user input, a compromised or malicious package in the supply chain could inject such a pattern into any code path that calls picomatch.
The Fix
The fix involved two coordinated changes across package.json and pnpm-lock.yaml.
1. Pinning picomatch in package.json
The most important change was explicitly adding picomatch as a direct dependency in package.json, overriding whatever version transitive dependencies would have resolved:
# package.json — BEFORE
"dependencies": {
"shx": "^0.4.0"
}
# package.json — AFTER
"dependencies": {
+ "picomatch": "2.3.2",
"shx": "^0.4.0"
}
By pinning "picomatch": "2.3.2" (an exact version, no ^ or ~), the project guarantees that the patched version is always used regardless of what upstream packages request. This is a critical technique for overriding vulnerable transitive dependencies in the npm/pnpm ecosystem.
2. Updating pnpm-lock.yaml
The lockfile was updated to reflect the new resolution, replacing the vulnerable 2.3.1 entry with 2.3.2 and removing the vulnerable 4.0.2 entry:
# pnpm-lock.yaml — BEFORE
- picomatch@2.3.1:
- resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
- engines: {node: '>=8.6'}
-
- picomatch@4.0.2:
- resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==}
- engines: {node: '>=12'}
# pnpm-lock.yaml — AFTER
+ picomatch@2.3.2:
+ resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
+ engines: {node: '>=8.6'}
picomatch@4.0.3:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
Note that picomatch@4.0.3 (a patched version in the 4.x line) is retained in the lockfile for packages that specifically require the v4 API, while the v2 line is now resolved to the safe 2.3.2.
What Changed Inside picomatch 2.3.2?
The picomatch maintainers restructured the regex generation for extglob patterns to avoid producing nested quantifier structures that are susceptible to backtracking. The fix ensures that the compiled regex for patterns like +(a+(b)) uses atomic grouping or possessive quantifiers where available, or restructures the alternation logic to eliminate ambiguity that the backtracking engine would otherwise explore exhaustively.
Prevention & Best Practices
1. Audit Your Lockfile for Transitive Vulnerabilities
The vulnerability was detected in pnpm-lock.yaml, not in a direct dependency. Run dependency scanners regularly against your lockfile — not just package.json:
# Using Trivy (detected this CVE)
trivy fs --scanners vuln .
# Using npm audit (also catches many CVEs)
pnpm audit
# Using Snyk
snyk test
2. Pin Vulnerable Transitive Dependencies
When a transitive dependency has a known CVE and the direct dependent hasn't released a fix yet, pin the safe version explicitly:
// package.json
"dependencies": {
"picomatch": "2.3.2" // override transitive resolution
}
In pnpm, you can also use overrides in package.json for a cleaner approach:
"pnpm": {
"overrides": {
"picomatch": ">=2.3.2"
}
}
3. Validate and Limit Glob Pattern Input
If your application accepts user-supplied glob or extglob patterns, apply these controls:
// Limit pattern length to prevent long backtracking inputs
const MAX_PATTERN_LENGTH = 200;
if (userPattern.length > MAX_PATTERN_LENGTH) {
throw new Error('Pattern too long');
}
// Optionally, use a timeout wrapper with worker threads for untrusted patterns
4. Consider Linear-Time Alternatives for Untrusted Input
For applications that must evaluate regex or glob patterns from untrusted sources, consider using RE2 — a linear-time regex engine that eliminates catastrophic backtracking by design:
const RE2 = require('re2');
// RE2 guarantees O(n) matching time regardless of pattern complexity
5. Enable Automated Dependency Scanning in CI
Add a scanning step to your CI pipeline so vulnerable versions are caught before they reach production:
# .github/workflows/security.yml
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
severity: 'HIGH,CRITICAL'
exit-code: '1'
Security Standards Reference
- CWE-1333: Inefficient Regular Expression Complexity — directly applicable to this vulnerability
- OWASP Input Validation Cheat Sheet: Recommends validating and sanitizing all input, including pattern strings
- OWASP Dependency Check: Recommends scanning all transitive dependencies, not just direct ones
Key Takeaways
- Extglob patterns in picomatch 2.3.1 and 4.0.2 produce regexes with nested quantifiers — the specific structure
(?:...(?:...)+)+that causes catastrophic backtracking with crafted non-matching strings. - Transitive dependencies are attack surface too — this CVE was hiding in
pnpm-lock.yamlas a resolved transitive dep, invisible to a simplepackage.jsonreview. - Pinning
"picomatch": "2.3.2"as a direct dependency is the correct pnpm/npm pattern for forcing a safe version across the entire dependency tree when upstream packages haven't updated yet. - Removing
picomatch@4.0.2from the lockfile was equally important — having two vulnerable versions resolved simultaneously doubled the attack surface. - ReDoS is a real denial-of-service vector in Node.js because the single-threaded event loop means one blocked regex evaluation freezes the entire process for all concurrent users.
How Orbis AppSec Detected This
- Source: The vulnerable picomatch version (
2.3.1) entered the project as a transitive dependency resolved inpnpm-lock.yaml, where it was available to any code path that invoked glob pattern matching with extglob syntax. - Sink: picomatch's internal
makeRe()/ extglob compilation function, which constructs and caches a JavaScriptRegExpobject from the user-supplied pattern string — the point at which a crafted extglob triggers catastrophic backtracking in the V8 regex engine. - Missing control: No version constraint in
package.jsonforced a safe minimum version of picomatch, allowing the package manager to resolve the vulnerable2.3.1without any warning in the source-controlled dependency manifest. - CWE: CWE-1333 — Inefficient Regular Expression Complexity
- Fix: Added
"picomatch": "2.3.2"as an explicit direct dependency inpackage.jsonand updatedpnpm-lock.yamlto resolve all picomatch references to patched versions (2.3.2 and 4.0.3), removing both2.3.1and4.0.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-33671 is a textbook example of how a single subtle flaw in regex generation — nested quantifiers inside extglob pattern compilation — can become a denial-of-service weapon in production Node.js applications. What makes it particularly insidious is that picomatch is a deeply embedded transitive dependency: it powers glob matching in Webpack, Rollup, Vite, Jest, ESLint, and dozens of other foundational tools. A vulnerable version silently lurking in your lockfile is all an attacker needs.
The fix is straightforward — upgrade to picomatch 2.3.2, 3.0.2, or 4.0.4 depending on your version line, and pin the version explicitly so your package manager can't silently downgrade it. But the broader lesson is about defense in depth for dependencies: scan your lockfiles, not just your manifests; use overrides to force safe versions; and integrate automated vulnerability scanning into every CI run.
Regex-based denial of service is often dismissed as theoretical, but in a Node.js event loop, one catastrophic backtrack is all it takes to take down a service for every user simultaneously.