Vectrum
Sign In
73 lines · 3.2 KB · TypeScript
// 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);
});