CVE-2026-33671
Description
Picomatch is a glob matcher written JavaScript. Versions prior to 4.0.4, 3.0.2, and 2.3.2 are vulnerable to Regular Expression Denial of Service (ReDoS) when processing crafted extglob patterns. Certain patterns using extglob quantifiers such as +() and *(), especially when combined with overlapping alternatives or nested extglobs, are compiled into regular expressions that can exhibit catastrophic backtracking on non-matching input. Applications are impacted when they allow untrusted users to supply glob patterns that are passed to picomatch for compilation or matching. In those cases, an attacker can cause excessive CPU consumption and block the Node.js event loop, resulting in a denial of service. Applications that only use trusted, developer-controlled glob patterns are much less likely to be exposed in a security-relevant way. This issue is fixed in picomatch 4.0.4, 3.0.2 and 2.3.2. Users should upgrade to one of these versions or later, depending on their supported release line. If upgrading is not immediately possible, avoid passing untrusted glob patterns to picomatch. Possible mitigations include disabling extglob support for untrusted patterns by using noextglob: true, rejecting or sanitizing patterns containing nested extglobs or extglob quantifiers such as +() and *(), enforcing strict allowlists for accepted pattern syntax, running matching in an isolated worker or separate process with time and resource limits, and applying application-level request throttling and input validation for any endpoint that accepts glob patterns.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
picomatchnpm | >= 4.0.0, < 4.0.4 | 4.0.4 |
picomatchnpm | >= 3.0.0, < 3.0.2 | 3.0.2 |
picomatchnpm | < 2.3.2 | 2.3.2 |
Affected products
1Patches
15eceecd27543Merge commit from fork
5 files changed · +453 −0
lib/constants.js+3 −0 modified@@ -3,6 +3,8 @@ const WIN_SLASH = '\\\\/'; const WIN_NO_SLASH = `[^${WIN_SLASH}]`; +const DEFAULT_MAX_EXTGLOB_RECURSION = 0; + /** * Posix glob regex */ @@ -86,6 +88,7 @@ const POSIX_REGEX_SOURCE = { }; module.exports = { + DEFAULT_MAX_EXTGLOB_RECURSION, MAX_LENGTH: 1024 * 64, POSIX_REGEX_SOURCE,
lib/parse.js+301 −0 modified@@ -45,6 +45,277 @@ const syntaxError = (type, char) => { return `Missing ${type}: "${char}" - use "\\\\${char}" to match literal characters`; }; +const splitTopLevel = input => { + const parts = []; + let bracket = 0; + let paren = 0; + let quote = 0; + let value = ''; + let escaped = false; + + for (const ch of input) { + if (escaped === true) { + value += ch; + escaped = false; + continue; + } + + if (ch === '\\') { + value += ch; + escaped = true; + continue; + } + + if (ch === '"') { + quote = quote === 1 ? 0 : 1; + value += ch; + continue; + } + + if (quote === 0) { + if (ch === '[') { + bracket++; + } else if (ch === ']' && bracket > 0) { + bracket--; + } else if (bracket === 0) { + if (ch === '(') { + paren++; + } else if (ch === ')' && paren > 0) { + paren--; + } else if (ch === '|' && paren === 0) { + parts.push(value); + value = ''; + continue; + } + } + } + + value += ch; + } + + parts.push(value); + return parts; +}; + +const isPlainBranch = branch => { + let escaped = false; + + for (const ch of branch) { + if (escaped === true) { + escaped = false; + continue; + } + + if (ch === '\\') { + escaped = true; + continue; + } + + if (/[?*+@!()[\]{}]/.test(ch)) { + return false; + } + } + + return true; +}; + +const normalizeSimpleBranch = branch => { + let value = branch.trim(); + let changed = true; + + while (changed === true) { + changed = false; + + if (/^@\([^\\()[\]{}|]+\)$/.test(value)) { + value = value.slice(2, -1); + changed = true; + } + } + + if (!isPlainBranch(value)) { + return; + } + + return value.replace(/\\(.)/g, '$1'); +}; + +const hasRepeatedCharPrefixOverlap = branches => { + const values = branches.map(normalizeSimpleBranch).filter(Boolean); + + for (let i = 0; i < values.length; i++) { + for (let j = i + 1; j < values.length; j++) { + const a = values[i]; + const b = values[j]; + const char = a[0]; + + if (!char || a !== char.repeat(a.length) || b !== char.repeat(b.length)) { + continue; + } + + if (a === b || a.startsWith(b) || b.startsWith(a)) { + return true; + } + } + } + + return false; +}; + +const parseRepeatedExtglob = (pattern, requireEnd = true) => { + if ((pattern[0] !== '+' && pattern[0] !== '*') || pattern[1] !== '(') { + return; + } + + let bracket = 0; + let paren = 0; + let quote = 0; + let escaped = false; + + for (let i = 1; i < pattern.length; i++) { + const ch = pattern[i]; + + if (escaped === true) { + escaped = false; + continue; + } + + if (ch === '\\') { + escaped = true; + continue; + } + + if (ch === '"') { + quote = quote === 1 ? 0 : 1; + continue; + } + + if (quote === 1) { + continue; + } + + if (ch === '[') { + bracket++; + continue; + } + + if (ch === ']' && bracket > 0) { + bracket--; + continue; + } + + if (bracket > 0) { + continue; + } + + if (ch === '(') { + paren++; + continue; + } + + if (ch === ')') { + paren--; + + if (paren === 0) { + if (requireEnd === true && i !== pattern.length - 1) { + return; + } + + return { + type: pattern[0], + body: pattern.slice(2, i), + end: i + }; + } + } + } +}; + +const getStarExtglobSequenceOutput = pattern => { + let index = 0; + const chars = []; + + while (index < pattern.length) { + const match = parseRepeatedExtglob(pattern.slice(index), false); + + if (!match || match.type !== '*') { + return; + } + + const branches = splitTopLevel(match.body).map(branch => branch.trim()); + if (branches.length !== 1) { + return; + } + + const branch = normalizeSimpleBranch(branches[0]); + if (!branch || branch.length !== 1) { + return; + } + + chars.push(branch); + index += match.end + 1; + } + + if (chars.length < 1) { + return; + } + + const source = chars.length === 1 + ? utils.escapeRegex(chars[0]) + : `[${chars.map(ch => utils.escapeRegex(ch)).join('')}]`; + + return `${source}*`; +}; + +const repeatedExtglobRecursion = pattern => { + let depth = 0; + let value = pattern.trim(); + let match = parseRepeatedExtglob(value); + + while (match) { + depth++; + value = match.body.trim(); + match = parseRepeatedExtglob(value); + } + + return depth; +}; + +const analyzeRepeatedExtglob = (body, options) => { + if (options.maxExtglobRecursion === false) { + return { risky: false }; + } + + const max = + typeof options.maxExtglobRecursion === 'number' + ? options.maxExtglobRecursion + : constants.DEFAULT_MAX_EXTGLOB_RECURSION; + + const branches = splitTopLevel(body).map(branch => branch.trim()); + + if (branches.length > 1) { + if ( + branches.some(branch => branch === '') || + branches.some(branch => /^[*?]+$/.test(branch)) || + hasRepeatedCharPrefixOverlap(branches) + ) { + return { risky: true }; + } + } + + for (const branch of branches) { + const safeOutput = getStarExtglobSequenceOutput(branch); + if (safeOutput) { + return { risky: true, safeOutput }; + } + + if (repeatedExtglobRecursion(branch) > max) { + return { risky: true }; + } + } + + return { risky: false }; +}; + /** * Parse the given input string. * @param {String} input @@ -225,6 +496,8 @@ const parse = (input, options) => { token.prev = prev; token.parens = state.parens; token.output = state.output; + token.startIndex = state.index; + token.tokensIndex = tokens.length; const output = (opts.capture ? '(' : '') + token.open; increment('parens'); @@ -234,6 +507,34 @@ const parse = (input, options) => { }; const extglobClose = token => { + const literal = input.slice(token.startIndex, state.index + 1); + const body = input.slice(token.startIndex + 2, state.index); + const analysis = analyzeRepeatedExtglob(body, opts); + + if ((token.type === 'plus' || token.type === 'star') && analysis.risky) { + const safeOutput = analysis.safeOutput + ? (token.output ? '' : ONE_CHAR) + (opts.capture ? `(${analysis.safeOutput})` : analysis.safeOutput) + : undefined; + const open = tokens[token.tokensIndex]; + + open.type = 'text'; + open.value = literal; + open.output = safeOutput || utils.escapeRegex(literal); + + for (let i = token.tokensIndex + 1; i < tokens.length; i++) { + tokens[i].value = ''; + tokens[i].output = ''; + delete tokens[i].suffix; + } + + state.output = token.output + open.output; + state.backtrack = true; + + push({ type: 'paren', extglob: true, value, output: '' }); + decrement('parens'); + return; + } + let output = token.close + (opts.capture ? ')' : ''); let rest;
README.md+8 −0 modified@@ -345,6 +345,7 @@ The following options may be used with the main `picomatch()` function or any of | `literalBrackets` | `boolean` | `undefined` | When `true`, brackets in the glob pattern will be escaped so that only literal brackets will be matched. | | `matchBase` | `boolean` | `false` | Alias for `basename` | | `maxLength` | `number` | `65536` | Limit the max length of the input string. An error is thrown if the input string is longer than this value. | +| `maxExtglobRecursion` | `number\|boolean` | `0` | Limit nested quantified extglobs and other risky repeated extglob forms. When the limit is exceeded, the extglob is treated as a literal string instead of being compiled to regex. Set to `false` to disable this safeguard. | | `nobrace` | `boolean` | `false` | Disable brace matching, so that `{a,b}` and `{1..3}` would be treated as literal characters. | | `nobracket` | `boolean` | `undefined` | Disable matching with regex brackets. | | `nocase` | `boolean` | `false` | Make matching case-insensitive. Equivalent to the regex `i` flag. Note that this option is overridden by the `flags` option. | @@ -554,6 +555,13 @@ console.log(pm.isMatch('foo.bar', '!(foo).!(bar)')); // false // supports nested extglobs console.log(pm.isMatch('foo.bar', '!(!(foo)).!(!(bar))')); // true + +// risky quantified extglobs are treated literally by default +console.log(pm.makeRe('+(a|aa)')); +//=> /^(?:\+\(a\|aa\))$/ + +// increase the limit to allow a small amount of nested quantified extglobs +console.log(pm.isMatch('aaa', '+(+(a))', { maxExtglobRecursion: 1 })); // true ``` #### POSIX brackets
test/options.maxExtglobRecursion.js+133 −0 added@@ -0,0 +1,133 @@ +'use strict'; + +const assert = require('assert'); +const { isMatch, makeRe } = require('..'); + +describe('options.maxExtglobRecursion', () => { + it('should literalize risky repeated extglobs by default', () => { + assert.strictEqual( + makeRe('+(a|aa)').source, + '^(?:\\+\\(a\\|aa\\))$' + ); + assert.strictEqual( + makeRe('+(*|?)').source, + '^(?:\\+\\(\\*\\|\\?\\))$' + ); + assert.strictEqual( + makeRe('+(+(a))').source, + '^(?:\\+\\(\\+\\(a\\)\\))$' + ); + assert.strictEqual( + makeRe('*(+(a))').source, + '^(?:\\*\\(\\+\\(a\\)\\))$' + ); + + assert(!isMatch('a'.repeat(20) + 'b', '+(a|aa)')); + assert(!isMatch('a'.repeat(12) + '!', '+(+(a))')); + }); + + it('should preserve non-risky extglobs by default', () => { + assert(isMatch('abcabc', '+(abc)')); + assert(isMatch('foobar', '*(foo|bar)')); + assert(isMatch('a', '(a|@(b|c)|d)')); + assert(isMatch('fffooo', '*(*(f)*(o))')); + assert(isMatch('abc', '+(*)c')); + }); + + it('should allow limited nested repeated extglobs when configured', () => { + assert.strictEqual( + makeRe('+(+(a))', { maxExtglobRecursion: 1 }).source, + '^(?:(?=.)(?:(?:a)+)+)$' + ); + assert.strictEqual( + makeRe('*(+(a))', { maxExtglobRecursion: 1 }).source, + '^(?:(?=.)(?:(?:a)+)*)$' + ); + + assert(isMatch('aaa', '+(+(a))', { maxExtglobRecursion: 1 })); + assert(isMatch('aaa', '*(+(a))', { maxExtglobRecursion: 1 })); + }); + + it('should still block ambiguous repeated alternation when recursion is allowed', () => { + assert.strictEqual( + makeRe('+(a|aa)', { maxExtglobRecursion: 1 }).source, + '^(?:\\+\\(a\\|aa\\))$' + ); + assert.strictEqual( + makeRe('+(*|?)', { maxExtglobRecursion: 1 }).source, + '^(?:\\+\\(\\*\\|\\?\\))$' + ); + }); + + it('should rewrite risky repeated extglobs embedded in larger patterns', () => { + assert.strictEqual( + makeRe('foo/+(a|aa)/bar').source, + '^(?:foo\\/\\+\\(a\\|aa\\)\\/bar)$' + ); + assert.strictEqual( + makeRe('x+(a|aa)y').source, + '^(?:x\\+\\(a\\|aa\\)y)$' + ); + + assert(isMatch('foo/+(a|aa)/bar', 'foo/+(a|aa)/bar')); + assert(!isMatch('foo/aa/bar', 'foo/+(a|aa)/bar')); + assert(isMatch('x+(a|aa)y', 'x+(a|aa)y')); + assert(!isMatch('xaay', 'x+(a|aa)y')); + }); + + it('should rewrite star-only repeated extglobs embedded in larger patterns', () => { + assert.strictEqual( + makeRe('pre*(*(f)*(o))post').source, + '^(?:pre[fo]*post)$' + ); + + assert(isMatch('prefoopost', 'pre*(*(f)*(o))post')); + }); + + it('should rewrite star-only repeated extglobs', () => { + assert.strictEqual( + makeRe('*(*(f))').source, + '^(?:(?=.)f*)$' + ); + + assert(isMatch('fff', '*(*(f))')); + }); + + it('should preserve capture behavior for rewritten repeated extglobs', () => { + const embedded = makeRe('foo/+(a|aa)/bar', { capture: true }); + assert.strictEqual(embedded.source, '^(?:foo\\/\\+\\(a\\|aa\\)\\/bar)$'); + assert.deepStrictEqual( + Array.from(embedded.exec('foo/+(a|aa)/bar')), + ['foo/+(a|aa)/bar'] + ); + + const simplified = makeRe('*(*(f)*(o))', { capture: true }); + assert.strictEqual(simplified.source, '^(?:(?=.)([fo]*))$'); + assert.deepStrictEqual( + Array.from(simplified.exec('fffooo')), + ['fffooo', 'fffooo'] + ); + }); + + it('should only rewrite the risky repeated extglob when adjacent extglobs are present', () => { + assert.strictEqual( + makeRe('+(a|aa)@(x)').source, + '^(?:\\+\\(a\\|aa\\)(x))$' + ); + + assert(isMatch('+(a|aa)x', '+(a|aa)@(x)')); + assert(!isMatch('aaax', '+(a|aa)@(x)')); + }); + it('should disable the safeguard when maxExtglobRecursion is false', () => { + assert( + /\(\?:a\|aa\)\+/.test( + makeRe('+(a|aa)', { maxExtglobRecursion: false }).source + ) + ); + assert( + /\(\?:\(\?:a\)\+\)\+/.test( + makeRe('+(+(a))', { maxExtglobRecursion: false }).source + ) + ); + }); +});
.verb.md+8 −0 modified@@ -106,6 +106,7 @@ The following options may be used with the main `picomatch()` function or any of | `literalBrackets` | `boolean` | `undefined` | When `true`, brackets in the glob pattern will be escaped so that only literal brackets will be matched. | | `matchBase` | `boolean` | `false` | Alias for `basename` | | `maxLength` | `number` | `65536` | Limit the max length of the input string. An error is thrown if the input string is longer than this value. | +| `maxExtglobRecursion` | `number\|boolean` | `0` | Limit nested quantified extglobs and other risky repeated extglob forms. When the limit is exceeded, the extglob is treated as a literal string instead of being compiled to regex. Set to `false` to disable this safeguard. | | `nobrace` | `boolean` | `false` | Disable brace matching, so that `{a,b}` and `{1..3}` would be treated as literal characters. | | `nobracket` | `boolean` | `undefined` | Disable matching with regex brackets. | | `nocase` | `boolean` | `false` | Make matching case-insensitive. Equivalent to the regex `i` flag. Note that this option is overridden by the `flags` option. | @@ -318,6 +319,13 @@ console.log(pm.isMatch('foo.bar', '!(foo).!(bar)')); // false // supports nested extglobs console.log(pm.isMatch('foo.bar', '!(!(foo)).!(!(bar))')); // true + +// risky quantified extglobs are treated literally by default +console.log(pm.makeRe('+(a|aa)')); +//=> /^(?:\+\(a\|aa\))$/ + +// increase the limit to allow a small amount of nested quantified extglobs +console.log(pm.isMatch('aaa', '+(+(a))', { maxExtglobRecursion: 1 })); // true ``` #### POSIX brackets
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/micromatch/picomatch/commit/5eceecd27543b8e056b9307d69e105ea03618a7dnvdPatchWEB
- github.com/micromatch/picomatch/security/advisories/GHSA-c2c7-rcm5-vvqjnvdPatchVendor AdvisoryWEB
- github.com/advisories/GHSA-c2c7-rcm5-vvqjghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-33671ghsaADVISORY
News mentions
0No linked articles in our index yet.