diff --git a/services/api/src/workspaces/project-environment.ts b/services/api/src/workspaces/project-environment.ts index 180793d4..41c44642 100644 --- a/services/api/src/workspaces/project-environment.ts +++ b/services/api/src/workspaces/project-environment.ts @@ -237,6 +237,14 @@ export class ProjectEnvironmentService { ) { assertRepositoryContract(input.manifest, input.credentials.repositories); const preparedInput = await this.withDeclaredEnvironment(input); + for (const repository of preparedInput.credentials.repositories) { + if (!isSafeGitBranch(repository.defaultBranch)) { + throw new ProjectEnvironmentError( + "repository_branch_invalid", + "repository default branch is invalid", + ); + } + } await this.run(preparedInput, "mkdir -p repos", ".", "environment.repositories"); for (const repository of preparedInput.credentials.repositories) { await this.runCommand( @@ -569,6 +577,12 @@ export class ProjectEnvironmentService { "Use a story branch, not a repository base branch", ); } + if (!isSafeGitBranch(repository.defaultBranch)) { + throw new ProjectEnvironmentError( + "repository_branch_invalid", + "repository default branch is invalid", + ); + } const local = await this.runtime.exec(input.workspace, { command: "git", args: ["show-ref", "--verify", `refs/heads/${input.branch}`], diff --git a/services/api/test/git-branch.test.ts b/services/api/test/git-branch.test.ts new file mode 100644 index 00000000..147f3726 --- /dev/null +++ b/services/api/test/git-branch.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { isSafeGitBranch } from "../src/workspaces/git-branch.js"; + +describe("isSafeGitBranch", () => { + it("accepts ordinary refs and hostile-but-valid Git names", () => { + expect(isSafeGitBranch("main")).toBe(true); + expect(isSafeGitBranch("release/2026")).toBe(true); + expect(isSafeGitBranch("$(id)")).toBe(true); + expect(isSafeGitBranch('foo"bar')).toBe(true); + }); + + it("rejects refs that become git revision syntax or control characters", () => { + expect(isSafeGitBranch("foo..bar")).toBe(false); + expect(isSafeGitBranch("foo@{upstream}")).toBe(false); + expect(isSafeGitBranch("-force")).toBe(false); + expect(isSafeGitBranch("chore/../other")).toBe(false); + expect(isSafeGitBranch("main\nother")).toBe(false); + expect(isSafeGitBranch("")).toBe(false); + }); +}); diff --git a/services/api/test/project-environment.integration.test.ts b/services/api/test/project-environment.integration.test.ts index 6629b59b..5a4dcb8f 100644 --- a/services/api/test/project-environment.integration.test.ts +++ b/services/api/test/project-environment.integration.test.ts @@ -869,6 +869,27 @@ environment: expect(setupFailure?.data).toMatchObject({ exitCode: 19, stderr: "setup mismatch [REDACTED]" }); expect(JSON.stringify(setupFailure?.data)).not.toContain(secret); }); + + it("rejects a repository default branch that is git revision syntax", async () => { + const environment = new ProjectEnvironmentService(db, runtime, `file://${remotes}`); + await expect( + environment.prepare({ + orgId, + projectId, + workspace, + manifest, + credentials: { + ...credentials, + repositories: credentials.repositories.map((repository) => + repository.role === "primary" + ? { ...repository, defaultBranch: "main..evil" } + : repository, + ), + }, + branch: "facility/story-environment", + }), + ).rejects.toMatchObject({ code: "repository_branch_invalid" }); + }); }); async function createBareRepository(root: string, owner: string, name: string) {