fix: allow slash remote push targets (#4109)

This commit is contained in:
Neil 2026-05-31 04:38:37 -07:00 committed by GitHub
parent 053e172585
commit e3c30712ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 53 additions and 6 deletions

View File

@ -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'))

View File

@ -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')
})
})

View File

@ -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<string, unknown>
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('-')) {