| Field | Value |
|---|---|
| CVE ID | CVE-2026-6951 |
| Affected package |
simple-git (npm) |
| Affected versions | < 3.36.0 |
| Patched version | 3.36.0 |
| CWE | CWE-94 (Improper Control of Generation of Code), CWE-88 (Argument Injection) |
| CVSS 3.1 | 9.8 (Critical) — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
|
| Disclosed | 2026-04-25 |
1. Overview
simple-git is the library that lets Node.js applications drive git programmatically — 8.7M weekly downloads and 7,789 dependent packages, making it a core piece of the JavaScript build/deploy toolchain.
CVE-2026-6951 is an incomplete fix for a vulnerability patched years earlier, CVE-2022-25912 (RCE via option injection). The 2022 patch only blocked the short-form -c flag, while git treats the long-form --config flag as a complete synonym — and the patch never blocked it. As a result, an attacker could reuse the exact same attack that had been "fixed" four years prior, just by changing how the flag was written.
2. The Root Cause: Understanding CVE-2022-25912 First
To understand this vulnerability properly, you first need to know what was patched in 2022.
Git has a special remote-repository protocol called ext::. It's meant to let git reach a remote repository through an external helper program — but the catch is that the "helper program" slot can hold arbitrary shell commands.
# ext:: protocol example — everything after the colon is executed as-is
git clone "ext::sh -c touch% /tmp/pwned% >&2" /tmp/dest
This is disabled by default and only works once the protocol.ext.allow=always config value is turned on — a value that can be injected at the command line with -c or --config.
git clone -c protocol.ext.allow=always "ext::sh -c '...'" /tmp/dest
So the attack needs two conditions at once:
- The ability to inject
protocol.ext.allow=always - The ability to inject a malicious clone URL starting with
ext::
simple-git passes the options (or customArgs) array that callers provide to clone(), fetch(), etc. almost verbatim to the git binary. If an application mixes untrusted input (user input, external API responses) into that array, an attacker can satisfy both conditions above.
The 2022 patch addressed condition 1 by detecting and blocking arguments like -c protocol...allow=... with a blocklist regex.
3. The 2022 Patch's Structure — and Its Limit
The patch lives in simple-git/src/lib/plugins/block-unsafe-operations-plugin.ts, with preventProtocolOverride as the core function. Simplified:
// block-unsafe-operations-plugin.ts (2022 patch, conceptually reconstructed)
function preventProtocolOverride(args: string[]) {
for (let i = 0; i < args.length; i++) {
// ① look for the exact token "-c"
if (args[i] === '-c') {
const next = args[i + 1]; // e.g. "protocol.ext.allow=always"
// ② check whether the next argument matches protocol.*.allow
if (/^\s*protocol(\.[a-z]+)?\.allow/.test(next)) {
throw new GitPluginError(
'Configuring protocol.allow is not permitted without enabling allowUnsafeExtProtocol'
);
}
}
}
}
Walking through it line by line:
-
①
args[i] === '-c': the function walks the argument array looking only for the exact string-c(the short-form flag). It's a plain string comparison, so any other spelling of "pass a config value" is invisible to it. -
② the regex check: if the value right after
-cmatchesprotocol.allow,protocol.ext.allow, etc., it throws and aborts execution.
This logic rests on a false assumption: that -c is the only way to pass a config value to git. In reality, git's CLI offers two interchangeable spellings for the same feature:
| Flag | Meaning | Detected? |
|---|---|---|
-c key=value |
short form | ✅ caught |
--config key=value |
long form, functionally identical to -c
|
❌ not caught |
Per git's own documentation, -c and --config are true synonyms and go through the exact same parsing path inside the git binary. But because simple-git's blocklist did an exact-string match against '-c', using --config instead simply never triggers the check at all.
This is a textbook case of the structural weakness of blocklist-based argument validation. A complete denylist would have to enumerate every spelling that can mean the same thing — and CLI tools routinely offer several spellings for one feature (short/long forms, case variants, =-joined forms, and so on), which makes this approach fragile by design. The sibling vulnerability CVE-2026-28292 (bypassing the same regex with uppercase PROTOCOL.ALLOW=always, since the original pattern was case-sensitive) comes from the exact same root cause in the same plugin.
4. Attack Flow
The key moment is step 2. The attacker isn't using some sophisticated technique — the entire vulnerability boils down to changing how a flag is spelled being enough to defeat a security fix that shipped four years earlier.
5. A Working PoC
Publicly available proof-of-concept code looks roughly like this (reconstructed to show the core logic, without external references):
const simpleGit = require('simple-git');
const myGit = simpleGit();
myGit.clone(
'ext::sh -c touch% /tmp/pwned% >&2', // ① malicious clone URL
'/tmp/example-new-repo', // ② clone destination directory
['--config', 'protocol.ext.allow=always'] // ③ the bypass option
);
Breaking it down:
-
① The first argument to
clone()is normally where a real repository URL goes. Passingext::sh -c touch% /tmp/pwned% >&2tells git: "clone via theextprotocol, and runsh -c touch /tmp/pwnedas the helper command." (%is how git encodes spaces inside this kind of URL.) - ② The second argument is just the normal clone destination path — irrelevant to the attack itself.
-
③ The third argument is the crux.
--configandprotocol.ext.allow=alwaysare passed as two separate array elements. This has the exact same effect as-c protocol.ext.allow=alwaysfrom git's perspective — butsimple-git's blocklist only looks for the literal string'-c', so the'--config'token sails right past it.
Under the hood, simple-git ends up executing exactly this:
git clone --config protocol.ext.allow=always "ext::sh -c touch% /tmp/pwned% >&2" /tmp/example-new-repo
Once this command runs, the ext:: helper process executes before git reports a clone failure (since the "repository" doesn't actually exist), creating /tmp/pwned. In other words, even if the final clone fails, the command execution has already succeeded — an application that catches the clone failure gracefully has no indication that it was already compromised.
Mapped onto a realistic application scenario, the danger becomes clearer:
// The application's intended, safe-looking call
simpleGit().clone(
'https://github.com/my-org/my-repo',
'/tmp/workdir',
userSuppliedOptions // an options array under user control
);
// But if an attacker can inject these two values into userSuppliedOptions...
userSuppliedOptions = ['--config', 'protocol.ext.allow=always'];
// ...and also control the clone URL itself, it's a direct path to RCE
6. Patch Analysis (simple-git 3.36.0)
The fix isn't just "add --config to the blocklist" — it's a meaningfully larger architectural change, touching 29 files and 1,000+ lines under the commit name "Environment Parsing."
6-1. Expanded config-key blocklist
The old detect-config-writes.ts blocked only six keys: protocol.allow, core.sshCommand, core.fsmonitor, core.gitProxy, core.hooksPath, diff.external. The new detect-vulnerable-config-writes.ts introduces a preventExpandedConfigBuilder helper with an expanded regex that also catches scoped variants like credential.https://example.com.helper, and adds blocking for:
-
alias.*— registering arbitrary commands as git aliases -
core.askPass,core.editor,core.pager— binary substitution -
credential.helper— credential interception -
diff.textconv,filter.clean,filter.smudge— content-filter chains that can intercept file contents - the
gpg.programfamily — signing-binary substitution -
merge.driver,mergetool.cmd— code execution at merge time -
sequence.editor— binary substitution during rebase
6-2. Expanded flag detection
The old detect-upload-pack.ts, which only caught --upload-pack/--receive-pack-style flags, is replaced by detect-vulnerable-flags.ts, which now also blocks --template (hook-planting via a custom template directory).
6-3. Environment variable scanning — an entirely new line of defense
This is the most important change in the patch. Git accepts the same kind of config injection not just via -c/--config on the command line, but also via environment variables like GIT_CONFIG_COUNT, GIT_CONFIG_KEY_n, and GIT_CONFIG_VALUE_n. That means filtering command-line arguments alone could never be a complete fix. The new parseEnv() function maps dangerous environment variables (GIT_ASKPASS, GIT_SSH_COMMAND, GIT_CONFIG_COUNT, GIT_EXTERNAL_DIFF, GIT_PROXY_COMMAND, and more), and when GIT_CONFIG_COUNT is set, extracts the corresponding GIT_CONFIG_KEY_n/VALUE_n pairs and runs them through the same blocklist check.
// Before the patch: only arguments are checked
action(args) {
const parsed = parseArgv(...args);
for (const v of parsed.vulnerabilities.vulnerabilities) { /* throw */ }
}
// After the patch: arguments AND environment variables are checked
action(args, { env }) {
for (const v of vulnerabilityCheck(args, env)) { /* throw */ }
}
In plain terms: the gatekeeper used to only look at "what came in on the command line." Now it also checks "what's been smuggled in through environment variables to achieve the same config injection." Even if an attacker's command-line arguments slip past validation, the same attack attempted through environment variables now gets caught by vulnerabilityCheck().
6-4. A more granular opt-in model
The patch introduces per-category opt-in flags — allowUnsafeAlias, allowUnsafeCredentialHelper, allowUnsafeTemplateDir, and more — so developers who genuinely need one of these features can explicitly enable it for trusted input only. VulnerabilityCategoryFlags, which previously had 7 entries, now has more than 20.
7. Why This Keeps Happening — The Structural Cause
This is the third related vulnerability in simple-git:
-
CVE-2022-25912: the original RCE via the
-cflag -
CVE-2026-28292: bypassing the case-sensitive regex with uppercase
PROTOCOL.ALLOW=always -
CVE-2026-6951: bypassing the blocklist entirely via the
--configsynonym
All three share the same root cause. A blocklist that matches one exact dangerous pattern will always be bypassable the moment a different spelling with the same meaning exists — short vs. long form, case variants, environment-variable paths, and so on. The fact that the 3.36.0 patch widens the defense to cover environment variables as well as command-line arguments is effectively an admission that single-point blocklists have a hard ceiling, and a move toward defense in depth instead.
8. Remediation
| Action | Details |
|---|---|
| Upgrade |
npm install simple-git@latest (3.36.0 or later) |
| Watch for partial upgrades | Mismatched sub-package versions (e.g. @simple-git/argv-parser) can cause TypeErrors in CI — regenerating yarn.lock/package-lock.json entirely is recommended |
| Validate input | Never pass untrusted input directly into options/customArgs — map it through an allowlist of accepted values instead |
| Least privilege | Run the process that invokes simple-git under a sandboxed or restricted-privilege account |


Top comments (0)