|
| 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