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".
This commit is contained in:
committed by
pullfrog[bot]
parent
fe2746198c
commit
585a5d21cc
+19
-9
@@ -1,9 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* git authentication via GIT_ASKPASS.
|
* git authentication via GIT_ASKPASS.
|
||||||
*
|
*
|
||||||
* a localhost HTTP server serves tokens via single-use UUID codes.
|
* a localhost HTTP server serves tokens via UUID codes whose lifetime is
|
||||||
* each $git() call writes a unique askpass script with the server
|
* bounded by the parent $git() invocation: register() makes the code active,
|
||||||
* port+code baked into the file body — no secrets in subprocess env.
|
* the script (and any sibling subprocess — e.g. git-lfs pre-push) can fetch
|
||||||
|
* the token any number of times, and $git()'s finally calls revoke() to
|
||||||
|
* close the window. each $git() call writes a unique askpass script with
|
||||||
|
* the server port+code baked into the file body — no secrets in subprocess
|
||||||
|
* env. a replay of a revoked code trips a 409 and revokes the underlying
|
||||||
|
* github installation token.
|
||||||
*
|
*
|
||||||
* see wiki/askpass.md for full security documentation.
|
* see wiki/askpass.md for full security documentation.
|
||||||
*/
|
*/
|
||||||
@@ -88,9 +93,13 @@ export function setGitAuthServer(server: GitAuthServer): void {
|
|||||||
* a remote and need credentials. working-tree operations (checkout, merge)
|
* a remote and need credentials. working-tree operations (checkout, merge)
|
||||||
* use $() from shell.ts which has no token.
|
* use $() from shell.ts which has no token.
|
||||||
*
|
*
|
||||||
* per call: registers a one-time code with the auth server, writes a
|
* per call: registers a code with the auth server (valid for the lifetime
|
||||||
* unique askpass script with port+code baked in, spawns git with
|
* of this invocation), writes a unique askpass script with port+code baked
|
||||||
* GIT_ASKPASS pointing to the script, and deletes the script in finally.
|
* in, spawns git with GIT_ASKPASS pointing to the script. on completion,
|
||||||
|
* revokes the code and deletes the script in finally. multiple sibling
|
||||||
|
* askpass calls within one invocation (e.g. git itself + git-lfs pre-push)
|
||||||
|
* all see a valid code; replay attempts after finally trip a 409 and the
|
||||||
|
* server revokes the underlying github token as a tamper signal.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* await $git("fetch", ["origin", "main"], { token });
|
* await $git("fetch", ["origin", "main"], { token });
|
||||||
@@ -149,8 +158,8 @@ export async function $git(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (result.stderr.includes("askpass-compromised")) {
|
if (result.stderr.includes("askpass-compromised")) {
|
||||||
log.info("askpass code was already consumed — token has been revoked");
|
log.info("askpass code was replayed after revoke — token has been revoked");
|
||||||
throw new Error("git auth failed — askpass code was already consumed, token revoked");
|
throw new Error("git auth failed — askpass code was replayed after revoke, token revoked");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.exitCode !== 0) {
|
if (result.exitCode !== 0) {
|
||||||
@@ -175,10 +184,11 @@ export async function $git(
|
|||||||
stderr: result.stderr.trim(),
|
stderr: result.stderr.trim(),
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
|
authServer.revoke(code);
|
||||||
try {
|
try {
|
||||||
unlinkSync(scriptPath);
|
unlinkSync(scriptPath);
|
||||||
} catch {
|
} catch {
|
||||||
// script may have self-deleted already
|
// script may already be gone (e.g. tmpdir cleanup raced us)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,8 +75,23 @@ describe("token delivery", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("single-use enforcement (tamper detection)", () => {
|
describe("code lifecycle (tamper detection)", () => {
|
||||||
it("returns 409 on second use of same code", async () => {
|
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();
|
const tmp = makeTmpdir();
|
||||||
server = await startGitAuthServer(tmp);
|
server = await startGitAuthServer(tmp);
|
||||||
const code = server.register("ghs_tamper_test");
|
const code = server.register("ghs_tamper_test");
|
||||||
@@ -84,10 +99,17 @@ describe("single-use enforcement (tamper detection)", () => {
|
|||||||
const first = await fetch(`http://127.0.0.1:${server.port}/${code}`);
|
const first = await fetch(`http://127.0.0.1:${server.port}/${code}`);
|
||||||
expect(first.status).toBe(200);
|
expect(first.status).toBe(200);
|
||||||
|
|
||||||
const second = await fetch(`http://127.0.0.1:${server.port}/${code}`);
|
server.revoke(code);
|
||||||
expect(second.status).toBe(409);
|
|
||||||
const body = await second.text();
|
const replay = await fetch(`http://127.0.0.1:${server.port}/${code}`);
|
||||||
expect(body).toBe("compromised");
|
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 () => {
|
it("each register() call produces an independent code", async () => {
|
||||||
|
|||||||
+50
-33
@@ -1,12 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* ASKPASS-based git authentication server.
|
* ASKPASS-based git authentication server.
|
||||||
*
|
*
|
||||||
* serves tokens via a localhost HTTP server with single-use UUID codes.
|
* serves tokens via a localhost HTTP server with per-$git()-call UUID codes.
|
||||||
* each $git() call gets a unique askpass script with the port+code baked in.
|
* each $git() call gets a unique askpass script with the port+code baked in.
|
||||||
* the token never appears in subprocess env — only the script file path.
|
* the token never appears in subprocess env — only the script file path.
|
||||||
*
|
*
|
||||||
* tamper-evident: if a code is used twice, the second request triggers
|
* lifetime: the code is valid for as long as the $git() invocation is
|
||||||
* immediate token revocation via the GitHub API as a precaution.
|
* running. multiple askpass calls within one invocation (e.g. git's own
|
||||||
|
* fetch/push + a git-lfs pre-push hook that also authenticates) all
|
||||||
|
* succeed. $git() calls revoke(code) in finally; subsequent requests for
|
||||||
|
* a revoked code trigger immediate token revocation via the GitHub API
|
||||||
|
* as a tamper-evidence precaution (an agent replaying the code after the
|
||||||
|
* legitimate window has closed is the realistic attack we still catch).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
@@ -15,20 +20,25 @@ import { createServer } from "node:http";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { log } from "./cli.ts";
|
import { log } from "./cli.ts";
|
||||||
|
|
||||||
type CodeState = "pending" | "consumed";
|
type CodeState = "active" | "revoked";
|
||||||
|
|
||||||
type PendingCode = {
|
type CodeEntry = {
|
||||||
token: string;
|
token: string;
|
||||||
state: CodeState;
|
state: CodeState;
|
||||||
timeout: NodeJS.Timeout;
|
// only present once the entry is revoked — bounds the replay-trap window.
|
||||||
|
// active entries have no timer because $git() can take arbitrarily long
|
||||||
|
// (large LFS pushes, slow networks, `activityTimeout: 0` on the spawn);
|
||||||
|
// any wall-clock TTL here would re-introduce the original LFS bug at
|
||||||
|
// a different boundary. revoke() is the only way out for an active code.
|
||||||
|
timeout?: NodeJS.Timeout;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CODE_TTL_MS = 5 * 60 * 1000;
|
const REVOKED_TRAP_MS = 60_000;
|
||||||
const TAMPER_WINDOW_MS = 60_000;
|
|
||||||
|
|
||||||
export type GitAuthServer = {
|
export type GitAuthServer = {
|
||||||
port: number;
|
port: number;
|
||||||
register: (token: string) => string;
|
register: (token: string) => string;
|
||||||
|
revoke: (code: string) => void;
|
||||||
writeAskpassScript: (code: string) => string;
|
writeAskpassScript: (code: string) => string;
|
||||||
close: () => Promise<void>;
|
close: () => Promise<void>;
|
||||||
[Symbol.asyncDispose]: () => Promise<void>;
|
[Symbol.asyncDispose]: () => Promise<void>;
|
||||||
@@ -49,7 +59,7 @@ function revokeGitHubToken(token: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function startGitAuthServer(tmpdir: string): Promise<GitAuthServer> {
|
export async function startGitAuthServer(tmpdir: string): Promise<GitAuthServer> {
|
||||||
const codes = new Map<string, PendingCode>();
|
const codes = new Map<string, CodeEntry>();
|
||||||
|
|
||||||
const server = createServer((req, res) => {
|
const server = createServer((req, res) => {
|
||||||
if (req.method !== "GET") {
|
if (req.method !== "GET") {
|
||||||
@@ -69,21 +79,20 @@ export async function startGitAuthServer(tmpdir: string): Promise<GitAuthServer>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.state === "pending") {
|
if (entry.state === "active") {
|
||||||
// first use — return token, keep entry for tamper detection
|
// legitimate caller (git, git-lfs, or any subprocess of the running
|
||||||
entry.state = "consumed";
|
// $git() call). hand back the token without consuming the code —
|
||||||
clearTimeout(entry.timeout);
|
// revoke() in $git's finally is what closes the window.
|
||||||
entry.timeout = setTimeout(() => codes.delete(code), TAMPER_WINDOW_MS);
|
|
||||||
entry.timeout.unref();
|
|
||||||
res.writeHead(200, { "Content-Type": "text/plain" });
|
res.writeHead(200, { "Content-Type": "text/plain" });
|
||||||
res.end(entry.token);
|
res.end(entry.token);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// second request for same code — revoke token as a precaution
|
// request for a revoked code — the $git() window has closed, so this
|
||||||
log.info("askpass code used twice — revoking token");
|
// is an agent replaying the code. revoke the token as a precaution.
|
||||||
|
log.info("askpass code used after revoke — revoking token");
|
||||||
revokeGitHubToken(entry.token);
|
revokeGitHubToken(entry.token);
|
||||||
clearTimeout(entry.timeout);
|
if (entry.timeout) clearTimeout(entry.timeout);
|
||||||
codes.delete(code);
|
codes.delete(code);
|
||||||
res.writeHead(409, { "Content-Type": "text/plain" });
|
res.writeHead(409, { "Content-Type": "text/plain" });
|
||||||
res.end("compromised");
|
res.end("compromised");
|
||||||
@@ -104,25 +113,34 @@ export async function startGitAuthServer(tmpdir: string): Promise<GitAuthServer>
|
|||||||
|
|
||||||
function register(token: string): string {
|
function register(token: string): string {
|
||||||
const code = randomUUID();
|
const code = randomUUID();
|
||||||
const timeout = setTimeout(() => {
|
codes.set(code, { token, state: "active" });
|
||||||
codes.delete(code);
|
|
||||||
log.debug(`git auth code expired: ${code.slice(0, 8)}...`);
|
|
||||||
}, CODE_TTL_MS);
|
|
||||||
timeout.unref();
|
|
||||||
codes.set(code, { token, state: "pending", timeout });
|
|
||||||
return code;
|
return code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function revoke(code: string): void {
|
||||||
|
const entry = codes.get(code);
|
||||||
|
if (!entry) return;
|
||||||
|
entry.state = "revoked";
|
||||||
|
// keep the entry around briefly so a replay attempt trips the trap
|
||||||
|
// (token revocation) instead of returning an opaque 404.
|
||||||
|
entry.timeout = setTimeout(() => codes.delete(code), REVOKED_TRAP_MS);
|
||||||
|
entry.timeout.unref();
|
||||||
|
}
|
||||||
|
|
||||||
function writeAskpassScript(code: string): string {
|
function writeAskpassScript(code: string): string {
|
||||||
const scriptId = randomUUID();
|
const scriptId = randomUUID();
|
||||||
const scriptName = `askpass-${scriptId}.js`;
|
const scriptName = `askpass-${scriptId}.js`;
|
||||||
const scriptPath = join(tmpdir, scriptName);
|
const scriptPath = join(tmpdir, scriptName);
|
||||||
|
|
||||||
// standalone node script — no project dependencies.
|
// standalone node script — no project dependencies.
|
||||||
// git calls this twice: once for "Username for ..." and once for "Password for ...".
|
// git invokes this once per credential prompt — separate process spawn
|
||||||
// username: return "x-access-token" locally (no server call).
|
// per prompt: one for "Username for ...", one for "Password for ...".
|
||||||
// password: fetch token from auth server, self-delete, return token.
|
// sibling subprocesses (git-lfs pre-push, custom auth-bound hooks)
|
||||||
// 409 = code was already consumed by another process (tamper detected).
|
// invoke it independently for their own auth, also one spawn per prompt.
|
||||||
|
// all succeed as long as the parent $git() is still running, which is
|
||||||
|
// why neither the script nor the code is single-use. cleanup happens
|
||||||
|
// in $git()'s finally.
|
||||||
|
// 409 = code was already revoked by $git()'s finally (replay attempt).
|
||||||
const content = [
|
const content = [
|
||||||
`#!/usr/bin/env node`,
|
`#!/usr/bin/env node`,
|
||||||
`var a=process.argv[2]||"";`,
|
`var a=process.argv[2]||"";`,
|
||||||
@@ -132,10 +150,8 @@ export async function startGitAuthServer(tmpdir: string): Promise<GitAuthServer>
|
|||||||
`if(r.statusCode===409){process.stderr.write("askpass-compromised\\n");process.exit(1)}`,
|
`if(r.statusCode===409){process.stderr.write("askpass-compromised\\n");process.exit(1)}`,
|
||||||
`if(r.statusCode!==200){process.exit(1)}`,
|
`if(r.statusCode!==200){process.exit(1)}`,
|
||||||
`var d="";r.on("data",function(c){d+=c});`,
|
`var d="";r.on("data",function(c){d+=c});`,
|
||||||
`r.on("end",function(){`,
|
`r.on("end",function(){process.stdout.write(d+"\\n")})`,
|
||||||
`process.stdout.write(d+"\\n");`,
|
`}).on("error",function(){process.exit(1)})}`,
|
||||||
`try{require("fs").unlinkSync("${scriptPath.replace(/\\/g, "\\\\")}")}catch(e){}`,
|
|
||||||
`})}).on("error",function(){process.exit(1)})}`,
|
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
writeFileSync(scriptPath, content, { mode: 0o700 });
|
writeFileSync(scriptPath, content, { mode: 0o700 });
|
||||||
@@ -144,7 +160,7 @@ export async function startGitAuthServer(tmpdir: string): Promise<GitAuthServer>
|
|||||||
|
|
||||||
async function close(): Promise<void> {
|
async function close(): Promise<void> {
|
||||||
for (const entry of codes.values()) {
|
for (const entry of codes.values()) {
|
||||||
clearTimeout(entry.timeout);
|
if (entry.timeout) clearTimeout(entry.timeout);
|
||||||
}
|
}
|
||||||
codes.clear();
|
codes.clear();
|
||||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
@@ -154,6 +170,7 @@ export async function startGitAuthServer(tmpdir: string): Promise<GitAuthServer>
|
|||||||
return {
|
return {
|
||||||
port,
|
port,
|
||||||
register,
|
register,
|
||||||
|
revoke,
|
||||||
writeAskpassScript,
|
writeAskpassScript,
|
||||||
close,
|
close,
|
||||||
[Symbol.asyncDispose]: close,
|
[Symbol.asyncDispose]: close,
|
||||||
|
|||||||
Reference in New Issue
Block a user