VYPR
High severity8.1NVD Advisory· Published Apr 13, 2026· Updated May 13, 2026

CVE-2026-28291

CVE-2026-28291

Description

simple-git enables running native Git commands from JavaScript. Versions up to and including 3.31.1 allow execution of arbitrary commands through Git option manipulation, bypassing safety checks meant to block dangerous options like -u and --upload-pack. The flaw stems from an incomplete fix for CVE-2022-25860, as Git's flexible option parsing allows numerous character combinations (e.g., -vu, -4u, -nu) to circumvent the regular-expression-based blocklist in the unsafe operations plugin. Due to the virtually infinite number of valid option variants that Git accepts, a complete blocklist-based mitigation may be infeasible without fully emulating Git's option parsing behavior. This issue has been fixed in version 3.32.0.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
simple-gitnpm
< 3.32.03.32.0

Affected products

1

Patches

1
1effd8e5012a

Unsafe plugin extended to more ways of the `-u` switch being sent to `git.clone`

https://github.com/steveukx/git-jsSteve KingFeb 21, 2026via ghsa
3 files changed · +98 15
  • .changeset/cool-experts-walk.md+8 0 added
    @@ -0,0 +1,8 @@
    +---
    +"simple-git": minor
    +---
    +
    +Enhances the `unsafe` plugin to block additional cases where the `-u` switch may be disguised
    +along with other single character options.
    +
    +Thanks to @JuHwiSang for identifying this as vulnerability.
    
  • simple-git/src/lib/plugins/block-unsafe-operations-plugin.ts+7 1 modified
    @@ -3,10 +3,16 @@ import type { SimpleGitPlugin } from './simple-git-plugin';
     import { GitPluginError } from '../errors/git-plugin-error';
     import type { SimpleGitPluginConfig } from '../types';
     
    +const CLONE_OPTIONS = /^\0*(-|--|--no-)[\0\dlsqvnobucj]+/;
    +
     function isConfigSwitch(arg: string | unknown) {
        return typeof arg === 'string' && arg.trim().toLowerCase() === '-c';
     }
     
    +function isCloneSwitch(char: string, arg: string | unknown) {
    +   return Boolean(typeof arg === 'string' && CLONE_OPTIONS.test(arg) && arg.includes(char));
    +}
    +
     function preventProtocolOverride(arg: string, next: string) {
        if (!isConfigSwitch(arg)) {
           return;
    @@ -32,7 +38,7 @@ function preventUploadPack(arg: string, method: string) {
           );
        }
     
    -   if (method === 'clone' && /^\s*-u\b/.test(arg)) {
    +   if (method === 'clone' && isCloneSwitch('u', arg)) {
           throw new GitPluginError(
              undefined,
              'unsafe',
    
  • simple-git/test/integration/plugin.unsafe.spec.ts+83 14 modified
    @@ -1,18 +1,87 @@
    -import { promiseError, promiseResult } from '@kwsites/promise-result';
    -import {
    -   assertGitError,
    -   createTestContext,
    -   newSimpleGit,
    -   SimpleGitTestContext,
    -} from '@simple-git/test-utils';
    +import { join } from 'node:path';
    +import { exists } from '@kwsites/file-exists';
    +import { promiseError, PromiseResult, promiseResult } from '@kwsites/promise-result';
    +import { assertGitError, createTestContext, newSimpleGit, SimpleGitTestContext } from '@simple-git/test-utils';
     
     import { GitPluginError } from '../..';
     
    -describe('add', () => {
    +describe('plugin.unsafe', () => {
        let context: SimpleGitTestContext;
     
    +   process.env.DEBUG = 'simple-git,simple-git:*';
    +
        beforeEach(async () => (context = await createTestContext()));
     
    +   describe('CVE-2022-25860: command execution using clone -u', () => {
    +      function pwnedPath() {
    +         return join(context.root, 'pwn', 'touched');
    +      }
    +
    +      function isPwned() {
    +         return exists(pwnedPath());
    +      }
    +
    +      function expectError(result: PromiseResult<unknown, Error>) {
    +         if (result.success) {
    +            expect(String(result.value).trim().startsWith('usage:')).toBe(true);
    +         } else {
    +            expect(result.error).toBeDefined();
    +         }
    +
    +         expect(isPwned()).toBe(false);
    +      }
    +
    +      beforeEach(async () => {
    +         await context.dir('pwn');
    +
    +         const first = newSimpleGit(await context.dir('first'));
    +         await first.init();
    +      });
    +
    +      it('allows local cloning', async () => {
    +         const result = await promiseResult(
    +            newSimpleGit({ baseDir: context.root })
    +               .clone('./first', './second'),
    +         );
    +
    +         expect(result.success).toBe(true);
    +      });
    +
    +      it('command injection report', async () => {
    +         for (const i of [45, 54, 52, 45, 118, 115, 113, 110, 108]) {
    +            expectError(
    +               await promiseResult(
    +                  newSimpleGit({ baseDir: context.root })
    +                     .clone('./first', './a', [String.fromCharCode(i) + '-u', `sh -c \"touch ${pwnedPath()}\"`]),
    +               ),
    +            );
    +
    +            expectError(
    +               await promiseResult(
    +                  newSimpleGit({ baseDir: context.root })
    +                     .clone('./first', './b', ['-' + String.fromCharCode(i) + 'u', `sh -c \"touch ${pwnedPath()}\"`]),
    +               ),
    +            );
    +
    +            expectError(
    +               await promiseResult(
    +                  newSimpleGit({ baseDir: context.root })
    +                     .clone('./first', './c', ['-u' + String.fromCharCode(i), `sh -c \"touch ${pwnedPath()}\"`]),
    +               ),
    +            );
    +         }
    +      });
    +
    +      it('allows clone command injection: `-u...` pattern', async () => {
    +         await promiseResult(
    +            newSimpleGit({ baseDir: context.root, unsafe: { allowUnsafePack: true } })
    +               .clone('./first', './c', ['-u', `sh -c \"touch ${pwnedPath()}\"`]),
    +         );
    +
    +         expect(isPwned()).toBe(true);
    +      });
    +   });
    +
        it('ignores non string arguments', async () => {
           const { threw } = await promiseResult(newSimpleGit(context.root).raw([['init']] as any));
     
    @@ -24,8 +93,8 @@ describe('add', () => {
              newSimpleGit(context.root, { unsafe: { allowUnsafeProtocolOverride: true } }).raw(
                 '-c',
                 'protocol.ext.allow=always',
    -            'init'
    -         )
    +            'init',
    +         ),
           );
     
           expect(threw).toBe(false);
    @@ -35,15 +104,15 @@ describe('add', () => {
           assertGitError(
              await promiseError(context.git.raw('-c', 'protocol.ext.allow=always', 'init')),
              'Configuring protocol.allow is not permitted',
    -         GitPluginError
    +         GitPluginError,
           );
        });
     
        it('prevents overriding protocol.ext.allow after the method of a command', async () => {
           assertGitError(
              await promiseError(context.git.raw('init', '-c', 'protocol.ext.allow=always')),
              'Configuring protocol.allow is not permitted',
    -         GitPluginError
    +         GitPluginError,
           );
        });
     
    @@ -53,10 +122,10 @@ describe('add', () => {
                 context.git.clone(`ext::sh -c touch% /tmp/pwn% >&2`, '/tmp/example-new-repo', [
                    '-c',
                    'protocol.ext.allow=always',
    -            ])
    +            ]),
              ),
              'Configuring protocol.allow is not permitted',
    -         GitPluginError
    +         GitPluginError,
           );
        });
     });
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

7

News mentions

0

No linked articles in our index yet.