Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions packages/sandbox/daemon/git/checkout-branch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-"));
Expand Down Expand Up @@ -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);
});
7 changes: 7 additions & 0 deletions packages/sandbox/daemon/git/checkout-branch.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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`,
);
Expand Down
Loading