diff --git a/packages/sandbox/daemon/git/checkout-branch.test.ts b/packages/sandbox/daemon/git/checkout-branch.test.ts index 4a70396088..082783de5d 100644 --- a/packages/sandbox/daemon/git/checkout-branch.test.ts +++ b/packages/sandbox/daemon/git/checkout-branch.test.ts @@ -8,6 +8,7 @@ import { resolveRemoteDefaultBranch, spawnCheckoutBranch, } from "./checkout-branch"; +import { InvalidRemoteBranchNameError } from "./ref-name"; function setupBareRepo(): { url: string; root: string; cleanup: () => void } { const root = mkdtempSync(join(tmpdir(), "checkout-branch-")); @@ -125,4 +126,32 @@ describe("spawnCheckoutBranch", () => { cleanup(); } }, 30_000); + + it("rejects a malicious origin/HEAD default branch instead of shelling it out", async () => { + const { url, root, cleanup } = setupBareRepo(); + try { + const repoDir = cloneWorkspace(url, root); + // A repo's default branch name is remote-controlled: whoever owns + // `origin` can point origin/HEAD anywhere. Git's ref-format rules allow + // shell metacharacters in ref names, so an attacker-named default + // branch would otherwise get interpolated straight into `sh -c`. + execSync( + `git -C ${repoDir} symbolic-ref refs/remotes/origin/HEAD 'refs/heads/pwn;touch\${IFS}${root}/INJECTED'`, + ); + + await expect( + spawnCheckoutBranch({ + repoDir, + branch: "does-not-exist-anywhere", + gc: `git -C ${repoDir}`, + runStep: (cmd) => spawnSetupStep(cmd, () => {}), + log: () => {}, + }), + ).rejects.toThrow(InvalidRemoteBranchNameError); + + expect(existsSync(join(root, "INJECTED"))).toBe(false); + } finally { + cleanup(); + } + }, 30_000); }); diff --git a/packages/sandbox/daemon/git/checkout-branch.ts b/packages/sandbox/daemon/git/checkout-branch.ts index 9b80a3b76e..41ceb838ab 100644 --- a/packages/sandbox/daemon/git/checkout-branch.ts +++ b/packages/sandbox/daemon/git/checkout-branch.ts @@ -1,4 +1,5 @@ import { gitSync } from "./git-sync"; +import { assertValidRemoteBranchName } from "./ref-name"; /** Default branch pointed to by `origin/HEAD`, falling back to `main`. */ export function resolveRemoteDefaultBranch(repoDir: string): string { @@ -88,6 +89,12 @@ export async function spawnCheckoutBranch( } const defaultBranch = resolveRemoteDefaultBranch(repoDir); + // `defaultBranch` comes from the remote's own `origin/HEAD` symref (set by + // whoever controls that remote) and is interpolated into `sh -c` git + // commands below — git permits shell metacharacters (`;`, `$(…)`, + // backticks, …) in ref names, so an unvalidated value here is a command + // injection vector. Reject before it ever reaches a shell string. + assertValidRemoteBranchName(defaultBranch); log( `[orchestrator] branch '${branch}' not on remote; creating from default branch '${defaultBranch}'\r\n`, );