// Regression tests for git argument injection through ref names (SEC_REPORT #1).
// Run with `bun test`.
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { after, before, test } from "node:test";
const tmp = mkdtempSync(path.join(os.tmpdir(), "vectrum-git-test-"));
process.env.DATA_ROOT = path.join(tmp, "data");
const target = path.join(tmp, "PWNED");
const evilBranch = `--output=${target}`;
const sh = (cwd: string, ...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" });
let git: typeof import("./git");
let repo: string;
let work: string;
before(async () => {
git = await import("./git");
repo = path.join(tmp, "srv.git");
await git.initBareRepo(repo);
work = path.join(tmp, "work");
sh(tmp, "init", "-q", "-b", "main", work);
sh(work, "-c", "user.name=a", "-c", "user.email=a@x", "commit", "-q", "--allow-empty", "-m", "ssh-ed25519 AAAA attacker");
});
after(() => rmSync(tmp, { recursive: true, force: true }));
/** Pushes through spawnService, the way the SSH server runs receive-pack. */
function push(refspec: string) {
const wrapper = path.join(tmp, "receive-pack.ts");
writeFileSync(
wrapper,
`const { spawnService } = await import(${JSON.stringify(path.join(import.meta.dirname, "git.ts"))});
const child = spawnService("git-receive-pack", process.argv[2], { stateless: false });
process.stdin.pipe(child.stdin);
child.stdout.pipe(process.stdout);
child.stderr.pipe(process.stderr);
child.on("close", (code) => process.exit(code ?? 1));`,
);
return sh(work, "push", "--porcelain", `--receive-pack=${process.execPath} ${wrapper}`, repo, refspec);
}
test("pushes of dash-prefixed ref names are refused", () => {
push("HEAD:refs/heads/main");
const declined = (e: { stdout?: string }) => /pre-receive hook declined/.test(e.stdout ?? "");
assert.throws(() => push(`HEAD:refs/heads/${evilBranch}`), declined);
assert.throws(() => push("HEAD:refs/tags/-x"), declined);
assert.equal(sh(repo, "for-each-ref", "--format=%(refname)").trim(), "refs/heads/main");
});
test("dash-prefixed refs that already exist are ignored and never reach git as options", async () => {
// Simulate a branch pushed before the hook existed.
sh(repo, "update-ref", `refs/heads/${evilBranch}`, "refs/heads/main");
sh(repo, "symbolic-ref", "HEAD", `refs/heads/${evilBranch}`);
assert.deepEqual((await git.listBranches(repo)).map((b) => b.name), ["main"]);
assert.equal(await git.defaultBranch(repo), "main");
assert.equal(await git.resolveRefAndPath(repo, [evilBranch]), null);
await assert.rejects(git.log(repo, evilBranch), git.GitError);
await assert.rejects(git.commitCount(repo, evilBranch), git.GitError);
await assert.rejects(git.listContributors(repo, evilBranch), git.GitError);
await assert.rejects(git.listTree(repo, evilBranch, ""), git.GitError);
assert.equal(existsSync(target), false);
// Normal refs still work.
assert.equal((await git.log(repo, "main"))[0].subject, "ssh-ed25519 AAAA attacker");
assert.equal(await git.commitCount(repo, "main"), 1);
});
test("release tags can't be named like options", async () => {
const { createTag, isValidTagName } = await import("./git-write");
for (const bad of ["-x", "rel/-x", evilBranch, "a b", "a..b", ""]) assert.equal(await isValidTagName(repo, bad), false, bad);
assert.equal(await isValidTagName(repo, "v1.0.0"), true);
assert.equal(await isValidTagName(repo, "release/2026-10"), true);
const sha = (await git.revParse(repo, "refs/heads/main"))!;
await createTag(repo, "v1.0.0", sha);
assert.equal(await git.revParse(repo, "refs/tags/v1.0.0"), sha);
await assert.rejects(createTag(repo, "v1.0.0", sha), git.GitError);
// Archives take a resolved commit; a dash-prefixed revision is refused before git sees it.
assert.throws(() => git.spawnArchive(repo, `--output=${target}`, "zip", "x"), git.GitError);
assert.equal(existsSync(target), false);
});