GHSA-c859-jr93-m5j4: 
JavaScript Analyse et atténuation de la vulnérabilité

Summary

minimatch.escape() is documented to "Escape all magic characters in a glob pattern, so that it will only ever match literal strings." (README, section minimatch.escape(pattern, options = {})). In minimatch 11.0.5 (latest release; main is at the v11.0.5 tag, commit ea0a4a6) four classes of input break that contract:

  1. A leading ! is left unescaped. escape('!secret.txt') returns '!secret.txt', which is a negated pattern. It matches every path except secret.txt, for example passwd. This holds in POSIX mode and in windowsPathsNoEscape mode.
  2. A leading # is left unescaped. escape('#notes.md') returns '#notes.md', which is a comment pattern. It matches nothing, including #notes.md. This holds in both modes.
  3. An escaped backslash does not survive brace expansion. For the user text x\* (x, backslash, star), escape() returns x\\\*. Alone, that pattern matches only the literal. When the pattern also contains a brace group, brace-expansion turns \\ back into a single \, so the * becomes a live wildcard. The brace group can come from the caller (escape(u) + '.{js,ts}') or, with default options, from the user text itself (escape('x\\*{,}')).
  4. With { windowsPathsNoEscape: true, magicalBraces: true }, escape('x{a,b}') returns x[{]a,b[}]. Brace-expansion does not know [...] classes and expands it to x[]a] and x[b[]. The pattern matches xa and does not match x{a,b}. The README documents negation and comments for patterns in general (README lines 484-493). The escape() section does not mention them, and its contract ("only ever match literal strings") is what callers rely on. The README warns that building a matcher from untrusted user patterns is unsafe. escape() is the documented way to make text match only as a literal (README.md:213-214), which is how callers embed untrusted text. These cases are inside that boundary. Behaviors 1 and 3 widen a match (fail open for an allowlist). Behavior 2 empties a match (fail open for a denylist or ignore list). Behavior 4 does both.

Details

All line numbers refer to tag v11.0.5. brace-expansion line numbers refer to brace-expansion 5.0.12, the version installed under ^5.0.12 at test time, file dist/commonjs/index.js. Behavior 1: leading !.

  • src/escape.ts:22-24: the comment states that ! needs no escaping because the parens that make extglobs magic are escaped. That is true for !(...). It ignores pattern-level negation.
  • src/escape.ts:25-32: none of the four branches escapes !. They escape ?*()[], plus \ in POSIX mode and {} with magicalBraces. A leading ! passes through.
  • src/index.ts:1360-1374 (parseNegate, called from make() at src/index.ts:730): every leading ! toggles this.negate and is stripped. match() then inverts the result.
  • \! is already a supported way to write a literal !. test/patterns.js:260 has the entry ['!\\!a*', ['a!b', 'd', 'e', '\\!a']], which excludes !ab and !abc. The POSIX part of the fix uses that existing syntax, and unescape() already reverses it.
  • test/escape-has-magic.js checks the unescape() round trip and hasMagic() === false for every entry in test/patterns.js. That list includes '!a*' (line 251), '!\\!a*' (line 260) and '# ignore this' (line 327). The test never checks negate or comment. A negated literal pattern reports hasMagic() === false, so the suite passes. Measured on 11.0.5 with the port described under Suggested fix: 811 of 811 upstream assertions pass, and 6 of 784 added negate/comment assertions fail ('!a*', '!\\!a*' and '# ignore this', each in POSIX and Windows mode). Behavior 2: leading #.
  • src/escape.ts:25-32: no branch handles #.
  • src/index.ts:118-120 (minimatch() shortcut) and src/index.ts:719-722 (make()): a pattern whose first character is # is a comment. minimatch() returns false and Minimatch builds an empty set. Behavior 3: escaped backslash collapsed by brace expansion.
  • src/escape.ts:28 and src/escape.ts:32: a literal \ is escaped as \\ and a literal * as \*. The user text x\* becomes x\\\*.
  • src/index.ts:733 calls #expandBraces(), which reaches braceExpand() at src/index.ts:243-257. When the pattern contains any {...} group, src/index.ts:256 passes the whole pattern to brace-expansion expand().
  • brace-expansion index.js:16 (slashPattern = /\\\\/g) and index.js:54-61 (escapeBraces) replace each \\ pair with a sentinel. index.js:62-69 (unescapeBraces, applied to every result at index.js:125) turns that sentinel into one \ (line 64). x\\\*.{js,ts} therefore expands to x\\*.js and x\\*.ts. In minimatch syntax that is an escaped backslash followed by a live *.
  • With default options, escape() leaves { and } as they are (see the related note below). The user text can then supply the brace group, and the caller appends nothing: escape('x\\*{,}') returns x\\\*{,}, which expands to x\\* twice and matches x\anything. Commit 0d27f1c, which added magicalBraces to escape(), states that it resolves #234. Neither that commit nor its tests cover braces that turn an escaped * into a wildcard. Its tests in test/escape-has-magic.js compare escape() and unescape() strings only, and none of their inputs contains *. With magicalBraces: true in POSIX mode the user braces are escaped, so a brace group from the caller is required.
  • The same collapse drops a literal backslash in front of an escaped brace: escape('\\{', { magicalBraces: true }) + '.{js,ts}' gives \\\{.{js,ts}, which does not match \{.js on 11.0.5.
  • The control case without a brace group is correct. The defect is only in the brace-expansion step.
  • A large numeric range such as {1..5000} (100 or more values, src/index.ts:263) is matched without brace-expansion in 11.0.5, so that suffix alone does not trigger behavior 3. Behavior 4: windowsPathsNoEscape + magicalBraces.
  • src/escape.ts:26-27: in this mode every magic character, including { and }, is wrapped in a one-character class: x{a,b} becomes x[{]a,b[}]. The README and the JSDoc at src/escape.ts:6-10 justify this by saying a character class "can only be satisfied by that exact character".
  • src/index.ts:599-601: in this mode backslashes are converted to / before parsing, so brace-expansion's \{ escape is not available to escape().
  • src/index.ts:251-256: the brace check regex and brace-expansion both see {]a,b[} as a brace set. brace-expansion splits it at the comma into x[]a] and x[b[]. hasMagic() returns true and the literal does not match itself. Related note (not a numbered behavior): default options leave braces live. With default options, and with windowsPathsNoEscape alone, escape() does not escape { or }. On 11.0.5, escape('x{a,b}') returns x{a,b}, which matches xa and does not match x{a,b}. The same holds with { windowsPathsNoEscape: true }. The JSDoc at src/escape.ts:12-13 says braces are escaped only with magicalBraces. The README sentence has no such qualifier. Commit 0d27f1c ("Support magicalBraces option in escape/unescape") states that it resolves #234. It made brace escaping opt-in and kept the default. The text of issue #234 itself was not read from this environment. This report treats the gap as a documentation issue, and the fix below does not change it. Either the README should state the magicalBraces requirement, or the default should escape braces. The bisection shows the gap in every release from 7.4.0 to 11.0.5.

Suggested fix

Two parts. Behaviors 1 and 2 are fixed in src/escape.ts. Behaviors 3 and 4 cannot be fixed in escape.ts alone: every encoding of a literal backslash (\\, [\\]) and every class encoding of a brace ([{]) is rewritten by brace-expansion. The fix for those belongs in braceExpand() in src/index.ts, which is the single path to brace-expansion (#expandBraces() and all its fallbacks call it). src/escape.ts:

export const escape = (
  s: string,
  {
    windowsPathsNoEscape = false,
    magicalBraces = false,
  }: Pick<MinimatchOptions, 'windowsPathsNoEscape' | 'magicalBraces'> = {},
) => {
  // Inside the pattern, +@! need no escape because the parens that make
  // them magic are escaped. At the start of the pattern, '!' negates and
  // '#' makes a comment, so the first character is handled below.
  const e =
    magicalBraces ?
      windowsPathsNoEscape ?
        s.replace(/[?*()[\]{}]/g, '[$&]')
      : s.replace(/[?*()[\]\\{}]/g, '\\$&')
    : windowsPathsNoEscape ? s.replace(/[?*()[\]]/g, '[$&]')
    : s.replace(/[?*()[\]\\]/g, '\\$&')
  const c = e.charAt(0)
  if (c === '#') {
    return (windowsPathsNoEscape ? '[#]' : '\\#') + e.slice(1)
  }
  if (c === '!') {
    // '[!]' does not work: '[!' opens a negated class. See options (a)
    // and (b) below for windowsPathsNoEscape mode.
    return (windowsPathsNoEscape ? '@(!)' : '\\!') + e.slice(1)
  }
  return e
}

src/unescape.ts, at the top of unescape() (option (a) only):

  if (windowsPathsNoEscape && s.startsWith('@(!)')) {
    return '!' + unescape(s.slice(4), { windowsPathsNoEscape, magicalBraces })
  }

src/index.ts, braceExpand(), replacing the final return expand(...):

  // brace-expansion turns each '\\' into '\'. Double every pair, so an
  // escaped backslash comes back as '\\' and cannot make the next escape
  // a live magic character.
  let p = pattern.replace(/\\\\/g, '\\\\\\\\')
  // brace-expansion does not know [...] classes. In windowsPathsNoEscape
  // mode escape() writes a literal brace as '[{]' or '[}]'. Escape the
  // brace inside the class; brace-expansion removes that backslash again.
  if (options.windowsPathsNoEscape || options.allowWindowsEscape === false) {
    p = p.replace(/\[([{}])\]/g, '[\\$1]')
  }
  return expand(p, { max: options.braceExpandMax })

Why this is safe:

  • brace-expansion 5.0.12 matches \\ pairs left to right before it looks at \{, \}, \, and \. (index.js:54-61). The doubling regex also works left to right. Each run of backslashes keeps its parity, so a trailing odd backslash still escapes the next character exactly as before. The only change is that \\ now comes back as \\.
  • The [\{] and [\}] rewrite runs only in windowsPathsNoEscape mode. On the Minimatch path, src/index.ts:599-601 has already turned every backslash into /, so the inserted backslash is the only one in the string. brace-expansion turns \{ back into { (index.js:62-69), so the result contains [{] again.
  • There is no fallback path. The earlier draft of this fix picked two sentinel characters from U+E000..U+F8FF and fell back to the unprotected expand() when the input contained all of them. User text could force that fallback. Measured with that earlier braceExpand() change applied to 11.0.5: checks 3, 3b, 4 and 4b pass, and P3 and P4 still fail (3 failed assertions; the PoC prints 8 assertion(s) failed because that build does not include the escape.ts change, so checks 1, 1w, 2 and 2w also fail). This version picks no sentinel, so no input can exhaust one, and every pattern goes through the protected path. A fix that does need sentinels must return [pattern] (no expansion) when none is free.
  • The doubling depends on brace-expansion's current escape handling. minimatch 11.0.5 requires ^5.0.12. PoC checks 3 and 3b should become tests so a change in brace-expansion is caught. Windows mode and a leading !: there is no clean encoding. [! opens a negated class, and backslash is a path separator in this mode. Measured on 11.0.5 in Windows mode with a trailing a: [!]a, [\!]a, [[=!=]]a, [[.!.]]a and ./!a do not match !a, and [!-!]a matches ba and not !a. Only @(!)a matches !a and nothing else among !a, ba, a, !, b, and it needs extglob. The two options, with measured trade-offs (port of test/escape-has-magic.js, 811 upstream assertions): | Option | escape('!a', win) | PoC | Upstream assertions | Trade-off | |---|---|---|---|---| | (a) @(!) plus the unescape() change (shown above) | @(!)a | all assertions passed | 804 pass, 7 fail | hasMagic() returns true for the 7 win32 entries that start with ! ('!a*' twice, '!!a*', '!\\!a*', '!(.a\|js)@(.*)', '!()y' twice). Those expectations must change. With noext: true the pattern matches neither !a nor b (fails closed). | | (a0) @(!) without the unescape() change | @(!)a | all assertions passed | 797 pass, 14 fail | The same 7 hasMagic() failures plus 7 failed win32 unescape() round trips. | | (b) leave a leading ! unescaped in Windows mode | !a | 1 assertion failed (1w: passwd matches) | 811 pass, 0 fail | Behavior 1 remains in Windows mode. Callers must pass nonegate: true; with it, !a matches !a and not b. Document this in the README. |

[#] for a leading # passes the upstream assertions in all three variants. Option (a) is the version verified below. Behavior change for patterns that do not come from escape(): an escaped backslash next to a brace group now stays a literal backslash. On 11.0.5, {src,lib}/a\\b.js (intended: the literal file a\b.js) expands to src/a\b.js and lib/a\b.js, matches src/ab.js and does not match src/a\b.js. With the fix it matches src/a\b.js and not src/ab.js. Likewise a\\{b,c} matched ab and now matches a\b. This is a correctness bug fix outside the security scope, but it is visible to users and belongs in the changelog. The public minimatch.braceExpand() output changes for the same patterns. No pattern in test/patterns.js changes (see the differential below). Local verification. The three changes were applied to a fresh copy of the installed 11.0.5 dist/commonjs and dist/esm files, with unpatched 11.0.5 as the control. Variants a0 and b were tested the same way. The TypeScript above was not compiled. The upstream tap suite was not run.

  • PoC (poc.js above): unpatched 11.0.5 prints 14 assertion(s) failed. Patched: all assertions passed. That covers all 11 checks: 1, 1w, 2, 2w, 3, 3b (user-supplied braces), 3c, 4, 4b, P3 and P4 (the private-use-character fallback bypass).
  • Port of test/escape-has-magic.js (the tap snapshot is replaced by a comparison of [set, hasMagic()] with unpatched 11.0.5):
    BuildUpstream assertionsSnapshot substituteAdded negate/comment assertions
    11.0.5811 pass, 0 fail196 of 196 equal778 pass, 6 fail
    option (a)804 pass, 7 fail196 of 196 equal784 pass, 0 fail
    option (a0)797 pass, 14 fail196 of 196 equal784 pass, 0 fail
    option (b)811 pass, 0 fail196 of 196 equal782 pass, 2 fail
    All 7 failures for option (a) are the win32 hasMagic() === false assertions listed in the table above.
  • Differential over test/patterns.js (unpatched 11.0.5 against option (a)): 196 string patterns, matched against the 135 distinct file names in the expected-result lists of that file. braceExpand() output: 196 comparisons, 0 differences. Match results: 26,460 comparisons, 0 differences. The script also escapes every pattern in four modes (POSIX, POSIX + magicalBraces, Windows, Windows + magicalBraces) and checks that the escaped pattern matches its own literal. In Windows mode the literal is compared with backslashes converted to /, because backslash is a path separator in that mode. Unpatched: 15, 6, 15 and 15 misses. Patched: 12, 3, 12 and 3. The patch fixes 21 misses and introduces none. The 30 that remain are the related default-brace note (9 brace entries, 8 distinct patterns because *(a|{b|c,c}) appears twice in test/patterns.js, in POSIX and Windows mode without magicalBraces: 18 misses) and 3 patterns with .. segments in all four modes (12 misses), which is path normalization.
  • Random inputs: 20,000 inputs of 1 to 6 characters over !#\*?{},[]()ab.+@-, with magicalBraces: true, POSIX and Windows mode, with and without an appended .{js,ts}. Each escaped pattern is checked against its literal and against the probe paths a, ab, passwd, a.js, b.js, x and the empty string. In Windows mode the literal is compared with backslashes converted to /, because backslash is a path separator in that mode. In the .{js,ts} mode, .js is appended to the literal and to each probe path that does not already end in .js. A self-miss is a pattern that does not match its literal. An over-match is a pattern that matches at least one probe path.
    Mode11.0.5 self-misses / over-matchesoption (a) self-misses / over-matches
    POSIX163 / 20990 / 0
    POSIX + .{js,ts}4516 / 28860 / 0
    Windows171 / 210716 / 16
    Windows + .{js,ts}163 / 20990 / 0
    The 16 remaining Windows cases are two distinct strings, @@\.. and ![\.., each generated 8 times. Each case is both a self-miss and an over-match. Both strings end in a backslash followed by .., which becomes a .. path segment. That is path normalization, not escaping.
    Tests to add:
  • escape('!x'), escape('#x'), escape('x\\*') + '.{a,b}', and escape('x{a,b}', { windowsPathsNoEscape: true, magicalBraces: true }) must each match the literal and must not match x, a, or passwd.
  • escape('x\\*{,}') with default options must not match x\anything.
  • escape('!a', { windowsPathsNoEscape: true }) must match !a and not b. The noext: true behavior must be decided and asserted.
  • Escaped text that contains every character from U+E000 to U+F8FF, plus .{js,ts}, must not match a wildcard path.
  • test/escape-has-magic.js should assert negate === false and comment === false. With option (a), its win32 hasMagic() expectations for inputs that start with ! change to true.

Affected versions by behavior

GHSA form field: >= 7.4.0, <= 11.0.5. escape() first shipped in 7.4.0 (commit 327cb60). 7.3.0 has no escape export. Per behavior:

  • Behavior 1 (leading !) and behavior 2 (leading #): >= 7.4.0, <= 11.0.5, no gaps. The brace-expansion version has no effect.
  • Behavior 3 (escaped backslash plus brace group, including 3b): depends on the brace-expansion version that is installed. Vulnerable with brace-expansion 2.x (all 12 releases, 2.0.0 to 2.1.7) and with 5.0.4 to 5.0.12. Not vulnerable with 4.0.0, 4.0.1, 5.0.2, 5.0.3 and @isaacs/brace-expansion 5.0.0 and 5.0.1. Those turn x\\\*.{js,ts} into x\..js and x\..ts, so even the literal stops matching (fails closed). On a fresh install that gives >= 7.4.0 < 10.0.0 || 10.0.1 || >= 10.1.3 <= 11.0.5. 9.0.6 to 9.0.8, 10.1.3 and 10.2.0 to 10.2.4 declare ^5.0.2 and are not affected when a lockfile pins brace-expansion 5.0.2 or 5.0.3. 10.0.0 and 10.0.2 (^4.x) and 10.0.3 to 10.1.2 (@isaacs/brace-expansion ^5.0.x) are not affected.
  • Behavior 4 (windowsPathsNoEscape + magicalBraces): >= 10.1.0, <= 11.0.5, no gaps. escape() gained the magicalBraces option in 10.1.0 (commit 0d27f1c). Before that it returns x{a,b} unchanged, which is the related default-brace note, not behavior 4. Bisection: all 50 releases from 7.3.0 to 11.0.5 were installed, each in its own directory with npm i --ignore-scripts minimatch@V, and tested in one process per install. Results on a fresh install (B3 and B3b gave the same result in every release): | minimatch | brace-expansion declared -> installed | B1 | B2 | B3 / B3b | B4 | |---|---|---|---|---|---| | 7.3.0 | ^2.0.1 -> 2.1.7 | no escape() | - | - | - | | 7.4.0 - 9.0.5 | ^2.0.1 or ^2.0.2 -> 2.1.7 | yes | yes | yes | option absent | | 9.0.6 - 9.0.8 | ^5.0.2 -> 5.0.12 | yes | yes | yes | option absent | | 9.0.9 | ^2.0.2 -> 2.1.7 | yes | yes | yes | option absent | | 10.0.0 | ^4.0.0 -> 4.0.1 | yes | yes | no (fails closed) | option absent | | 10.0.1 | ^2.0.1 -> 2.1.7 | yes | yes | yes | option absent | | 10.0.2 | ^4.0.1 -> 4.0.1 | yes | yes | no (fails closed) | option absent | | 10.0.3 | @isaacs ^5.0.0 -> 5.0.1 | yes | yes | no (fails closed) | option absent | | 10.1.0 - 10.1.2 | @isaacs ^5.0.0 or ^5.0.1 -> 5.0.1 | yes | yes | no (fails closed) | yes | | 10.1.3 - 10.2.4 | ^5.0.2 -> 5.0.12 | yes | yes | yes | yes | | 10.2.5 - 10.2.6 | ^5.0.5 or ^5.0.8 -> 5.0.12 | yes | yes | yes | yes | | 11.0.0 - 11.0.5 | ^5.0.12 -> 5.0.12 | yes | yes | yes | yes | The brace-expansion boundary was confirmed with npm overrides: minimatch 10.2.0 with brace-expansion 5.0.3 is not affected and with 5.0.4 is affected; 9.0.6 with 5.0.2 is not affected; 7.4.0 with 2.0.1 is affected. Each of the 27 brace-expansion releases that minimatch 7.4.0 to 11.0.5 can resolve (2.0.0 to 2.1.7, 4.0.0, 4.0.1, 5.0.2 to 5.0.12, and @isaacs/brace-expansion 5.0.0 and 5.0.1) was also fed x\\\*.{js,ts} directly, with the results listed above. No minimatch release declares brace-expansion 3.x, so 3.0.0 to 3.0.9 were not tested.

Credits

Reported by: agntflw

Proof of concept

Setup: npm i minimatch@11.0.5, save the script as poc.js in the same directory, then run node poc.js. The script has no other dependencies.

// PoC: minimatch.escape() output that does not match only the literal input.
// Setup: npm i minimatch@11.0.5   Run: node poc.js
'use strict'
const { minimatch, escape, braceExpand } = require('minimatch')
const version = require('minimatch/package.json').version
const braceVersion = require('brace-expansion/package.json').version
console.log('minimatch ' + version + ', brace-expansion ' + braceVersion)
// Long inputs are printed as a short form.
const PUA = Array.from({ length: 0xf8ff - 0xe000 + 1 }, (_, i) =>
  String.fromCharCode(0xe000 + i)).join('')
const show = s => JSON.stringify(s.split(PUA).join('<U+E000..U+F8FF>'))
let failures = 0
function check(n, title, user, pattern, opts, cases) {
  console.log('\n[' + n + '] ' + title)
  console.log('    user input : ' + show(user))
  console.log('    options    : ' + JSON.stringify(opts))
  console.log('    pattern    : ' + show(pattern))
  for (const [path, expected] of cases) {
    const actual = minimatch(path, pattern, opts)
    const ok = actual === expected
    if (!ok) failures++
    console.log('    ' + (ok ? 'ok  ' : 'FAIL') + ' match(' + show(path) +
      ') expected=' + expected + ' actual=' + actual)
  }
}
// 1. Leading '!' is not escaped; the pattern is negated.
{
  const user = '!secret.txt'
  check(1, "leading '!' becomes negation", user, escape(user), {}, [
    ['!secret.txt', true],
    ['passwd', false],
    ['.env.production', false],
  ])
  const w = { windowsPathsNoEscape: true }
  check('1w', "leading '!' becomes negation (windowsPathsNoEscape)", user,
    escape(user, w), w, [
      ['!secret.txt', true],
      ['passwd', false],
    ])
}
// 2. Leading '#' is not escaped; the pattern is a comment.
{
  const user = '#notes.md'
  check(2, "leading '#' becomes a comment", user, escape(user), {}, [
    ['#notes.md', true],
  ])
  const w = { windowsPathsNoEscape: true }
  check('2w', "leading '#' becomes a comment (windowsPathsNoEscape)", user,
    escape(user, w), w, [
      ['#notes.md', true],
    ])
}
// 3. Escaped backslash is collapsed by brace expansion; '*' becomes live.
{
  const user = 'x\\*' // three characters: x, backslash, star
  const pattern = escape(user) + '.{js,ts}'
  check(3, 'escaped backslash collapsed by a caller-added brace group', user,
    pattern, {}, [
      ['x\\*.js', true],
      ['x\\anything.js', false],
    ])
  console.log('    braceExpand: ' + JSON.stringify(braceExpand(pattern)))
  // 3b. Default options leave braces unescaped, so the user text itself
  // can supply the brace group. The caller appends nothing.
  const user3b = 'x\\*{,}'
  const p3b = escape(user3b)
  check('3b', 'escaped backslash collapsed by a user-supplied brace group',
    user3b, p3b, {}, [
      ['x\\anything', false],
    ])
  console.log('    braceExpand: ' + JSON.stringify(braceExpand(p3b)))
  const control = escape(user) + '.js'
  check('3c', 'control: same input, no brace group', user, control, {}, [
    ['x\\*.js', true],
    ['x\\anything.js', false],
  ])
}
// 4. windowsPathsNoEscape + magicalBraces: '[{]' and '[}]' are split by
// brace expansion.
{
  const opts = { windowsPathsNoEscape: true, magicalBraces: true }
  const user = 'x{a,b}'
  const pattern = escape(user, opts)
  check(4, 'windowsPathsNoEscape + magicalBraces: class-wrapped braces expanded',
    user, pattern, opts, [
      ['x{a,b}', true],
      ['xa', false],
    ])
  console.log('    braceExpand: ' + JSON.stringify(braceExpand(pattern, opts)))
  const p4b = pattern + '.{js,ts}'
  check('4b', 'same, with a caller-added brace group', user, p4b, opts, [
    ['x{a,b}.js', true],
    ['xa.js', false],
  ])
}
// P. Private-use characters in the user text. A fix that picks sentinel
// characters from U+E000..U+F8FF must not fall back to the unprotected
// expansion when the input contains all of them.
{
  const user = 'x\\*' + PUA
  const pattern = escape(user) + '.{js,ts}'
  check('P3', 'behavior 3 with every U+E000..U+F8FF character in the input',
    user, pattern, {}, [
      ['x\\*' + PUA + '.js', true],
      ['x\\ZZZ' + PUA + '.js', false],
    ])
  const opts = { windowsPathsNoEscape: true, magicalBraces: true }
  const user4 = '{q,b}' + PUA
  check('P4', 'behavior 4 with every U+E000..U+F8FF character in the input',
    user4, escape(user4, opts), opts, [
      ['{q,b}' + PUA, true],
      ['b' + PUA, false],
    ])
}
console.log('\n' + (failures ? failures + ' assertion(s) failed: escape() output did not match only the literal input.' : 'all assertions passed'))

Output on macOS, Node.js v24.2.0, minimatch 11.0.5, brace-expansion 5.0.12:

$ node poc.js
minimatch 11.0.5, brace-expansion 5.0.12
[1] leading '!' becomes negation
    user input : "!secret.txt"
    options    : {}
    pattern    : "!secret.txt"
    ok   match("!secret.txt") expected=true actual=true
    FAIL match("passwd") expected=false actual=true
    FAIL match(".env.production") expected=false actual=true
[1w] leading '!' becomes negation (windowsPathsNoEscape)
    user input : "!secret.txt"
    options    : {"windowsPathsNoEscape":true}
    pattern    : "!secret.txt"
    ok   match("!secret.txt") expected=true actual=true
    FAIL match("passwd") expected=false actual=true
[2] leading '#' becomes a comment
    user input : "#notes.md"
    options    : {}
    pattern    : "#notes.md"
    FAIL match("#notes.md") expected=true actual=false
[2w] leading '#' becomes a comment (windowsPathsNoEscape)
    user input : "#notes.md"
    options    : {"windowsPathsNoEscape":true}
    pattern    : "#notes.md"
    FAIL match("#notes.md") expected=true actual=false
[3] escaped backslash collapsed by a caller-added brace group
    user input : "x\\*"
    options    : {}
    pattern    : "x\\\\\\*.{js,ts}"
    ok   match("x\\*.js") expected=true actual=true
    FAIL match("x\\anything.js") expected=false actual=true
    braceExpand: ["x\\\\*.js","x\\\\*.ts"]
[3b] escaped backslash collapsed by a user-supplied brace group
    user input : "x\\*{,}"
    options    : {}
    pattern    : "x\\\\\\*{,}"
    FAIL match("x\\anything") expected=false actual=true
    braceExpand: ["x\\\\*","x\\\\*"]
[3c] control: same input, no brace group
    user input : "x\\*"
    options    : {}
    pattern    : "x\\\\\\*.js"
    ok   match("x\\*.js") expected=true actual=true
    ok   match("x\\anything.js") expected=false actual=false
[4] windowsPathsNoEscape + magicalBraces: class-wrapped braces expanded
    user input : "x{a,b}"
    options    : {"windowsPathsNoEscape":true,"magicalBraces":true}
    pattern    : "x[{]a,b[}]"
    FAIL match("x{a,b}") expected=true actual=false
    FAIL match("xa") expected=false actual=true
    braceExpand: ["x[]a]","x[b[]"]
[4b] same, with a caller-added brace group
    user input : "x{a,b}"
    options    : {"windowsPathsNoEscape":true,"magicalBraces":true}
    pattern    : "x[{]a,b[}].{js,ts}"
    FAIL match("x{a,b}.js") expected=true actual=false
    FAIL match("xa.js") expected=false actual=true
[P3] behavior 3 with every U+E000..U+F8FF character in the input
    user input : "x\\*<U+E000..U+F8FF>"
    options    : {}
    pattern    : "x\\\\\\*<U+E000..U+F8FF>.{js,ts}"
    ok   match("x\\*<U+E000..U+F8FF>.js") expected=true actual=true
    FAIL match("x\\ZZZ<U+E000..U+F8FF>.js") expected=false actual=true
[P4] behavior 4 with every U+E000..U+F8FF character in the input
    user input : "{q,b}<U+E000..U+F8FF>"
    options    : {"windowsPathsNoEscape":true,"magicalBraces":true}
    pattern    : "[{]q,b[}]<U+E000..U+F8FF>"
    FAIL match("{q,b}<U+E000..U+F8FF>") expected=true actual=false
    FAIL match("b<U+E000..U+F8FF>") expected=false actual=true
14 assertion(s) failed: escape() output did not match only the literal input.

Notes on the output:

  • In checks 1 and 1w, !secret.txt matches itself only because the negated pattern excludes secret.txt, not !secret.txt. passwd shows the negation.
  • The script has 11 numbered checks (1, 1w, 2, 2w, 3, 3b, 3c, 4, 4b, P3, P4). Each check makes one or more match assertions. The final line counts failed assertions, not checks: on 11.0.5, 14 assertions fail across 10 checks.
  • Check 3c is the control. The same escaped text without a brace group behaves correctly.
  • Checks P3 and P4 put every character from U+E000 to U+F8FF (6400 characters) into the user text. They exist to test fixes that pick sentinel characters from that range. On 11.0.5 they fail the same way as checks 3 and 4.

Impact

The impact depends on how the application uses the result. The precondition for every case is an application that passes user-controlled text through escape() and uses the resulting pattern for an access, filter, or exclusion decision. Each case below follows that pattern.

  1. Leading !, allowlist. A file-sharing feature builds an allow pattern per shared file: allow.push(escape(fileName)), then grants access when minimatch(requestedPath, p) is true for any p. A tenant uploads a file named !a.txt and shares it. The share grants access to every path except a.txt, including other files in the same scope. If the list is a denylist instead, the same file name blocks every other path.
  2. Leading #, denylist or ignore list. A backup or publish step excludes user-selected files: exclude.push(escape(name)). A file named #secret# (the Emacs autosave naming scheme) or #notes.md is never excluded and is uploaded or published. No error is reported.
  3. Backslash plus brace group, allowlist. An application allows a user-selected base name with known extensions: escape(name) + '.{js,ts}'. On POSIX, \ is a legal file name character. A user who can create a file named x\* gets a pattern that matches any file in that directory whose name starts with x\ and ends in .js or .ts. With default options the application does not need to add a brace group: the name x\*{,} alone gives a pattern that matches any name that starts with x\. The over-match is limited to one path segment, to the user's chosen prefix, and to names that contain a literal backslash.
  4. windowsPathsNoEscape + magicalBraces. An application on Windows escapes names with both options so braces are literal. The name report{draft,final}.docx becomes report[{]draft,final[}].docx, which expands to report[]draft].docx and report[final[].docx. That pattern matches any name made of report, one character from ]draft or final[, and .docx (for example reportd.docx, reporta.docx, report].docx, report[.docx). It does not match report{draft,final}.docx. For the input {secret,b} the pattern matches any one-character name from ]secret or b[ (for example b, [, ], s), and does not match {secret,b} or secret. A denylist entry silently fails to block the file. An allowlist entry admits unrelated names. This is not remote code execution and not a denial of service. It is an integrity and confidentiality issue in security decisions that rely on escape(). Workarounds until a fix ships (each measured on 11.0.5):
  • Behaviors 1 and 2: pass { nonegate: true, nocomment: true } when matching escaped patterns. With nonegate, escape('!secret.txt') matches !secret.txt and not passwd. With nocomment, escape('#notes.md') matches #notes.md. This covers both modes.
  • Behavior 3: do not combine escaped text with a brace group, and use { magicalBraces: true } so user braces are escaped. Passing nobrace: true stops the over-match (x\anything.js no longer matches), but the caller's brace group then becomes literal: x\*.js no longer matches either, and only x\*.{js,ts} does. Expand the group in application code instead.
  • Behavior 4: pass nobrace: true when matching. With { windowsPathsNoEscape: true, magicalBraces: true, nobrace: true }, x[{]a,b[}] matches x{a,b} and not xa. On POSIX, { magicalBraces: true } without windowsPathsNoEscape gives x\{a,b\}, which matches only the literal. Dropping magicalBraces does not help: escape('x{a,b}', { windowsPathsNoEscape: true }) returns x{a,b}, which matches xa and not the literal.

Source: NVD

Connexe JavaScript Vulnérabilités :

CVE ID

Gravité

Partition

Technologies

Nom du composant

Exploit CISA KEV

A corrigé

Date de publication

GHSA-fxhh-mj9j-w84fHIGH7.1
  • JavaScript logoJavaScript
  • proxy-chain
NonOuiNov 11, 2026
GHSA-8hp6-7rhf-3g4fMEDIUM6.5
  • JavaScript logoJavaScript
  • proxy-chain
NonOuiNov 11, 2026
GHSA-hqmv-qgp3-jx75MEDIUM5.9
  • JavaScript logoJavaScript
  • minimatch
NonOuiNov 11, 2026
GHSA-c859-jr93-m5j4MEDIUM5.4
  • JavaScript logoJavaScript
  • minimatch
NonOuiNov 11, 2026
GHSA-jx97-gwvc-pfxxLOWN/A
  • JavaScript logoJavaScript
  • matrix-js-sdk
NonOuiNov 11, 2026

Évaluation gratuite des vulnérabilités

Évaluez votre posture de sécurité cloud

Évaluez vos pratiques de sécurité cloud dans 9 domaines de sécurité afin d’évaluer votre niveau de risque et d’identifier les lacunes dans vos défenses.

Évaluation de la demande

Ressources Wiz supplémentaires

Obtenez une démo personnalisée

Prêt(e) à voir Wiz en action ?

"La meilleure expérience utilisateur que j’ai jamais vue, offre une visibilité totale sur les workloads cloud."
David EstlickRSSI
"Wiz fournit une interface unique pour voir ce qui se passe dans nos environnements cloud."
Adam FletcherChef du service de sécurité
"Nous savons que si Wiz identifie quelque chose comme critique, c’est qu’il l’est réellement."
Greg PoniatowskiResponsable de la gestion des menaces et des vulnérabilités