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:
Colin McDonnell
2026-05-26 18:27:40 +00:00
committed by pullfrog[bot]
parent fe2746198c
commit 585a5d21cc
3 changed files with 97 additions and 48 deletions
+19 -9
View File
@@ -1,9 +1,14 @@
/**
* git authentication via GIT_ASKPASS.
*
* a localhost HTTP server serves tokens via single-use UUID codes.
* each $git() call writes a unique askpass script with the server
* port+code baked into the file body — no secrets in subprocess env.
* a localhost HTTP server serves tokens via UUID codes whose lifetime is
* bounded by the parent $git() invocation: register() makes the code active,
* 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.
*/
@@ -88,9 +93,13 @@ export function setGitAuthServer(server: GitAuthServer): void {
* a remote and need credentials. working-tree operations (checkout, merge)
* use $() from shell.ts which has no token.
*
* per call: registers a one-time code with the auth server, writes a
* unique askpass script with port+code baked in, spawns git with
* GIT_ASKPASS pointing to the script, and deletes the script in finally.
* per call: registers a code with the auth server (valid for the lifetime
* of this invocation), writes a unique askpass script with port+code baked
* 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
* await $git("fetch", ["origin", "main"], { token });
@@ -149,8 +158,8 @@ export async function $git(
});
if (result.stderr.includes("askpass-compromised")) {
log.info("askpass code was already consumed — token has been revoked");
throw new Error("git auth failed — askpass code was already consumed, token revoked");
log.info("askpass code was replayed after revoke — token has been revoked");
throw new Error("git auth failed — askpass code was replayed after revoke, token revoked");
}
if (result.exitCode !== 0) {
@@ -175,10 +184,11 @@ export async function $git(
stderr: result.stderr.trim(),
};
} finally {
authServer.revoke(code);
try {
unlinkSync(scriptPath);
} catch {
// script may have self-deleted already
// script may already be gone (e.g. tmpdir cleanup raced us)
}
}
}