diff --git a/apps/server/src/resources/impls/git.test.ts b/apps/server/src/resources/impls/git.test.ts index 9b6c9f5c..878d54cc 100644 --- a/apps/server/src/resources/impls/git.test.ts +++ b/apps/server/src/resources/impls/git.test.ts @@ -4,8 +4,48 @@ import path from 'node:path'; import os from 'node:os'; import { loadGitResource } from './git.ts'; +import { GitResourceSchema } from '../schema.ts'; import type { BtcaGitResourceArgs } from '../types.ts'; +describe('Branch name validation', () => { + const validGitResource = (branch: string) => ({ + type: 'git' as const, + name: 'test', + url: 'https://github.com/test/repo', + branch + }); + + it('accepts monorepo-style scoped tags', () => { + const result = GitResourceSchema.safeParse(validGitResource('@chakra-ui/react@2.8.2')); + expect(result.success).toBe(true); + }); + + it('accepts simple version tags', () => { + const result = GitResourceSchema.safeParse(validGitResource('v18.2.0')); + expect(result.success).toBe(true); + }); + + it('accepts standard branch names', () => { + const result = GitResourceSchema.safeParse(validGitResource('feature/my-branch')); + expect(result.success).toBe(true); + }); + + it('rejects branch names starting with hyphen', () => { + const result = GitResourceSchema.safeParse(validGitResource('-dangerous')); + expect(result.success).toBe(false); + }); + + it('rejects branch names with spaces', () => { + const result = GitResourceSchema.safeParse(validGitResource('my branch')); + expect(result.success).toBe(false); + }); + + it('rejects branch names with special characters', () => { + const result = GitResourceSchema.safeParse(validGitResource('branch;rm -rf')); + expect(result.success).toBe(false); + }); +}); + describe('Git Resource', () => { let testDir: string; @@ -101,6 +141,36 @@ describe('Git Resource', () => { expect(loadGitResource(args)).rejects.toThrow('Branch name must contain only'); }); + it('throws error for branch name starting with hyphen', async () => { + const args: BtcaGitResourceArgs = { + type: 'git', + name: 'hyphen-branch', + url: 'https://github.com/test/repo', + branch: '-dangerous', + repoSubPaths: [], + resourcesDirectoryPath: testDir, + specialAgentInstructions: '', + quiet: true + }; + + expect(loadGitResource(args)).rejects.toThrow("must not start with '-'"); + }); + + it('throws error for branch name with spaces', async () => { + const args: BtcaGitResourceArgs = { + type: 'git', + name: 'space-branch', + url: 'https://github.com/test/repo', + branch: 'my branch', + repoSubPaths: [], + resourcesDirectoryPath: testDir, + specialAgentInstructions: '', + quiet: true + }; + + expect(loadGitResource(args)).rejects.toThrow('Branch name must contain only'); + }); + it('throws error for path traversal attempt', async () => { const args: BtcaGitResourceArgs = { type: 'git', diff --git a/apps/server/src/resources/impls/git.ts b/apps/server/src/resources/impls/git.ts index c45d185c..3edb4273 100644 --- a/apps/server/src/resources/impls/git.ts +++ b/apps/server/src/resources/impls/git.ts @@ -239,7 +239,7 @@ const gitClone = async (args: { if (!branchValidation.success) { throw new ResourceError({ message: branchValidation.error, - hint: 'Branch names can only contain letters, numbers, hyphens, underscores, dots, and forward slashes.', + hint: 'Branch names can only contain letters, numbers, hyphens, underscores, dots, forward slashes, and @ symbols.', cause: new Error('Branch validation failed') }); } diff --git a/apps/server/src/resources/schema.ts b/apps/server/src/resources/schema.ts index 0cbe203a..c1b94fac 100644 --- a/apps/server/src/resources/schema.ts +++ b/apps/server/src/resources/schema.ts @@ -17,7 +17,7 @@ const RESOURCE_NAME_REGEX = /^@?[a-zA-Z0-9][a-zA-Z0-9._-]*(\/[a-zA-Z0-9][a-zA-Z0 * Branch name: alphanumeric, forward slashes, dots, underscores, and hyphens. * Must not start with hyphen to prevent git option injection. */ -const BRANCH_NAME_REGEX = /^[a-zA-Z0-9/_.-]+$/; +const BRANCH_NAME_REGEX = /^[a-zA-Z0-9/_.\-@]+$/; const parseUrl = (value: string) => Result.try(() => new URL(value)).match({ @@ -99,7 +99,7 @@ const BranchNameSchema = z .max(LIMITS.BRANCH_NAME_MAX, `Branch name too long (max ${LIMITS.BRANCH_NAME_MAX} chars)`) .regex( BRANCH_NAME_REGEX, - 'Branch name must contain only alphanumeric characters, forward slashes, dots, underscores, and hyphens' + 'Branch name must contain only alphanumeric characters, forward slashes, dots, underscores, hyphens, and @ symbols' ) .refine((branch) => !branch.startsWith('-'), { message: "Branch name must not start with '-' to prevent git option injection" diff --git a/apps/server/src/validation/index.ts b/apps/server/src/validation/index.ts index 1722660d..e485dd53 100644 --- a/apps/server/src/validation/index.ts +++ b/apps/server/src/validation/index.ts @@ -23,7 +23,7 @@ const RESOURCE_NAME_REGEX = /^@?[a-zA-Z0-9][a-zA-Z0-9._-]*(\/[a-zA-Z0-9][a-zA-Z0 * Branch name: alphanumeric, forward slashes, dots, underscores, and hyphens. * Must not start with hyphen to prevent git option injection. */ -const BRANCH_NAME_REGEX = /^[a-zA-Z0-9/_.-]+$/; +const BRANCH_NAME_REGEX = /^[a-zA-Z0-9/_.\-@]+$/; /** * Provider/Model names: letters, numbers, dots, underscores, plus, hyphens, forward slashes, colons. @@ -152,7 +152,7 @@ export const validateBranchName = (branch: string): ValidationResult => { if (!BRANCH_NAME_REGEX.test(branch)) { return fail( - `Invalid branch name: "${branch}". Must contain only alphanumeric characters, forward slashes, dots, underscores, and hyphens` + `Invalid branch name: "${branch}". Must contain only alphanumeric characters, forward slashes, dots, underscores, hyphens, and @ symbols` ); } diff --git a/apps/web/static/btca.schema.json b/apps/web/static/btca.schema.json index 467babe2..d79c8349 100644 --- a/apps/web/static/btca.schema.json +++ b/apps/web/static/btca.schema.json @@ -87,7 +87,8 @@ }, "branch": { "type": "string", - "description": "Git branch to use", + "description": "Git branch, tag, or ref to use (supports monorepo-style tags like @scope/pkg@version)", + "pattern": "^[a-zA-Z0-9/_\\.\\-@]+$", "default": "main" }, "searchPath": {