From e3c30712ca02f5bd3197750d9b3d74bae7217b77 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 31 May 2026 04:38:37 -0700 Subject: [PATCH] fix: allow slash remote push targets (#4109) --- src/main/git/remote.test.ts | 16 +++++++++++++ src/shared/git-push-target-validation.test.ts | 19 +++++++++++++++ src/shared/git-push-target-validation.ts | 24 ++++++++++++++----- 3 files changed, 53 insertions(+), 6 deletions(-) create mode 100644 src/shared/git-push-target-validation.test.ts diff --git a/src/main/git/remote.test.ts b/src/main/git/remote.test.ts index b86b3eee4..e6ebc1465 100644 --- a/src/main/git/remote.test.ts +++ b/src/main/git/remote.test.ts @@ -335,6 +335,22 @@ describe('git remote operations', () => { ]) }) + it('fetches explicit publish target remotes whose names contain slashes', async () => { + gitExecFileAsyncMock + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + + await gitFetch('/repo', { + remoteName: 'foo/bar', + branchName: 'feature/fix' + }) + + expect(gitExecFileAsyncMock.mock.calls).toEqual([ + [['check-ref-format', '--branch', 'feature/fix'], { cwd: '/repo' }], + [['fetch', '--prune', 'foo/bar'], { cwd: '/repo' }] + ]) + }) + it('normalizes fetch authentication errors to a friendly message', async () => { gitExecFileAsyncMock.mockRejectedValueOnce(new Error('Authentication failed')) diff --git a/src/shared/git-push-target-validation.test.ts b/src/shared/git-push-target-validation.test.ts new file mode 100644 index 000000000..7b5dd096e --- /dev/null +++ b/src/shared/git-push-target-validation.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { assertGitPushTargetShape } from './git-push-target-validation' + +describe('assertGitPushTargetShape', () => { + it('accepts slash-separated git remote names', () => { + expect(() => + assertGitPushTargetShape({ remoteName: 'foo/bar', branchName: 'feature/fix' }) + ).not.toThrow() + }) + + it('rejects remote names with empty or parent segments', () => { + expect(() => + assertGitPushTargetShape({ remoteName: 'foo//bar', branchName: 'feature/fix' }) + ).toThrow('Invalid git remote name') + expect(() => + assertGitPushTargetShape({ remoteName: 'foo/../bar', branchName: 'feature/fix' }) + ).toThrow('Invalid git remote name') + }) +}) diff --git a/src/shared/git-push-target-validation.ts b/src/shared/git-push-target-validation.ts index ed71eb022..e4de8736a 100644 --- a/src/shared/git-push-target-validation.ts +++ b/src/shared/git-push-target-validation.ts @@ -1,6 +1,6 @@ import type { GitPushTarget } from './types' -const SAFE_REMOTE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/ +const SAFE_REMOTE_NAME_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/ const GITHUB_CLONE_URL = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\.git$/ const GITHUB_SSH_URL = /^git@github\.com:[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\.git$/ @@ -10,6 +10,22 @@ function assertString(value: unknown, name: string): asserts value is string { } } +function isSafeRemoteName(remoteName: string): boolean { + if (remoteName.length === 0 || remoteName.length > 100) { + return false + } + return remoteName.split('/').every((segment) => { + // Git accepts slash-separated remote names; each segment still needs to be + // a concrete name so persisted push targets cannot smuggle path traversal. + return ( + segment !== '' && + segment !== '.' && + segment !== '..' && + SAFE_REMOTE_NAME_SEGMENT.test(segment) + ) + }) +} + export function assertGitPushTargetShape(target: unknown): asserts target is GitPushTarget { if (typeof target !== 'object' || target === null) { throw new Error('Invalid PR push target.') @@ -17,11 +33,7 @@ export function assertGitPushTargetShape(target: unknown): asserts target is Git const candidate = target as Record assertString(candidate.remoteName, 'remote name') assertString(candidate.branchName, 'branch name') - if ( - !SAFE_REMOTE_NAME.test(candidate.remoteName) || - candidate.remoteName === '.' || - candidate.remoteName === '..' - ) { + if (!isSafeRemoteName(candidate.remoteName)) { throw new Error(`Invalid git remote name: ${candidate.remoteName}`) } if (!candidate.branchName || candidate.branchName.startsWith('-')) {