Skip to content

Commit 53cc3ed

Browse files
authored
fix(sandbox): validate requested branch name before shelling out in clone (#4499)
git.repository.branch is config-supplied and gets interpolated straight into sh -c git commands in spawnClone (ls-remote/clone/checkout) with no validation, unlike the sibling checkout path (git/checkout-branch.ts) which already rejects unsafe ref names before shelling out. Since git allows shell metacharacters in ref names, a branch like `x;touch pwned` runs as a second shell command during the very first clone. Consolidated clone.ts's own isSafeRefName (already used for the remote-controlled default-branch fetch) into the shared git/ref-name.ts validator so both call sites share one allowlist, and used it to validate the requested branch too.
1 parent 1b2eed8 commit 53cc3ed

4 files changed

Lines changed: 50 additions & 67 deletions

File tree

packages/sandbox/daemon/git/ref-name.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ describe("assertValidRemoteBranchName", () => {
2121
"main/",
2222
"main.lock",
2323
"has space",
24+
"a//b",
2425
]) {
2526
expect(() => assertValidRemoteBranchName(name)).toThrow(
2627
InvalidRemoteBranchNameError,

packages/sandbox/daemon/git/ref-name.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,23 @@ export class InvalidRemoteBranchNameError extends Error {
88
}
99
}
1010

11+
/** Whether `name` is safe to interpolate as a git CLI branch/ref argument. */
12+
export function isValidRemoteBranchName(name: string): boolean {
13+
return (
14+
!!name &&
15+
name.length <= 255 &&
16+
!name.includes("..") &&
17+
!name.includes("//") &&
18+
!name.startsWith("/") &&
19+
!name.endsWith("/") &&
20+
!name.endsWith(".lock") &&
21+
REMOTE_BRANCH_NAME.test(name)
22+
);
23+
}
24+
1125
/** Validates a remote branch name before passing it as a git CLI argument. */
1226
export function assertValidRemoteBranchName(name: string): void {
13-
if (
14-
!name ||
15-
name.length > 255 ||
16-
name.includes("..") ||
17-
name.startsWith("/") ||
18-
name.endsWith("/") ||
19-
name.endsWith(".lock") ||
20-
!REMOTE_BRANCH_NAME.test(name)
21-
) {
27+
if (!isValidRemoteBranchName(name)) {
2228
throw new InvalidRemoteBranchNameError(name);
2329
}
2430
}

packages/sandbox/daemon/setup/clone.test.ts

Lines changed: 21 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { tmpdir } from "node:os";
1111
import { join } from "node:path";
1212
import { computeBranchDivergence } from "../git/branch-divergence";
1313
import type { Config } from "../types";
14-
import { isSafeRefName, spawnClone } from "./clone";
14+
import { spawnClone } from "./clone";
1515

1616
/**
1717
* Creates a bare "origin" git repo with two branches:
@@ -91,42 +91,6 @@ function makeConfig(
9191
} as unknown as Config;
9292
}
9393

94-
describe("isSafeRefName", () => {
95-
it("accepts real branch names", () => {
96-
for (const name of [
97-
"main",
98-
"master",
99-
"feature/x",
100-
"release/1.0",
101-
"feat-2.0_a",
102-
"user/fix.bug",
103-
]) {
104-
expect(isSafeRefName(name)).toBe(true);
105-
}
106-
});
107-
108-
it("rejects shell metacharacters and ref-format edge cases", () => {
109-
for (const name of [
110-
"x;whoami",
111-
"a$(id)b",
112-
"a`id`b",
113-
"a|b",
114-
"a&b",
115-
"a>b",
116-
"a b",
117-
"-x",
118-
"/x",
119-
"x/",
120-
"a..b",
121-
"a//b",
122-
"x.lock",
123-
"",
124-
]) {
125-
expect(isSafeRefName(name)).toBe(false);
126-
}
127-
});
128-
});
129-
13094
describe("spawnClone", () => {
13195
it("clones the target branch directly when it exists on remote", async () => {
13296
const { url, root, cleanup } = setupBareRepo();
@@ -248,6 +212,26 @@ describe("spawnClone", () => {
248212
}
249213
}, 30_000);
250214

215+
it("rejects a malicious requested branch instead of shelling it out", async () => {
216+
const { url, root, cleanup } = setupBareRepo();
217+
try {
218+
const repoDir = join(root, "workspace");
219+
// `branch` comes from tenant-supplied config and is interpolated into
220+
// `sh -c` git commands (ls-remote/clone/checkout) before any other
221+
// check runs — an unsafe name must be rejected before it ever reaches
222+
// a shell string instead of executing as a second command.
223+
const marker = join(root, "INJECTED");
224+
const { code } = await spawnClone({
225+
config: makeConfig(repoDir, url, `x;touch ${marker}`),
226+
onChunk: () => {},
227+
});
228+
expect(code).not.toBe(0);
229+
expect(existsSync(marker)).toBe(false);
230+
} finally {
231+
cleanup();
232+
}
233+
}, 30_000);
234+
251235
it("clones default and forks a local branch when target is missing on remote", async () => {
252236
const { url, root, cleanup } = setupBareRepo();
253237
try {

packages/sandbox/daemon/setup/clone.ts

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { sleep } from "@decocms/std";
22
import { existsSync, readdirSync } from "node:fs";
33
import { isAbsolute } from "node:path";
44
import { isSyntheticBranch } from "../constants";
5+
import { isValidRemoteBranchName } from "../git/ref-name";
56
import type { Config } from "../types";
67
import { spawnSetupStep } from "./spawn-step";
78

@@ -97,26 +98,6 @@ async function runNetworkStep(cmd: string, deps: CloneDeps): Promise<number> {
9798
// through to a fork-from-default fallback.
9899
const LS_REMOTE_NO_MATCH = 2;
99100

100-
/**
101-
* Conservative ref-name allowlist. `base` below is derived from
102-
* remote-controlled `ls-remote` output and interpolated into `sh -c` git
103-
* commands; git permits shell metacharacters (`;`, `$(…)`, backticks, `|`, …)
104-
* in branch names, so an untrusted remote whose HEAD points at a maliciously
105-
* named default branch could inject commands. Restrict to characters that
106-
* appear in real branch names and reject the ref-format edge cases.
107-
*/
108-
export function isSafeRefName(name: string): boolean {
109-
return (
110-
/^[A-Za-z0-9._/-]+$/.test(name) &&
111-
!name.startsWith("-") &&
112-
!name.startsWith("/") &&
113-
!name.endsWith("/") &&
114-
!name.endsWith(".lock") &&
115-
!name.includes("..") &&
116-
!name.includes("//")
117-
);
118-
}
119-
120101
/** Like runNetworkStep but also returns the (merged stdout+stderr) output. */
121102
async function runNetworkStepCapture(
122103
cmd: string,
@@ -180,7 +161,7 @@ async function fetchBaseBranch(
180161
// into `sh -c` git commands below — reject anything outside a safe ref-name
181162
// charset to close the command-injection vector (git allows `;`, `$(…)`,
182163
// backticks, etc. in branch names).
183-
if (!isSafeRefName(base)) {
164+
if (!isValidRemoteBranchName(base)) {
184165
deps.onChunk(
185166
"setup",
186167
`\r\n[clone] warning: refusing unsafe base branch name ${JSON.stringify(base)}; divergence vs base unavailable until next fetch\r\n`,
@@ -266,6 +247,17 @@ export async function spawnClone(deps: CloneDeps): Promise<CloneResult> {
266247
let branchOnRemote: string | null = null;
267248
let branchToForkLocally: string | null = null;
268249
if (branch) {
250+
// `branch` is config-supplied and interpolated into `sh -c` git commands
251+
// below (ls-remote/clone/checkout) — same command-injection vector as
252+
// `base` above (git permits shell metacharacters in ref names), so
253+
// validate before it ever reaches a shell string.
254+
if (!isValidRemoteBranchName(branch)) {
255+
deps.onChunk(
256+
"setup",
257+
`\r\n[clone] refusing invalid branch name ${JSON.stringify(branch)}\r\n`,
258+
);
259+
return { code: 1 };
260+
}
269261
const probe = await runNetworkStep(
270262
`${gc} ls-remote --exit-code --heads ${cloneUrl} ${branch}`,
271263
deps,

0 commit comments

Comments
 (0)