Skip to content

Commit dfeb116

Browse files
authored
Fix/exec path vulnerability (#1197)
* Add vulnerability checks for the use of `--exec-path` * Add vulnerability checks for the use of `--git-dir`
1 parent 98864c6 commit dfeb116

10 files changed

Lines changed: 289 additions & 19 deletions

File tree

‎.changeset/lucky-guards-block.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@simple-git/argv-parser": patch
3+
---
4+
5+
Vulnerability detection expanded to cover configuration delivered through path-taking global options, where
6+
the dangerous value is a file on disk rather than a token `simple-git` can inspect:
7+
8+
- `--exec-path` names the directory `git` loads built-in commands and remote helpers from, and is blocked
9+
under the new `allowUnsafeExec` category along with the `GIT_EXEC_PATH` environment variable (previously
10+
grouped under `allowUnsafeConfigPaths`)
11+
- `--git-dir`, `--work-tree` and `-C` cause `git` to read the configuration of the repository they name, and
12+
are blocked under `allowUnsafeConfigPaths`
13+
14+
These options are only detected when supplied before the git sub-command and with a value - used as getters
15+
(`git.raw('rev-parse', '--git-dir')`) or as task options (`git.raw('commit', '-C', 'HEAD~1')`) they are
16+
unaffected.

‎.nvmrc‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
v24.10.0
1+
v26.4.0

‎docs/PLUGIN-UNSAFE-ACTIONS.md‎

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -426,12 +426,17 @@ await simpleGit({ unsafe: { allowUnsafeMergeDriver: true } })
426426
.raw('-c', 'mergetool.vimdiff.path=/usr/bin/vim', 'mergetool');
427427
```
428428

429-
### Configuration paths via environment variables
429+
### Configuration paths
430430

431-
The `GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM`, `GIT_CONFIG`, `GIT_EXEC_PATH`, and `PREFIX` environment
432-
variables override the paths `git` uses to locate its configuration files and built-in commands. Controlling
433-
these paths allows an attacker to supply an entirely malicious git configuration or replace git's built-in
434-
commands with arbitrary binaries.
431+
The `GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM`, `GIT_CONFIG` and `PREFIX` environment variables override the
432+
paths `git` uses to locate its configuration files, as do the `--git-dir`, `--work-tree` and `-C` global
433+
options. Controlling any of these paths allows an attacker to supply an entirely malicious git configuration
434+
- including settings that enable further command-execution vectors - by naming a directory rather than by
435+
supplying a value `simple-git` can inspect. The configuration file itself need not be executable.
436+
437+
Used without a value, `--git-dir` and `--work-tree` report the paths `git` resolved rather than setting them,
438+
so `git.checkIsRepo(CheckRepoActions.IS_REPO_ROOT)` and the equivalent `git.raw('rev-parse', '--git-dir')`
439+
are unaffected.
435440

436441
```typescript
437442
import { simpleGit } from 'simple-git';
@@ -441,12 +446,38 @@ await simpleGit()
441446
.env('GIT_CONFIG_GLOBAL', '/attacker/controlled/gitconfig')
442447
.clone('https://example.com/repo');
443448

449+
// throws - git reads the configuration of whichever repository the path names
450+
await simpleGit()
451+
.raw('--git-dir=/attacker/controlled/repo', 'ls-remote', 'https://example.com/repo.git');
452+
453+
// throws - `git` discovers, and reads the configuration of, a repository in that directory
454+
await simpleGit().raw('-C', '/attacker/controlled/checkout', 'ls-remote', 'https://example.com/repo.git');
455+
444456
// opt in to overriding git configuration paths
445457
await simpleGit({ unsafe: { allowUnsafeConfigPaths: true } })
446458
.env('GIT_CONFIG_GLOBAL', '/custom/global/gitconfig')
447459
.clone('https://example.com/repo');
448460
```
449461

462+
### Git executable path
463+
464+
The `--exec-path` option and `GIT_EXEC_PATH` environment variable name the directory `git` loads its
465+
built-in commands and remote helpers from. An attacker-named directory containing, for example, a
466+
`git-remote-https` executable has that file run in place of git's own helper - reached by any operation
467+
using an `https://` url, including `git.clone`.
468+
469+
```typescript
470+
import { simpleGit } from 'simple-git';
471+
472+
// throws
473+
await simpleGit()
474+
.raw('--exec-path=/attacker/controlled/bin', 'ls-remote', 'https://example.com/repo.git');
475+
476+
// opt in to overriding the git executable path
477+
await simpleGit({ unsafe: { allowUnsafeExec: true } })
478+
.raw('--exec-path=/opt/custom/libexec/git-core', 'ls-remote', 'https://example.com/repo.git');
479+
```
480+
450481
### Environment-based configuration
451482

452483
Git supports injecting configuration values at runtime through a set of numbered environment variables:

‎packages/argv-parser/src/env/parse-env.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const GitEnvKeys = {
1111
'git_config_parameters': 'allowUnsafeConfigEnvCount',
1212
'git_config': 'allowUnsafeConfigPaths',
1313
'git_editor': 'allowUnsafeEditor',
14-
'git_exec_path': 'allowUnsafeConfigPaths',
14+
'git_exec_path': 'allowUnsafeExec',
1515
'git_external_diff': 'allowUnsafeDiffExternal',
1616
'git_pager': 'allowUnsafePager',
1717
'git_proxy_command': 'allowUnsafeGitProxy',

‎packages/argv-parser/src/vulnerabilities/detect-vulnerable-flags.ts‎

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,25 +7,51 @@ export function* detectVulnerableFlags(
77
): Generator<Vulnerability> {
88
for (const flag of flags) {
99
for (const helper of preventUnsafeFlags) {
10-
const vulnerability = helper(task, flag.name);
10+
const vulnerability = helper(task, flag);
1111
if (vulnerability) {
1212
yield vulnerability;
1313
}
1414
}
1515
}
1616
}
1717

18+
interface PreventFlagOptions {
19+
/** Label to use in the error message in place of the matcher itself */
20+
name?: string;
21+
22+
/** Only match when the switch appears before the git sub-command */
23+
globalOnly?: boolean;
24+
25+
/**
26+
* Only match when the switch was supplied with a value - without one switches
27+
* such as `--git-dir` and `--exec-path` are getters rather than setters.
28+
*/
29+
withValue?: boolean;
30+
}
31+
1832
function preventFlagBuilder(
1933
task: string | null,
2034
flag: string | RegExp,
2135
category: VulnerabilityCategory,
22-
name = String(flag)
36+
{ name = String(flag), globalOnly = false, withValue = false }: PreventFlagOptions = {}
2337
) {
2438
const regex = typeof flag === 'string' ? new RegExp(`\\s*${flag.toLowerCase()}`) : flag;
2539
const message = `Use of ${task ? `${task} with option ` : ''}${name} is not permitted without enabling ${category}`;
2640

27-
return function preventFlag(currentTask: string | null, flagName: string): Vulnerability | void {
28-
if ((!task || currentTask === task) && regex.test(flagName)) {
41+
return function preventFlag(currentTask: string | null, flag: Flag): Vulnerability | void {
42+
if (task && currentTask !== task) {
43+
return;
44+
}
45+
46+
if (globalOnly && !flag.isGlobal) {
47+
return;
48+
}
49+
50+
if (withValue && flag.value === undefined) {
51+
return;
52+
}
53+
54+
if (regex.test(flag.name)) {
2955
return {
3056
category,
3157
message,
@@ -34,15 +60,20 @@ function preventFlagBuilder(
3460
};
3561
}
3662

63+
const pathTakingGlobal: PreventFlagOptions = { globalOnly: true, withValue: true };
64+
3765
const preventUnsafeFlags = [
38-
preventFlagBuilder(
39-
null,
40-
/--(upload|receive)-pack/,
41-
'allowUnsafePack',
42-
'--upload-pack or --receive-pack'
43-
),
66+
preventFlagBuilder(null, /--(upload|receive)-pack/, 'allowUnsafePack', {
67+
name: '--upload-pack or --receive-pack',
68+
}),
4469
preventFlagBuilder('clone', /^-\w*u/, 'allowUnsafePack'),
4570
preventFlagBuilder('clone', '--u', 'allowUnsafePack'),
46-
preventFlagBuilder('push', '--exec', 'allowUnsafePack'),
71+
preventFlagBuilder('push', /^--exec$/, 'allowUnsafePack', { name: '--exec' }),
4772
preventFlagBuilder(null, '--template', 'allowUnsafeTemplateDir'),
73+
preventFlagBuilder(null, '--exec-path', 'allowUnsafeExec', pathTakingGlobal),
74+
// `git` reads the configuration of whichever repository these name, so the
75+
// directory alone is enough to deliver config the argv guards never see
76+
preventFlagBuilder(null, '--git-dir', 'allowUnsafeConfigPaths', pathTakingGlobal),
77+
preventFlagBuilder(null, '--work-tree', 'allowUnsafeConfigPaths', pathTakingGlobal),
78+
preventFlagBuilder(null, /^-C$/, 'allowUnsafeConfigPaths', { ...pathTakingGlobal, name: '-C' }),
4879
];

‎packages/argv-parser/src/vulnerabilities/vulnerability.types.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,12 @@ export interface VulnerabilityCategoryFlags {
8787
*/
8888
allowUnsafeGitProxy: boolean;
8989

90+
/**
91+
* Use of the `--exec-path` and similar options can override the binaries executed during
92+
* `git` operations.
93+
*/
94+
allowUnsafeExec: boolean;
95+
9096
/**
9197
* Using a `-c` switch to enable custom hooks path commands to be run automatically
9298
* exposes and attack vector for running arbitrary commands.

‎packages/argv-parser/test/parse-env.spec.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ describe('parseEnv', () => {
3232
['GIT_CONFIG', '/tmp/malicious', 'allowUnsafeConfigPaths'],
3333
['GIT_EDITOR', '/tmp/malicious', 'allowUnsafeEditor'],
3434
['GIT_SEQUENCE_EDITOR', '/tmp/malicious', 'allowUnsafeEditor'],
35-
['GIT_EXEC_PATH', '/tmp/malicious', 'allowUnsafeConfigPaths'],
35+
['GIT_EXEC_PATH', '/tmp/malicious', 'allowUnsafeExec'],
3636
['GIT_EXTERNAL_DIFF', '/tmp/malicious', 'allowUnsafeDiffExternal'],
3737
['GIT_PAGER', '/tmp/malicious', 'allowUnsafePager'],
3838
['GIT_PROXY_COMMAND', '/tmp/malicious', 'allowUnsafeGitProxy'],

‎packages/argv-parser/test/vulnerability-analysis.spec.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,4 +195,44 @@ describe('VulnerabilityAnalysis', () => {
195195
);
196196
});
197197
});
198+
describe('path-taking global flags', () => {
199+
it.each<[string, VulnerabilityCategory]>([
200+
['--exec-path', 'allowUnsafeExec'],
201+
['--git-dir', 'allowUnsafeConfigPaths'],
202+
['--work-tree', 'allowUnsafeConfigPaths'],
203+
])('detects %s supplied with a value', (flag, category) => {
204+
const expected = oneVulnerability(category);
205+
206+
expect(parseArgv(`${flag}=/tmp/evil`, 'ls-remote', 'url').vulnerabilities).toEqual(
207+
expected
208+
);
209+
expect(parseArgv(flag, '/tmp/evil', 'ls-remote', 'url').vulnerabilities).toEqual(expected);
210+
});
211+
212+
it('detects -C supplied with a value', () => {
213+
expect(parseArgv('-C', '/tmp/evil', 'ls-remote', 'url').vulnerabilities).toEqual(
214+
oneVulnerability('allowUnsafeConfigPaths')
215+
);
216+
});
217+
218+
it.each([['--exec-path'], ['--git-dir'], ['--work-tree']])(
219+
'allows %s as a getter, where no value is consumed',
220+
(flag) => {
221+
expect(parseArgv('rev-parse', flag).vulnerabilities).toEqual(noVulnerabilities());
222+
}
223+
);
224+
225+
it.each([
226+
['apply', '-C', '3'],
227+
['commit', '-C', 'HEAD~1'],
228+
])('allows %s %s as a task option', (task, flag, value) => {
229+
expect(parseArgv(task, flag, value).vulnerabilities).toEqual(noVulnerabilities());
230+
});
231+
232+
it('does not confuse -C with the inline config -c', () => {
233+
expect(parseArgv('-c', 'user.name=bob', 'status').vulnerabilities).toEqual(
234+
noVulnerabilities()
235+
);
236+
});
237+
});
198238
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { chmod } from 'node:fs/promises';
2+
import { describe, expect, it } from 'vitest';
3+
import { exists, FILE } from '@kwsites/file-exists';
4+
import { promiseError } from '@kwsites/promise-result';
5+
import { assertGitError, createTestContext, newSimpleGit } from '@simple-git/test-utils';
6+
7+
describe('--exec-path', () => {
8+
it('blocks: global flag without being allowed', async () => {
9+
const context = await createTestContext();
10+
const root = await context.dir('poc-workdir');
11+
const pwnd = context.path('new-exec-path-pwned');
12+
13+
await newSimpleGit(root).init();
14+
15+
// `git` loads the remote helper for a url's scheme out of its exec-path, so an
16+
// attacker-named directory containing `git-remote-https` is arbitrary execution
17+
const execPath = await context.dir('evil-exec-path');
18+
const helper = await context.file(
19+
['evil-exec-path', 'git-remote-https'],
20+
`#!/bin/sh\ntouch ${pwnd}\nexit 1\n`
21+
);
22+
await chmod(helper, 0o755);
23+
24+
const err = await promiseError(
25+
newSimpleGit(root).raw(
26+
`--exec-path=${execPath}`,
27+
'ls-remote',
28+
'https://example.com/repo.git'
29+
)
30+
);
31+
32+
expect(exists(pwnd, FILE)).toBe(false);
33+
34+
assertGitError(err, 'allowUnsafeExec');
35+
});
36+
});
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { chmod, writeFile } from 'node:fs/promises';
2+
import { describe, expect, it } from 'vitest';
3+
import { exists, FILE } from '@kwsites/file-exists';
4+
import { promiseError } from '@kwsites/promise-result';
5+
import {
6+
assertGitError,
7+
createTestContext,
8+
newSimpleGit,
9+
type SimpleGitTestContext,
10+
} from '@simple-git/test-utils';
11+
12+
/**
13+
* Plants a repo skeleton at `dir` whose config runs `payload` whenever git opens an
14+
* ssh connection, rewriting `https://` urls to ssh so that a url supplied by the
15+
* application itself is enough to reach it. Note the config is plain text with no
16+
* execute bit - only the directory name needs to be attacker controlled.
17+
*/
18+
async function plantRepo(context: SimpleGitTestContext, dir: string[], payload: string) {
19+
const gitDir = await context.dir(...dir);
20+
21+
await writeFile(
22+
context.path(...dir, 'config'),
23+
`[core]\n\tsshCommand = ${payload}\n[url "ssh://x.invalid/"]\n\tinsteadOf = https://\n`
24+
);
25+
await writeFile(context.path(...dir, 'HEAD'), 'ref: refs/heads/main\n');
26+
await context.dir(...dir, 'objects');
27+
await context.dir(...dir, 'refs');
28+
29+
return gitDir;
30+
}
31+
32+
async function plantPayload(context: SimpleGitTestContext, pwnd: string) {
33+
const payload = await context.file('payload.sh', `#!/bin/sh\ntouch ${pwnd}\nexit 1\n`);
34+
await chmod(payload, 0o755);
35+
36+
return payload;
37+
}
38+
39+
describe('config through a path-taking global flag', () => {
40+
it('allows: --git-dir when used as a getter', async () => {
41+
const context = await createTestContext();
42+
const root = await context.dir('poc-workdir');
43+
const pwnd = context.path('new-git-dir-pwned');
44+
45+
await newSimpleGit(root).init();
46+
await plantRepo(context, ['evil-git-dir'], await plantPayload(context, pwnd));
47+
48+
const err = await promiseError(newSimpleGit(root).raw('rev-parse', `--git-dir`));
49+
50+
expect(exists(pwnd, FILE)).toBe(false);
51+
52+
expect(err).toBe(undefined);
53+
});
54+
55+
it('blocks: --git-dir at an attacker-named directory', async () => {
56+
const context = await createTestContext();
57+
const root = await context.dir('poc-workdir');
58+
const pwnd = context.path('new-git-dir-pwned');
59+
60+
await newSimpleGit(root).init();
61+
const gitDir = await plantRepo(context, ['evil-git-dir'], await plantPayload(context, pwnd));
62+
63+
const err = await promiseError(
64+
newSimpleGit(root).raw(`--git-dir=${gitDir}`, 'ls-remote', 'https://example.com/repo.git')
65+
);
66+
67+
expect(exists(pwnd, FILE)).toBe(false);
68+
69+
assertGitError(err, 'allowUnsafeConfigPaths');
70+
});
71+
72+
it('blocks: --git-dir supplied as a separate token', async () => {
73+
const context = await createTestContext();
74+
const root = await context.dir('poc-workdir');
75+
const pwnd = context.path('new-git-dir-pwned');
76+
77+
await newSimpleGit(root).init();
78+
const gitDir = await plantRepo(context, ['evil-git-dir'], await plantPayload(context, pwnd));
79+
80+
const err = await promiseError(
81+
newSimpleGit(root).raw('--git-dir', gitDir, 'ls-remote', 'https://example.com/repo.git')
82+
);
83+
84+
expect(exists(pwnd, FILE)).toBe(false);
85+
86+
assertGitError(err, 'allowUnsafeConfigPaths');
87+
});
88+
89+
it('blocks: -C into a directory holding a planted repo', async () => {
90+
const context = await createTestContext();
91+
const root = await context.dir('poc-workdir');
92+
const pwnd = context.path('new-change-dir-pwned');
93+
94+
await newSimpleGit(root).init();
95+
await plantRepo(context, ['evil-work-dir', '.git'], await plantPayload(context, pwnd));
96+
97+
const err = await promiseError(
98+
newSimpleGit(root).raw(
99+
'-C',
100+
context.path('evil-work-dir'),
101+
'ls-remote',
102+
'https://example.com/repo.git'
103+
)
104+
);
105+
106+
expect(exists(pwnd, FILE)).toBe(false);
107+
108+
assertGitError(err, 'allowUnsafeConfigPaths');
109+
});
110+
});

0 commit comments

Comments
 (0)