From 60815ef62e6909187333027e5d8551b8a2a344d6 Mon Sep 17 00:00:00 2001 From: pedrofrxncx Date: Sat, 11 Jul 2026 14:33:52 -0300 Subject: [PATCH] fix(sandbox): validate remote default branch before shelling out in checkout spawnCheckoutBranch's fork-from-default-branch path reads defaultBranch from the remote's own origin/HEAD symref and interpolates it into sh -c git commands without validation. Git's ref-format rules permit shell metacharacters (;, $(...), backticks) in ref names, so a repo whose default branch is attacker-named injects arbitrary commands into the sandbox daemon. The sibling fetchBaseBranch path in clone.ts was already hardened against this exact class with its own ad-hoc allowlist; this path was missed. Reuse the existing assertValidRemoteBranchName guard (already used in rebase-onto-base.ts and routes/git.ts) instead of adding another ad-hoc check. --- .../daemon/git/checkout-branch.test.ts | 29 +++++++++++++++++++ .../sandbox/daemon/git/checkout-branch.ts | 7 +++++ 2 files changed, 36 insertions(+) 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`, );