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>
This commit is contained in:
Garry Tan
2026-07-22 11:55:24 -07:00
co-authored by Claude Fable 5
parent 421f892703
commit e2fc3df9fe
2 changed files with 25 additions and 2 deletions
+6 -2
View File
@@ -16,7 +16,7 @@
*/
import { execFileSync } from 'child_process';
import { lstatSync, existsSync, readdirSync, realpathSync } from 'fs';
import { join, sep } from 'path';
import { isAbsolute, join, sep } from 'path';
import { fileURLToPath } from 'url';
import { isInternalUrl } from './url-safety.ts';
@@ -275,7 +275,11 @@ function pullOriginUrl(repoPath: string): string | null {
function isTrustedLocalOrigin(repoPath: string, trustedRoot: string): boolean {
const url = pullOriginUrl(repoPath);
if (!url || !isLocalFileRemote(url)) return false;
const originPath = url.startsWith('file://') ? fileURLToPath(url) : url;
// Resolve a relative origin against the repo, matching what `git -C repoPath
// pull` actually pulls (git resolves relative file remotes against the repo
// dir, NOT this process's cwd) — the guard must check the same path git uses.
const raw = url.startsWith('file://') ? fileURLToPath(url) : url;
const originPath = isAbsolute(raw) ? raw : join(repoPath, raw);
try {
const real = realpathSync(originPath);
const root = realpathSync(trustedRoot);
+19
View File
@@ -453,6 +453,25 @@ describe('pullRepo', () => {
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 });
});
});
// ---------------------------------------------------------------------------