Files
shockbot/utils/gitAuthServer.test.ts
Colin McDonnell 585a5d21cc fix(askpass): scope code + script lifetime to one $git() call (#841)
* fix(askpass): scope code + script lifetime to one $git() call, not first password prompt

LFS pre-push (and any auth-bound sibling subprocess) consumed the single-use
code AND triggered the script self-delete, so git's own push call then hit
`fatal: cannot exec '/tmp/pullfrog-…/askpass-…js'` and our server treated
the legitimate retry as tamper, revoking the installation token. Observed
on nteract/nteract#2987 (LFS repo).

`gitAuthServer` codes are now `active` until `$git()`'s finally calls
`revoke()`; the script no longer self-deletes (finally already unlinks).
Replay after revoke still trips 409 + token revocation, which is the
realistic exfiltration vector we care about.

* fix(askpass): drop wall-clock TTL on active codes

Copilot review on #841 noticed the 5-minute CODE_TTL_MS still applied to
active codes, which would re-introduce the original LFS failure mode at a
different boundary: a large LFS push lasting >5min would hit a 404 mid-
call. $git() uses `activityTimeout: 0` precisely because git fetch/push
can take arbitrarily long, so any wall-clock TTL on active codes is wrong.

Active codes now live until revoke() is called (in $git()'s finally) or
the auth server is closed. Revoked codes keep their 60s replay trap.

* docs(askpass): purge stale single-use vocabulary; align error message

Pullfrog review on #841 surfaced four doc-drift sites that still described
the pre-PR single-use model:

- wiki/security.md — 3 references (overview prose, bullets, threat-mitigation table)
- action/utils/gitAuth.ts — file-level JSDoc + the 409 error message
- wiki/askpass.md — error message quoted in the tamper-evident section
- action/utils/gitAuthServer.ts — per-prompt invocation comment was ambiguous

All updated to match the active|revoked vocabulary; error message is now
"askpass code was replayed after revoke, token revoked".
2026-05-26 18:27:40 +00:00

161 lines
5.3 KiB
TypeScript

import { existsSync, mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { type GitAuthServer, startGitAuthServer } from "./gitAuthServer.ts";
let server: GitAuthServer | undefined;
afterEach(async () => {
if (server) {
await server.close();
server = undefined;
}
});
function makeTmpdir(): string {
return mkdtempSync(join(tmpdir(), "askpass-test-"));
}
describe("git auth server lifecycle", () => {
it("starts and listens on a port", async () => {
const tmp = makeTmpdir();
server = await startGitAuthServer(tmp);
expect(server.port).toBeGreaterThan(0);
});
it("closes cleanly", async () => {
const tmp = makeTmpdir();
server = await startGitAuthServer(tmp);
const port = server.port;
await server.close();
server = undefined;
// port should no longer accept connections
const err = await fetch(`http://127.0.0.1:${port}/test`).catch((e) => e);
expect(err).toBeInstanceOf(Error);
});
});
describe("token delivery", () => {
it("returns token on first request with valid code", async () => {
const tmp = makeTmpdir();
server = await startGitAuthServer(tmp);
const code = server.register("ghs_test_token_12345");
const res = await fetch(`http://127.0.0.1:${server.port}/${code}`);
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toBe("ghs_test_token_12345");
});
it("returns 404 for unknown code", async () => {
const tmp = makeTmpdir();
server = await startGitAuthServer(tmp);
const res = await fetch(`http://127.0.0.1:${server.port}/nonexistent-code`);
expect(res.status).toBe(404);
});
it("returns 400 for empty code", async () => {
const tmp = makeTmpdir();
server = await startGitAuthServer(tmp);
const res = await fetch(`http://127.0.0.1:${server.port}/`);
expect(res.status).toBe(400);
});
it("returns 405 for non-GET methods", async () => {
const tmp = makeTmpdir();
server = await startGitAuthServer(tmp);
const code = server.register("token");
const res = await fetch(`http://127.0.0.1:${server.port}/${code}`, { method: "POST" });
expect(res.status).toBe(405);
});
});
describe("code lifecycle (tamper detection)", () => {
it("returns the token on repeated use while the code is active", async () => {
// a single $git() call can produce multiple legitimate askpass requests:
// git itself (username + password), git-lfs pre-push hook, custom hooks.
// they must all succeed until $git()'s finally calls revoke().
const tmp = makeTmpdir();
server = await startGitAuthServer(tmp);
const code = server.register("ghs_active_test");
for (let i = 0; i < 5; i++) {
const res = await fetch(`http://127.0.0.1:${server.port}/${code}`);
expect(res.status).toBe(200);
expect(await res.text()).toBe("ghs_active_test");
}
});
it("returns 409 after revoke (replay-after-call trap)", async () => {
const tmp = makeTmpdir();
server = await startGitAuthServer(tmp);
const code = server.register("ghs_tamper_test");
const first = await fetch(`http://127.0.0.1:${server.port}/${code}`);
expect(first.status).toBe(200);
server.revoke(code);
const replay = await fetch(`http://127.0.0.1:${server.port}/${code}`);
expect(replay.status).toBe(409);
expect(await replay.text()).toBe("compromised");
});
it("revoke() on an unknown code is a no-op", async () => {
const tmp = makeTmpdir();
server = await startGitAuthServer(tmp);
expect(() => server!.revoke("nonexistent")).not.toThrow();
});
it("each register() call produces an independent code", async () => {
const tmp = makeTmpdir();
server = await startGitAuthServer(tmp);
const code1 = server.register("token-a");
const code2 = server.register("token-b");
expect(code1).not.toBe(code2);
const res1 = await fetch(`http://127.0.0.1:${server.port}/${code1}`);
expect(await res1.text()).toBe("token-a");
const res2 = await fetch(`http://127.0.0.1:${server.port}/${code2}`);
expect(await res2.text()).toBe("token-b");
});
});
describe("askpass script generation", () => {
it("writes an executable script file", async () => {
const tmp = makeTmpdir();
server = await startGitAuthServer(tmp);
const code = server.register("ghs_script_test");
const scriptPath = server.writeAskpassScript(code);
expect(existsSync(scriptPath)).toBe(true);
expect(scriptPath.startsWith(tmp)).toBe(true);
const content = readFileSync(scriptPath, "utf-8");
expect(content).toContain("#!/usr/bin/env node");
expect(content).toContain(String(server.port));
expect(content).toContain(code);
// token should NOT be in the script — only port and code
expect(content).not.toContain("ghs_script_test");
});
it("script handles Username prompt locally (no server call)", async () => {
const tmp = makeTmpdir();
server = await startGitAuthServer(tmp);
const code = server.register("ghs_username_test");
const scriptPath = server.writeAskpassScript(code);
const content = readFileSync(scriptPath, "utf-8");
// script checks for /^Username/i and returns "x-access-token" without HTTP
expect(content).toContain("Username");
expect(content).toContain("x-access-token");
});
});