import { AsyncLocalStorage } from "node:async_hooks"; import { env } from "cloudflare:workers"; import { parsePack } from "../../git/pack"; import type { ParsedPack } from "../../git/pack"; import { concatBytes, FLUSH_PKT, packetText, parseAllPackets, pktLine } from "../../git/pktline"; import { limitStream, PackTooLargeError } from "../../git/ref-sync-protocol"; import { packFromResponse } from "../../git/snapshot"; import { guardedFetch } from "../../git/relay"; /** * Git smart-HTTP (protocol v2) against github.com, for resolving and downloading actions. Going through git * rather than the REST API or codeload tarballs matters twice: anonymous REST calls from shared egress IPs * hit GitHub's 60/hour limit at once, and git objects are content-addressed, so every downloaded object can * be checked against the SHA it is named by (a tarball can't be tied back to a commit). */ const ORIGIN = "https://github.com"; const DELIM_PKT = new TextEncoder().encode("0001"); const USER_AGENT = "git/gitfor-actions"; /** api.github.com answers an HTML 403 to authenticated requests whose User-Agent starts with `git/`. */ const API_USER_AGENT = "gitfor-actions"; /** GitHub answers 401 (not 404) for a repository that is private or doesn't exist. */ const MISSING_STATUSES = new Set([401, 403, 404]); const LS_REFS_TIMEOUT_MS = 20_000; const FETCH_TIMEOUT_MS = 60_000; const MAX_REF_BYTES = 8 * 1024 * 1024; const MAX_REACHABILITY_PACK_BYTES = 12 * 1024 * 1024; const MAX_REACHABILITY_TIPS = 200; const PARENT_LINE = /^parent ([0-9a-f]{40,64})$/gm; const decoder = new TextDecoder(); /** * Isolates have 128 MB, and the pack, its inflated objects, the tar and its gzip are all alive together while an * archive is built, so the budgets stay well under a quarter of that each. Most JavaScript actions are a few MB. */ export const MAX_ACTION_PACK_BYTES = 16 * 1024 * 1024; const ACTION_PACK_LIMITS = { maxObjects: 40_000, maxObjectBytes: 16 * 1024 * 1024, maxTotalBytes: 24 * 1024 * 1024 }; export class ActionSourceError extends Error {} /** GitHub refused for now (rate limit or outage), as opposed to the action not existing. */ export class GithubUnavailableError extends ActionSourceError {} const HTTP_UNAUTHORIZED = 401; const HTTP_TOO_MANY = 429; const RETRY_STATUSES = new Set([HTTP_TOO_MANY, 500, 502, 503, 504]); const MAX_ATTEMPTS = 3; const MAX_RETRY_DELAY_MS = 5_000; const PUBLIC_CHECK_SECONDS = 24 * 60 * 60; /** * The repository's GITHUB_COM_TOKEN while its run's actions are resolved and downloaded. Anonymous git requests * share GitHub's per-IP limits with every other Worker, so a token is what keeps resolution from failing with 429. */ const githubToken = new AsyncLocalStorage(); export function withGithubToken(token: string | null, fn: () => Promise): Promise { return githubToken.run(token, fn); } /** * A token can read private repositories, but action archives are shared by every account, so an action fetched * with one must be public; GitHub's API answers that with the same token (its own, much higher, limit). */ async function assertPublic(owner: string, repo: string, token: string): Promise { const key = `action-public:v1:${owner.toLowerCase()}/${repo.toLowerCase()}`; if ((await env.KV.get(key)) === "1") return; const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`, { headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json", "user-agent": API_USER_AGENT }, signal: AbortSignal.timeout(LS_REFS_TIMEOUT_MS), }); if (RETRY_STATUSES.has(response.status)) { await response.body?.cancel(); throw new GithubUnavailableError(`GitHub is busy (HTTP ${response.status}); retry the run shortly`); } if (response.status === HTTP_UNAUTHORIZED) { await response.body?.cancel(); throw new ActionSourceError("GitHub rejected the GITHUB_COM_TOKEN secret (expired or revoked?)"); } const body = response.ok ? ((await response.json()) as { private?: boolean }) : null; if (!body || body.private !== false) { throw new ActionSourceError(`github.com/${owner}/${repo} was not found or is private (only public actions run)`); } await env.KV.put(key, "1", { expirationTtl: PUBLIC_CHECK_SECONDS }); } function retryDelay(response: Response, attempt: number): number { const seconds = Number(response.headers.get("retry-after")); const wanted = Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : 500 * 2 ** attempt; return Math.min(wanted, MAX_RETRY_DELAY_MS); } export type GithubRef = { name: string; sha: string; peeled: string | null }; function remote(owner: string, repo: string): string { return `${ORIGIN}/${owner}/${repo}.git`; } /** * Every request goes through the relay's guarded fetch (public-https and DNS checks on every hop, the same * path imports use), with a fixed github.com URL built from validated owner/repo names. Upload-pack POSTs * never follow redirects, so a renamed repository must be referenced by its new name. */ async function githubFetch(url: string, init: RequestInit): Promise { try { return await guardedFetch({ url, authorization: null }, url, init); } catch (error) { if (error instanceof Error && /redirected/.test(error.message)) { throw new ActionSourceError("GitHub redirected the action (was the repository renamed? use its new name)"); } throw error; } } async function sendUploadPack( owner: string, repo: string, body: Uint8Array, timeoutMs: number, token: string | null, ): Promise { const headers: Record = { "git-protocol": "version=2", "content-type": "application/x-git-upload-pack-request", accept: "application/x-git-upload-pack-result", "user-agent": USER_AGENT, }; if (token) headers.authorization = `Basic ${btoa(`x-access-token:${token}`)}`; let response!: Response; for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { response = await githubFetch(`${remote(owner, repo)}/git-upload-pack`, { method: "POST", headers, body, signal: AbortSignal.timeout(timeoutMs), }); if (!RETRY_STATUSES.has(response.status) || attempt === MAX_ATTEMPTS - 1) break; await response.body?.cancel(); await new Promise((resolve) => setTimeout(resolve, retryDelay(response, attempt))); } return response; } /** * The token only ever helps: if GitHub won't take it (expired, revoked, a fine-grained token scoped elsewhere), * the request is repeated anonymously, which can only reach public repositories anyway. */ async function usableToken(owner: string, repo: string): Promise { const token = githubToken.getStore() ?? null; if (!token) return null; try { await assertPublic(owner, repo, token); return token; } catch (error) { if (error instanceof GithubUnavailableError) throw error; console.warn("GITHUB_COM_TOKEN not usable for action fetch", `${owner}/${repo}`, String(error)); return null; } } async function uploadPack( owner: string, repo: string, body: Uint8Array, timeoutMs: number, ): Promise { const token = await usableToken(owner, repo); let response = await sendUploadPack(owner, repo, body, timeoutMs, token); if (token && MISSING_STATUSES.has(response.status)) { console.warn("GitHub refused GITHUB_COM_TOKEN for git", `${owner}/${repo}`, response.status); await response.body?.cancel(); response = await sendUploadPack(owner, repo, body, timeoutMs, null); } if (RETRY_STATUSES.has(response.status)) { await response.body?.cancel(); throw new GithubUnavailableError( response.status === HTTP_TOO_MANY ? `GitHub is rate-limiting requests for github.com/${owner}/${repo}${token ? "" : "; add a GITHUB_COM_TOKEN secret so the forge can authenticate"}, or retry the run shortly` : `GitHub is unavailable (HTTP ${response.status}) for github.com/${owner}/${repo}; retry the run shortly`, ); } if (MISSING_STATUSES.has(response.status)) { await response.body?.cancel(); throw new ActionSourceError(`github.com/${owner}/${repo} was not found or is private (only public actions run)`); } if (!response.ok || !response.body) { await response.body?.cancel(); throw new ActionSourceError(`github.com/${owner}/${repo} refused the request (HTTP ${response.status})`); } return response; } async function readLimited(response: Response, maxBytes: number): Promise { if (!response.body) return new Uint8Array(0); return new Uint8Array(await new Response(limitStream(response.body, maxBytes)).arrayBuffer()); } /** Parses `ls-refs` lines: ` [ symref-target:][ peeled:]`. */ export function parseGithubRefs(body: Uint8Array): GithubRef[] { const refs: GithubRef[] = []; for (const packet of parseAllPackets(body)) { const line = packetText(packet); if (line === null) continue; if (line.startsWith("ERR ")) throw new ActionSourceError(`GitHub: ${line.slice(4)}`); const [sha, name, ...attributes] = line.split(" "); if (!sha || !name || !/^[0-9a-f]{40,64}$/.test(sha)) continue; const peeled = attributes.find((attribute) => attribute.startsWith("peeled:"))?.slice("peeled:".length) ?? null; refs.push({ name, sha, peeled }); } return refs; } export async function listGithubRefs(owner: string, repo: string, prefixes: string[]): Promise { const body = concatBytes([ pktLine("command=ls-refs\n"), pktLine(`agent=${USER_AGENT}\n`), DELIM_PKT, pktLine("peel\n"), ...prefixes.map((prefix) => pktLine(`ref-prefix ${prefix}\n`)), FLUSH_PKT, ]); const response = await uploadPack(owner, repo, body, LS_REFS_TIMEOUT_MS); try { return parseGithubRefs(await readLimited(response, MAX_REF_BYTES)); } catch (error) { if (error instanceof PackTooLargeError) throw new ActionSourceError(`github.com/${owner}/${repo} has too many refs`); throw error; } } /** The commit a ref names, preferring a tag over a branch of the same name (git's own rev-parse order). */ export function pickRef(refs: GithubRef[], ref: string): string | null { const tag = refs.find((candidate) => candidate.name === `refs/tags/${ref}`); if (tag) return tag.peeled ?? tag.sha; return refs.find((candidate) => candidate.name === `refs/heads/${ref}`)?.sha ?? null; } export async function resolveGithubRef(owner: string, repo: string, ref: string): Promise { const refs = await listGithubRefs(owner, repo, [`refs/tags/${ref}`, `refs/heads/${ref}`]); const sha = pickRef(refs, ref); if (!sha) throw new ActionSourceError(`github.com/${owner}/${repo} has no tag or branch named '${ref}'`); return sha; } function parentsOf(content: Uint8Array): string[] { const header = decoder.decode(content.subarray(0, Math.min(content.byteLength, 64 * 1024))).split("\n\n")[0]; return [...header.matchAll(PARENT_LINE)].map((match) => match[1]); } /** Whether `sha` is an ancestor of one of the commits in `pack` (a pack of `tips` minus history of `sha`). */ export function namesAsParent(parsed: ParsedPack, sha: string): boolean { return parsed.objects.some((object) => object.type === "commit" && parentsOf(object.content).includes(sha)); } /** * Whether `sha` is reachable from a branch or tag of owner/repo. GitHub serves any object in a fork network * from every repository in it, so without this check `owner/repo@` could run a commit that was only * ever pushed to an attacker's fork. Tips are checked directly (most pins are release tags); otherwise one * commits-only fetch (`filter tree:0`) of every tip with `have ` returns exactly the commits reachable * from the tips but not from `sha`, so `sha` is reachable iff one of them names it as a parent. Fails * closed: an answer that can't be computed within budget is an error, not a pass. */ export async function isReachableFromRefs(owner: string, repo: string, sha: string): Promise { const refs = await listGithubRefs(owner, repo, ["refs/heads/", "refs/tags/"]); const tips = new Set(); for (const ref of refs) { if (ref.sha === sha || ref.peeled === sha) return true; tips.add(ref.peeled ?? ref.sha); } if (tips.size === 0) return false; const heads = refs.filter((ref) => ref.name.startsWith("refs/heads/")).map((ref) => ref.sha); const candidates = [...new Set([...heads, ...tips])]; const wants = candidates.slice(0, MAX_REACHABILITY_TIPS); const body = concatBytes([ pktLine("command=fetch\n"), pktLine(`agent=${USER_AGENT}\n`), DELIM_PKT, pktLine("no-progress\n"), pktLine("filter tree:0\n"), ...wants.map((want) => pktLine(`want ${want}\n`)), pktLine(`have ${sha}\n`), pktLine("done\n"), FLUSH_PKT, ]); const response = await uploadPack(owner, repo, body, FETCH_TIMEOUT_MS); let bytes: Uint8Array; try { bytes = await readLimited(response, MAX_REACHABILITY_PACK_BYTES); } catch (error) { if (error instanceof PackTooLargeError) { throw new ActionSourceError( `couldn't verify that ${sha} belongs to github.com/${owner}/${repo} (history too large); pin a tagged commit`, ); } throw error; } const parsed = await parsePack(packFromResponse(bytes), { maxObjects: 200_000, maxObjectBytes: 1024 * 1024, maxTotalBytes: 32 * 1024 * 1024, }); if (parsed.truncated || parsed.unresolved.length > 0) { throw new ActionSourceError(`couldn't verify that ${sha} belongs to github.com/${owner}/${repo}`); } if (namesAsParent(parsed, sha)) return true; if (candidates.length > wants.length) { throw new ActionSourceError( `couldn't verify that ${sha} belongs to github.com/${owner}/${repo} (too many branches and tags); pin a tagged commit`, ); } return false; } /** Every object of one commit (its tree and blobs, no history), each verified against its SHA by parsePack. */ export async function fetchCommitObjects(owner: string, repo: string, sha: string): Promise { const body = concatBytes([ pktLine("command=fetch\n"), pktLine(`agent=${USER_AGENT}\n`), DELIM_PKT, pktLine("no-progress\n"), pktLine(`want ${sha}\n`), pktLine("deepen 1\n"), pktLine("done\n"), FLUSH_PKT, ]); const response = await uploadPack(owner, repo, body, FETCH_TIMEOUT_MS); let bytes: Uint8Array; try { bytes = await readLimited(response, MAX_ACTION_PACK_BYTES); } catch (error) { if (error instanceof PackTooLargeError) { throw new ActionSourceError(`github.com/${owner}/${repo} is too large to use as an action`); } throw error; } const parsed = await parsePack(packFromResponse(bytes), ACTION_PACK_LIMITS); if (parsed.truncated || parsed.unresolved.length > 0) { throw new ActionSourceError(`github.com/${owner}/${repo}@${sha} is too large to use as an action`); } return parsed; }