From e2fc3df9fefae0c68ca0196e77a27cf043af482a Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 22 Jul 2026 11:55:24 -0700 Subject: [PATCH] fix(git-remote): resolve relative pull origins against the repo dir, matching git -C MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/core/git-remote.ts | 8 ++++++-- test/git-remote.test.ts | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/core/git-remote.ts b/src/core/git-remote.ts index cb394bf57..921848a60 100644 --- a/src/core/git-remote.ts +++ b/src/core/git-remote.ts @@ -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); diff --git a/test/git-remote.test.ts b/test/git-remote.test.ts index dc3e6b95f..c2e02723a 100644 --- a/test/git-remote.test.ts +++ b/test/git-remote.test.ts @@ -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 }); + }); }); // ---------------------------------------------------------------------------