Files
gbrain/test/git-remote.test.ts
T
Garry TanandClaude Fable 5 e2fc3df9fe fix(git-remote): resolve relative pull origins against the repo dir, matching git -C
isTrustedLocalOrigin realpath'd a relative origin against process.cwd(),
but `git -C repoPath pull` resolves relative file remotes against the
repo dir — the containment guard was checking a different path than the
one git actually pulls. Resolve relative origins against repoPath before
the realpath containment check. Regression test mutation-checked (fails
on the cwd-based resolution).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 11:55:24 -07:00

541 lines
21 KiB
TypeScript

import { test, expect, describe, beforeAll, afterAll, beforeEach } from 'bun:test';
import { mkdirSync, writeFileSync, rmSync, readFileSync, existsSync, chmodSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import {
GIT_SSRF_FLAGS,
GIT_SSRF_FLAGS_LOCAL,
GIT_SSRF_SUBCOMMAND_FLAGS,
parseRemoteUrl,
RemoteUrlError,
cloneRepo,
pullRepo,
GitOperationError,
validateRepoState,
} from '../src/core/git-remote.ts';
import { withEnv } from './helpers/with-env.ts';
// ---------------------------------------------------------------------------
// Fake-git harness: write a shell script that records its argv to a log file,
// then prepend its dir to PATH for the test. Lets us assert exact argv shape
// without invoking real git.
// ---------------------------------------------------------------------------
const FAKE_GIT_DIR = join(tmpdir(), `gbrain-git-remote-test-${process.pid}`);
const FAKE_GIT_LOG = join(FAKE_GIT_DIR, 'argv.log');
const FAKE_GIT_MODE = join(FAKE_GIT_DIR, 'mode');
const FAKE_GIT_ORIGIN = join(FAKE_GIT_DIR, 'origin-url');
function writeFakeGit(): void {
mkdirSync(FAKE_GIT_DIR, { recursive: true });
// Mode file controls fake-git behavior: "ok" = exit 0, "fail" = exit 1.
writeFileSync(FAKE_GIT_MODE, 'ok');
// Per-invocation argv goes into argv.log (one JSON array per line).
writeFileSync(FAKE_GIT_LOG, '');
// Origin URL emitted by fake-git for `remote get-url` in local-origin mode.
writeFileSync(FAKE_GIT_ORIGIN, '');
const script = `#!/usr/bin/env bash
# Fake git for git-remote.test.ts
{ printf '['; for arg in "$@"; do printf '%s,' "$(printf '%s' "$arg" | jq -Rs .)"; done; printf 'null]\\n'; } >> "${FAKE_GIT_LOG}"
mode=$(cat "${FAKE_GIT_MODE}" 2>/dev/null || echo ok)
case "$mode" in
fail) exit 1 ;;
url-drift) echo "https://github.com/different/url" ;;
url-match) echo "https://github.com/expected/url" ;;
local-origin)
case " $* " in
*" symbolic-ref "*) echo "main" ;;
*" get-url "*) cat "${FAKE_GIT_ORIGIN}" 2>/dev/null || true ;;
*) : ;;
esac
;;
*) ;;
esac
exit 0
`;
const path = join(FAKE_GIT_DIR, 'git');
writeFileSync(path, script);
chmodSync(path, 0o755);
}
function readArgvLog(): string[][] {
const raw = readFileSync(FAKE_GIT_LOG, 'utf8');
return raw
.split('\n')
.filter(Boolean)
.map(line => {
const arr = JSON.parse(line) as (string | null)[];
return arr.filter((x): x is string => x !== null);
});
}
function clearArgvLog(): void {
writeFileSync(FAKE_GIT_LOG, '');
}
function setMode(mode: 'ok' | 'fail' | 'url-drift' | 'url-match' | 'local-origin'): void {
writeFileSync(FAKE_GIT_MODE, mode);
}
function setOrigin(url: string): void {
writeFileSync(FAKE_GIT_ORIGIN, url);
}
beforeAll(() => writeFakeGit());
afterAll(() => rmSync(FAKE_GIT_DIR, { recursive: true, force: true }));
beforeEach(() => {
clearArgvLog();
setMode('ok');
});
const fakePath = (): string => `${FAKE_GIT_DIR}:${process.env.PATH ?? ''}`;
// ---------------------------------------------------------------------------
// GIT_SSRF_FLAGS — pinned shape (snapshot test). If a future flag is added,
// update the expected list here AND verify both cloneRepo + pullRepo pick it
// up via the GIT_SSRF_FLAGS spread (the codex finding that motivated this).
// ---------------------------------------------------------------------------
describe('GIT_SSRF_FLAGS', () => {
test('exact shape — global -c config flags only (spread BEFORE the verb)', () => {
expect([...GIT_SSRF_FLAGS]).toEqual([
'-c', 'http.followRedirects=false',
'-c', 'protocol.file.allow=never',
'-c', 'protocol.ext.allow=never',
]);
});
});
describe('GIT_SSRF_SUBCOMMAND_FLAGS', () => {
test('exact shape — subcommand-level flags only (spread AFTER the verb)', () => {
// v0.34 fix wave: --no-recurse-submodules is a clone/pull subcommand
// flag, not a global flag. Real git exits 129 with "unknown option"
// when it appears before the verb. The pre-v0.34 single-constant
// spread baked the bug in.
expect([...GIT_SSRF_SUBCOMMAND_FLAGS]).toEqual([
'--no-recurse-submodules',
]);
});
});
// ---------------------------------------------------------------------------
// parseRemoteUrl
// ---------------------------------------------------------------------------
describe('parseRemoteUrl — happy path', () => {
test('accepts plain https URL', () => {
const r = parseRemoteUrl('https://github.com/garrytan/dummy.git');
expect(r.url).toBe('https://github.com/garrytan/dummy.git');
expect(r.hostname).toBe('github.com');
});
});
describe('parseRemoteUrl — rejection cases', () => {
test('rejects empty input', () => {
expect(() => parseRemoteUrl('')).toThrow(RemoteUrlError);
});
test('rejects malformed URL', () => {
expect(() => parseRemoteUrl('not a url')).toThrow(/malformed|invalid_url/i);
});
test('rejects ssh:// scheme', () => {
try {
parseRemoteUrl('ssh://git@github.com/foo/bar.git');
throw new Error('expected throw');
} catch (e) {
expect(e).toBeInstanceOf(RemoteUrlError);
expect((e as RemoteUrlError).code).toBe('unsupported_scheme');
}
});
test('rejects git:// scheme', () => {
expect(() => parseRemoteUrl('git://github.com/foo/bar')).toThrow(/scheme not supported/i);
});
test('rejects file:// scheme', () => {
expect(() => parseRemoteUrl('file:///etc/passwd')).toThrow(/scheme not supported/i);
});
test('rejects embedded credentials', () => {
try {
parseRemoteUrl('https://user:pass@github.com/foo');
throw new Error('expected throw');
} catch (e) {
expect(e).toBeInstanceOf(RemoteUrlError);
expect((e as RemoteUrlError).code).toBe('embedded_credentials');
}
});
test('rejects path traversal (..)', () => {
try {
parseRemoteUrl('https://github.com/foo/../etc/passwd');
throw new Error('expected throw');
} catch (e) {
expect(e).toBeInstanceOf(RemoteUrlError);
expect((e as RemoteUrlError).code).toBe('path_traversal');
}
});
test('rejects RFC1918 192.168.x.x', () => {
try {
parseRemoteUrl('https://192.168.1.1/repo.git');
throw new Error('expected throw');
} catch (e) {
expect(e).toBeInstanceOf(RemoteUrlError);
expect((e as RemoteUrlError).code).toBe('internal_target');
}
});
test('rejects loopback 127.0.0.1', () => {
expect(() => parseRemoteUrl('https://127.0.0.1/repo')).toThrow(/internal/i);
});
test('rejects localhost', () => {
expect(() => parseRemoteUrl('https://localhost/repo')).toThrow(/internal/i);
});
test('rejects metadata.google.internal', () => {
expect(() => parseRemoteUrl('https://metadata.google.internal/foo')).toThrow(
/internal/i,
);
});
test('rejects 169.254.x.x AWS metadata range', () => {
expect(() => parseRemoteUrl('https://169.254.169.254/foo')).toThrow(/internal/i);
});
// Codex v0.28.1 finding: IPv6 ULA + link-local were not blocked.
test('rejects IPv6 ULA fc00::/7 (fd-prefix)', () => {
expect(() => parseRemoteUrl('https://[fd00:1234::1]/repo')).toThrow(/internal/i);
});
test('rejects IPv6 ULA fc00::/7 (fc-prefix)', () => {
expect(() => parseRemoteUrl('https://[fc01:2345::abcd]/repo')).toThrow(/internal/i);
});
test('rejects IPv6 link-local fe80::/10', () => {
expect(() => parseRemoteUrl('https://[fe80::1]/repo')).toThrow(/internal/i);
});
test('does NOT reject public IPv6', () => {
// 2606:4700:4700::1111 is Cloudflare DNS — public IPv6
const r = parseRemoteUrl('https://[2606:4700:4700::1111]/repo');
expect(r.hostname).toBe('[2606:4700:4700::1111]');
});
});
// T3 — Tailscale CGNAT regression cases.
describe('parseRemoteUrl — CGNAT 100.64/10 (Tailscale)', () => {
test('rejected by default', async () => {
await withEnv({ GBRAIN_ALLOW_PRIVATE_REMOTES: undefined }, async () => {
try {
parseRemoteUrl('https://100.64.0.1/repo.git');
throw new Error('expected throw');
} catch (e) {
expect(e).toBeInstanceOf(RemoteUrlError);
expect((e as RemoteUrlError).code).toBe('internal_target');
}
});
});
test('accepted with GBRAIN_ALLOW_PRIVATE_REMOTES=1', async () => {
await withEnv({ GBRAIN_ALLOW_PRIVATE_REMOTES: '1' }, async () => {
const r = parseRemoteUrl('https://100.64.0.1/repo.git');
expect(r.hostname).toBe('100.64.0.1');
});
});
test('also covers 100.127.x (upper end of CGNAT range)', async () => {
await withEnv({ GBRAIN_ALLOW_PRIVATE_REMOTES: undefined }, async () => {
expect(() => parseRemoteUrl('https://100.127.255.1/x')).toThrow(/internal/i);
});
});
test('does NOT reject 100.0.x (just below CGNAT range)', () => {
// 100.0.0.0/8 is regular public IP space outside CGNAT
const r = parseRemoteUrl('https://100.63.255.1/repo');
expect(r.hostname).toBe('100.63.255.1');
});
});
// ---------------------------------------------------------------------------
// cloneRepo — fake-git harness
// ---------------------------------------------------------------------------
describe('cloneRepo', () => {
test('happy path: invokes git with GIT_SSRF_FLAGS + --depth=1 + url + dest', async () => {
const dest = join(FAKE_GIT_DIR, 'clone-target');
rmSync(dest, { recursive: true, force: true });
await withEnv({ PATH: fakePath() }, async () => {
cloneRepo('https://example.com/repo', dest);
});
const calls = readArgvLog();
expect(calls.length).toBe(1);
const argv = calls[0];
// Global -c config flags must appear BEFORE the 'clone' verb.
expect(argv.slice(0, GIT_SSRF_FLAGS.length)).toEqual([...GIT_SSRF_FLAGS]);
expect(argv).toContain('clone');
expect(argv).toContain('--depth=1');
expect(argv).toContain('https://example.com/repo');
expect(argv[argv.length - 1]).toBe(dest);
// v0.34 fix wave: subcommand flags MUST appear after the verb. Real
// git rejects `git --no-recurse-submodules clone ...` with exit 129.
// The fake-git harness returned 0 for any argv shape, so this
// position-anchored assertion is the structural regression test.
const cloneIdx = argv.indexOf('clone');
expect(cloneIdx).toBeGreaterThan(-1);
for (const subFlag of GIT_SSRF_SUBCOMMAND_FLAGS) {
const flagIdx = argv.indexOf(subFlag);
expect(flagIdx).toBeGreaterThan(cloneIdx);
}
});
test('depth=0 means no --depth flag (full clone)', async () => {
const dest = join(FAKE_GIT_DIR, 'clone-full');
rmSync(dest, { recursive: true, force: true });
await withEnv({ PATH: fakePath() }, async () => {
cloneRepo('https://example.com/repo', dest, { depth: 0 });
});
const argv = readArgvLog()[0];
expect(argv.find(a => a.startsWith('--depth'))).toBeUndefined();
});
test('passes --branch when provided', async () => {
const dest = join(FAKE_GIT_DIR, 'clone-branch');
rmSync(dest, { recursive: true, force: true });
await withEnv({ PATH: fakePath() }, async () => {
cloneRepo('https://example.com/repo', dest, { branch: 'main' });
});
const argv = readArgvLog()[0];
const branchIdx = argv.indexOf('--branch');
expect(branchIdx).toBeGreaterThan(-1);
expect(argv[branchIdx + 1]).toBe('main');
});
test('refuses non-empty destDir', async () => {
const dest = join(FAKE_GIT_DIR, 'clone-nonempty');
mkdirSync(dest, { recursive: true });
writeFileSync(join(dest, 'sentinel'), 'hi');
await withEnv({ PATH: fakePath() }, async () => {
try {
cloneRepo('https://example.com/repo', dest);
throw new Error('expected throw');
} catch (e) {
expect(e).toBeInstanceOf(GitOperationError);
expect((e as GitOperationError).op).toBe('clone');
}
});
expect(readArgvLog().length).toBe(0); // never invoked git
rmSync(dest, { recursive: true, force: true });
});
test('throws GitOperationError when git exits non-zero', async () => {
const dest = join(FAKE_GIT_DIR, 'clone-fails');
rmSync(dest, { recursive: true, force: true });
setMode('fail');
await withEnv({ PATH: fakePath() }, async () => {
try {
cloneRepo('https://example.com/repo', dest);
throw new Error('expected throw');
} catch (e) {
expect(e).toBeInstanceOf(GitOperationError);
expect((e as GitOperationError).op).toBe('clone');
}
});
});
});
// ---------------------------------------------------------------------------
// pullRepo — fake-git harness
// ---------------------------------------------------------------------------
describe('pullRepo', () => {
test('happy path: invokes git -C path with GIT_SSRF_FLAGS + pull --ff-only', async () => {
const repo = join(FAKE_GIT_DIR, 'pull-target');
mkdirSync(repo, { recursive: true });
await withEnv({ PATH: fakePath() }, async () => {
pullRepo(repo);
});
const argv = readArgvLog()[0];
expect(argv[0]).toBe('-C');
expect(argv[1]).toBe(repo);
expect(argv.slice(2, 2 + GIT_SSRF_FLAGS.length)).toEqual([...GIT_SSRF_FLAGS]);
expect(argv).toContain('pull');
expect(argv).toContain('--ff-only');
// v0.34 fix wave: subcommand flag position assertion.
const pullIdx = argv.indexOf('pull');
expect(pullIdx).toBeGreaterThan(-1);
for (const subFlag of GIT_SSRF_SUBCOMMAND_FLAGS) {
const flagIdx = argv.indexOf(subFlag);
expect(flagIdx).toBeGreaterThan(pullIdx);
}
rmSync(repo, { recursive: true, force: true });
});
test('throws GitOperationError when git exits non-zero', async () => {
const repo = join(FAKE_GIT_DIR, 'pull-fails');
mkdirSync(repo, { recursive: true });
setMode('fail');
await withEnv({ PATH: fakePath() }, async () => {
expect(() => pullRepo(repo)).toThrow(GitOperationError);
});
rmSync(repo, { recursive: true, force: true });
});
// -------------------------------------------------------------------------
// local-file origin gating (#2709, security-sensitive). pullRepo is strict
// by default and only relaxes protocol.file.allow to `always` when opted-in
// AND the resolved pull origin is a local-file path physically contained
// under the trusted root. We observe the recorded argv's `-c` block
// (between the `-C <repo>` prefix and the `pull` verb).
// -------------------------------------------------------------------------
test('default (no opts): -c block is strict — protocol.file.allow=never, origin never resolved', async () => {
const repo = join(FAKE_GIT_DIR, 'pull-strict-default');
mkdirSync(repo, { recursive: true });
await withEnv({ PATH: fakePath() }, async () => {
pullRepo(repo);
});
const calls = readArgvLog();
// Strict path short-circuits origin resolution: exactly one git call.
expect(calls.length).toBe(1);
const argv = calls[0];
const flagBlock = argv.slice(2, argv.indexOf('pull'));
expect(flagBlock).toEqual([...GIT_SSRF_FLAGS]);
expect(flagBlock).toContain('protocol.file.allow=never');
expect(flagBlock).not.toContain('protocol.file.allow=always');
rmSync(repo, { recursive: true, force: true });
});
test('allowLocalFileOrigin + local origin under trusted root: -c block relaxes to GIT_SSRF_FLAGS_LOCAL (protocol.file.allow=always)', async () => {
const repo = join(FAKE_GIT_DIR, 'pull-local-trusted');
mkdirSync(repo, { recursive: true });
// A REAL local bare-repo dir as origin, physically under the trusted root
// (realpathSync requires both to exist for the containment check).
const originPath = join(FAKE_GIT_DIR, 'local-origin.git');
mkdirSync(originPath, { recursive: true });
setMode('local-origin');
setOrigin(originPath);
await withEnv({ PATH: fakePath() }, async () => {
pullRepo(repo, { allowLocalFileOrigin: true, trustedOriginRoot: FAKE_GIT_DIR });
});
const pullCall = readArgvLog().find(c => c.includes('pull'));
expect(pullCall).toBeDefined();
const flagBlock = pullCall!.slice(2, pullCall!.indexOf('pull'));
expect(flagBlock).toEqual([...GIT_SSRF_FLAGS_LOCAL]);
expect(flagBlock).toContain('protocol.file.allow=always');
expect(flagBlock).not.toContain('protocol.file.allow=never');
rmSync(repo, { recursive: true, force: true });
rmSync(originPath, { recursive: true, force: true });
});
test('allowLocalFileOrigin + local origin OUTSIDE trusted root: -c block stays strict (protocol.file.allow=never)', async () => {
const repo = join(FAKE_GIT_DIR, 'pull-local-outside');
mkdirSync(repo, { recursive: true });
const originPath = join(FAKE_GIT_DIR, 'local-origin.git');
mkdirSync(originPath, { recursive: true });
// A real trusted root that does NOT contain originPath.
const outsideRoot = join(tmpdir(), `gbrain-outside-root-${process.pid}`);
mkdirSync(outsideRoot, { recursive: true });
setMode('local-origin');
setOrigin(originPath);
await withEnv({ PATH: fakePath() }, async () => {
pullRepo(repo, { allowLocalFileOrigin: true, trustedOriginRoot: outsideRoot });
});
const pullCall = readArgvLog().find(c => c.includes('pull'));
expect(pullCall).toBeDefined();
const flagBlock = pullCall!.slice(2, pullCall!.indexOf('pull'));
expect(flagBlock).toEqual([...GIT_SSRF_FLAGS]);
expect(flagBlock).toContain('protocol.file.allow=never');
expect(flagBlock).not.toContain('protocol.file.allow=always');
rmSync(repo, { recursive: true, force: true });
rmSync(originPath, { recursive: true, force: true });
rmSync(outsideRoot, { recursive: true, force: true });
});
test('allowLocalFileOrigin + https origin: -c block stays strict (network remote never relaxed)', async () => {
const repo = join(FAKE_GIT_DIR, 'pull-https-origin');
mkdirSync(repo, { recursive: true });
setMode('local-origin');
setOrigin('https://github.com/example/repo.git');
await withEnv({ PATH: fakePath() }, async () => {
pullRepo(repo, { allowLocalFileOrigin: true, trustedOriginRoot: FAKE_GIT_DIR });
});
const pullCall = readArgvLog().find(c => c.includes('pull'));
expect(pullCall).toBeDefined();
const flagBlock = pullCall!.slice(2, pullCall!.indexOf('pull'));
expect(flagBlock).toEqual([...GIT_SSRF_FLAGS]);
expect(flagBlock).toContain('protocol.file.allow=never');
expect(flagBlock).not.toContain('protocol.file.allow=always');
rmSync(repo, { recursive: true, force: true });
});
test('relative origin resolves against the repo dir (matching `git -C`), not process.cwd()', async () => {
// git resolves a relative file remote against the repo dir (`git -C repo
// pull`), so the containment guard must too. The relative name exists
// under repo (inside the trusted root) but NOT under process.cwd() — a
// cwd-based guard would realpath-fail and stay strict.
const repo = join(FAKE_GIT_DIR, 'pull-rel-origin');
mkdirSync(join(repo, 'rel-origin.git'), { recursive: true });
setMode('local-origin');
setOrigin('rel-origin.git');
await withEnv({ PATH: fakePath() }, async () => {
pullRepo(repo, { allowLocalFileOrigin: true, trustedOriginRoot: FAKE_GIT_DIR });
});
const pullCall = readArgvLog().find(c => c.includes('pull'));
expect(pullCall).toBeDefined();
const flagBlock = pullCall!.slice(2, pullCall!.indexOf('pull'));
expect(flagBlock).toEqual([...GIT_SSRF_FLAGS_LOCAL]);
rmSync(repo, { recursive: true, force: true });
});
});
// ---------------------------------------------------------------------------
// validateRepoState — 6-state decision tree
// ---------------------------------------------------------------------------
describe('validateRepoState', () => {
const fixtureDir = join(FAKE_GIT_DIR, 'state-fixtures');
beforeEach(() => {
rmSync(fixtureDir, { recursive: true, force: true });
mkdirSync(fixtureDir, { recursive: true });
});
test("returns 'missing' for nonexistent path", () => {
expect(validateRepoState(join(fixtureDir, 'nope'))).toBe('missing');
});
test("returns 'not-a-dir' when path is a file", () => {
const p = join(fixtureDir, 'a-file');
writeFileSync(p, 'hi');
expect(validateRepoState(p)).toBe('not-a-dir');
});
test("returns 'no-git' for directory without .git/", () => {
const p = join(fixtureDir, 'no-git-dir');
mkdirSync(p, { recursive: true });
expect(validateRepoState(p)).toBe('no-git');
});
test("returns 'corrupted' when git remote get-url fails", async () => {
const p = join(fixtureDir, 'corrupted-repo');
mkdirSync(join(p, '.git'), { recursive: true });
setMode('fail');
await withEnv({ PATH: fakePath() }, async () => {
expect(validateRepoState(p)).toBe('corrupted');
});
});
test("returns 'url-drift' when remote differs from expected", async () => {
const p = join(fixtureDir, 'drift-repo');
mkdirSync(join(p, '.git'), { recursive: true });
setMode('url-drift');
await withEnv({ PATH: fakePath() }, async () => {
expect(validateRepoState(p, 'https://github.com/expected/url')).toBe('url-drift');
});
});
test("returns 'healthy' when remote matches expected", async () => {
const p = join(fixtureDir, 'healthy-repo');
mkdirSync(join(p, '.git'), { recursive: true });
setMode('url-match');
await withEnv({ PATH: fakePath() }, async () => {
expect(validateRepoState(p, 'https://github.com/expected/url')).toBe('healthy');
});
});
test("returns 'healthy' when no expected URL provided (just probe)", async () => {
const p = join(fixtureDir, 'healthy-no-expect');
mkdirSync(join(p, '.git'), { recursive: true });
setMode('ok');
await withEnv({ PATH: fakePath() }, async () => {
expect(validateRepoState(p)).toBe('healthy');
});
});
});