// 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); });