import { jobTokenAccess, jobTokenRepoPermission, markJobTokenPush } from "../ci/job-token"; import { enforceRateLimit, requestIp } from "../limits"; import { HttpError } from "../errors"; import { hasPermission, loadRepo } from "../repos"; import { scopedRepoPermission, tokenCredential } from "../token-scope"; import { directUpdateViolation, forcePushViolation, loadProtections, protectionFor, tagProtectionFor, } from "../protection"; import { findForcePushes } from "./force-push"; import type { BranchProtection } from "../protection"; import { triggerPush } from "../events/push-trigger"; import { isHiddenRef } from "./hidden-refs"; import { filterAdvertisement, filterLsRefs, inspectUploadRequest, MalformedUploadRequestError, } from "./advertise-filter"; import { scanPushBody } from "../security/push-protection"; import { basicAuthHeader, repoTokenSecret } from "./artifacts"; import { concatBytes, packetText, parseAllPackets, pktLine, readUntilFlush, reportStatusResponse } from "./pktline"; import { invalidateRefs } from "./refs"; import { ZERO_SHA } from "./store"; import { scratchGitPermission, scratchPushPolicy } from "../sites/scratch-git"; import type { Permission, RepoRow, Viewer } from "~/lib/types"; import { randomId } from "../crypto"; import { countingBody, haveDetector, meterGitOp } from "../metering/git"; const GIT_PATH = /^\/([^/]+)\/([^/]+?)(?:\.git)?\/(info\/refs|git-upload-pack|git-receive-pack)$/; const MAX_COMMAND_BYTES = 1024 * 1024; const MAX_PUSH_COMMANDS = 1000; const MAX_TRIGGERED_REFS = 100; const FORWARDED_REQUEST_HEADERS = ["content-type", "accept", "content-encoding", "git-protocol", "user-agent"]; const FORWARDED_RESPONSE_HEADERS = ["content-type", "cache-control", "expires", "pragma"]; const SIDEBAND_DATA = 1; const SHA_PATTERN = /^[0-9a-f]{40}$/; /** git's `probe_rpc` for large pushes (http.postBuffer exceeded): a lone flush packet, sent before the real body. */ const PROBE_BODY = "0000"; const decoder = new TextDecoder(); type GitService = "git-upload-pack" | "git-receive-pack"; type GitRoute = { owner: string; repo: string; action: "info/refs" | GitService }; type Command = { oldSha: string; newSha: string; ref: string }; export function matchGitRoute(url: URL): GitRoute | null { const match = GIT_PATH.exec(url.pathname); if (!match) return null; const [, owner, repo, action] = match; if (action !== "info/refs" && action !== "git-upload-pack" && action !== "git-receive-pack") return null; return { owner, repo, action }; } function challenge(message: string): Response { return new Response(`${message}\n`, { status: 401, headers: { "www-authenticate": 'Basic realm="gitfor"', "content-type": "text/plain" }, }); } /** A v0 upload-pack `ERR` packet, which git prints as "remote error: …" instead of a confusing protocol failure. */ function uploadPackError(message: string): Response { return new Response(pktLine(`ERR ${message}\n`), { headers: { "content-type": "application/x-git-upload-pack-result", "cache-control": "no-cache" }, }); } function textResponse(status: number, message: string): Response { return new Response(`${message}\n`, { status, headers: { "content-type": "text/plain" } }); } function serviceFor(route: GitRoute, url: URL): GitService | null { if (route.action !== "info/refs") return route.action; const service = url.searchParams.get("service"); return service === "git-upload-pack" || service === "git-receive-pack" ? service : null; } function parseCommands(lines: string[]): { commands: Command[]; capabilities: string[] } { const commands: Command[] = []; let capabilities: string[] = []; for (const [index, line] of lines.entries()) { const [commandPart, caps] = line.split("\0"); if (index === 0 && caps) capabilities = caps.trim().split(" "); const [oldSha, newSha, ref] = commandPart.trim().split(" "); if (oldSha && newSha && ref && SHA_PATTERN.test(oldSha) && SHA_PATTERN.test(newSha)) { commands.push({ oldSha, newSha, ref }); } } return { commands, capabilities }; } /** The rule decisions for a push, without I/O: immediate violations, and updates that must be fast-forwards. */ export function protectionVerdicts( protections: BranchProtection[], permission: Permission, commands: Command[], ): { violations: Map; needsFastForward: Command[] } { const violations = new Map(); const needsFastForward: Command[] = []; for (const command of commands) { const isTag = command.ref.startsWith("refs/tags/"); if (!isTag && !command.ref.startsWith("refs/heads/")) continue; const protection = isTag ? tagProtectionFor(protections, command.ref) : protectionFor(protections, command.ref.slice("refs/heads/".length)); const isDeletion = command.newSha === ZERO_SHA; const isUpdate = command.oldSha !== ZERO_SHA && !isDeletion; const violation = isTag ? isDeletion ? directUpdateViolation(protection, permission, true) : isUpdate ? forcePushViolation(protection, permission) : null : directUpdateViolation(protection, permission, isDeletion); if (violation) violations.set(command.ref, violation); else if (!isTag && isUpdate && forcePushViolation(protection, permission)) needsFastForward.push(command); } return { violations, needsFastForward }; } /** Branch-protection and hidden-ref checks for a receive-pack, shared with mirrors that write into a repo. */ export async function checkProtections( repo: RepoRow, permission: Permission, commands: Command[], body: ReadableStream, gzip: boolean, ): Promise<{ violations: Map; body: ReadableStream }> { const hidden = new Map(); for (const command of commands) { if (isHiddenRef(command.ref)) hidden.set(command.ref, "refs/pull and refs/gitfor are managed by gitfor"); } if (hidden.size > 0) return { violations: hidden, body }; const protections = await loadProtections(repo.id); if (protections.length === 0) return { violations: hidden, body }; const { violations, needsFastForward } = protectionVerdicts(protections, permission, commands); if (violations.size > 0 || needsFastForward.length === 0) return { violations, body }; const checked = await findForcePushes(repo, needsFastForward, body, gzip, permission === "admin"); for (const [ref, reason] of checked.rejected) violations.set(ref, reason); return { violations, body: checked.body }; } function rejectPush( commands: Command[], violations: Map, capabilities: string[], message = "gitfor: push rejected by branch protection", ): Response { if (!capabilities.includes("report-status")) { return textResponse(403, [message, ...violations.values()].join("\n")); } const useSideband = capabilities.includes("side-band-64k") || capabilities.includes("side-band"); const results = commands.map((command) => ({ ref: command.ref, error: violations.get(command.ref) ?? "atomic push failed: another ref was rejected", })); return new Response(reportStatusResponse(results, useSideband, message), { headers: { "content-type": "application/x-git-receive-pack-result", "cache-control": "no-cache" }, }); } /** Extracts `ok ` results from a receive-pack response that may be wrapped in side-band packets. */ function successfulRefs(body: Uint8Array): Set { const ok = new Set(); const collect = (packets: ReturnType) => { for (const packet of packets) { if (packet.kind !== "data") continue; if (packet.data[0] === SIDEBAND_DATA) { collect(parseAllPackets(packet.data.subarray(1))); continue; } const line = packetText(packet); if (line?.startsWith("ok ")) ok.add(line.slice(3).trim()); } }; collect(parseAllPackets(body)); return ok; } function observeReceivePack( response: Response, onComplete: (report: Uint8Array) => Promise, ctx: ExecutionContext, ): Response { const chunks: Uint8Array[] = []; let finish: (() => void) | undefined; const done = new Promise((resolve) => { finish = resolve; }); const transform = new TransformStream({ transform(chunk, controller) { chunks.push(chunk); controller.enqueue(chunk); }, flush() { finish?.(); }, }); ctx.waitUntil(done.then(() => onComplete(concatBytes(chunks)))); return new Response(response.body?.pipeThrough(transform) ?? null, { status: response.status, headers: copyHeaders(response.headers, FORWARDED_RESPONSE_HEADERS), }); } function copyHeaders(source: Headers, names: string[]): Headers { const headers = new Headers(); for (const name of names) { const value = source.get(name); if (value) headers.set(name, value); } return headers; } async function authorize( request: Request, repo: RepoRow | null, needed: Permission, ): Promise<{ viewer: Viewer | null; permission: Permission; jobToken?: boolean } | Response> { const scratch = await scratchGitPermission(request, repo); if (scratch === "write") return { viewer: null, permission: scratch }; if (scratch === "none") return challenge("Invalid or expired scratch push token"); const jobAccess = await jobTokenAccess(request); if (jobAccess) { const permission = repo ? jobTokenRepoPermission(jobAccess, repo.id) : "none"; if (hasPermission(permission, needed)) return { viewer: null, permission, jobToken: true }; return permission === "none" ? textResponse(404, "Repository not found") : textResponse(403, "Permission denied"); } const credential = (await tokenCredential(request)) ?? { viewer: null, grant: null }; const { viewer } = credential; const authenticated = viewer !== null || credential.grant !== null; if (!repo) return authenticated ? textResponse(404, "Repository not found") : challenge("Authentication required"); const permission = await scopedRepoPermission(repo, credential, "contents"); if (hasPermission(permission, needed)) return { viewer, permission }; if (!authenticated) return challenge("Authentication required"); return permission === "none" ? textResponse(404, "Repository not found") : textResponse(403, "Permission denied"); } export async function handleGitRequest(request: Request, route: GitRoute, ctx: ExecutionContext): Promise { const url = new URL(request.url); const service = serviceFor(route, url); if (!service) return textResponse(400, "Unsupported git service"); const isInfoRefs = route.action === "info/refs"; if (isInfoRefs ? request.method !== "GET" : request.method !== "POST") { return textResponse(405, "Method not allowed"); } const repo = await loadRepo(route.owner, route.repo); const needed: Permission = service === "git-receive-pack" ? "write" : "read"; const auth = await authorize(request, repo, needed); if (auth instanceof Response) return auth; try { await enforceRateLimit("GIT_LIMITER", auth.viewer ? `git:user:${auth.viewer.id}` : `git:ip:${requestIp(request)}`); } catch (error) { if (error instanceof HttpError) return textResponse(error.status, error.message); throw error; } if (!repo) return textResponse(404, "Repository not found"); if (repo.status !== "ready") return textResponse(503, `Repository is ${repo.status}`); if (service === "git-receive-pack" && repo.is_archived === 1) { return textResponse(403, "Repository is archived and read-only"); } let body: ReadableStream | null = request.body; let commands: Command[] = []; let probe = false; if ( service === "git-receive-pack" && !isInfoRefs && request.headers.get("content-length") === String(PROBE_BODY.length) ) { const text = await request.text(); if (text !== PROBE_BODY) return textResponse(400, "Malformed receive-pack request"); probe = true; body = new Blob([text]).stream(); } if (service === "git-receive-pack" && !isInfoRefs && request.body && !probe) { const [inspect, forward] = request.body.tee(); body = forward; const encoding = (request.headers.get("content-encoding") ?? "").trim().toLowerCase(); if (encoding !== "" && encoding !== "identity" && encoding !== "gzip") { await Promise.all([inspect.cancel(), forward.cancel()]); return textResponse(415, "Unsupported content-encoding"); } const encoded = encoding === "gzip"; let lines: string[]; try { lines = await readUntilFlush( encoded ? inspect.pipeThrough(new DecompressionStream("gzip")) : inspect, MAX_COMMAND_BYTES, ); } catch { await forward.cancel(); return textResponse(400, "Malformed receive-pack request"); } const parsed = parseCommands(lines); commands = parsed.commands; const commandLines = lines.filter((line) => !line.startsWith("shallow ")); if (commands.length === 0 || commands.length !== commandLines.length) { await forward.cancel(); return textResponse(400, "Malformed receive-pack request"); } if (commands.length > MAX_PUSH_COMMANDS) { await forward.cancel(); return textResponse(413, `Pushes may update at most ${MAX_PUSH_COMMANDS} refs at once`); } if (auth.jobToken) await markJobTokenPush( repo.id, commands.map((command) => command.newSha), ); const scratch = await scratchPushPolicy( repo, commands.map((command) => command.ref), forward, ); if ("error" in scratch) { const declined = new Map(commands.map((command) => [command.ref, scratch.error])); return rejectPush(commands, declined, parsed.capabilities, `gitfor: ${scratch.error}`); } const checked = await checkProtections(repo, auth.permission, commands, scratch.body, encoded); if (checked.violations.size > 0) { await checked.body.cancel().catch(() => undefined); return rejectPush(commands, checked.violations, parsed.capabilities); } const expectsPack = commands.some((command) => command.newSha !== ZERO_SHA); const isAdmin = auth.permission === "admin"; const scan = await scanPushBody(repo, auth.viewer?.id ?? null, checked.body, encoded, expectsPack, isAdmin); if (scan.rejection) { const declined = new Map(commands.map((command) => [command.ref, "push declined due to secrets"])); return rejectPush(commands, declined, parsed.capabilities, scan.rejection); } body = scan.body; } let uploadCommand: string | null = null; if (service === "git-upload-pack" && !isInfoRefs && request.body) { const encoding = (request.headers.get("content-encoding") ?? "").trim().toLowerCase(); if (encoding !== "" && encoding !== "identity" && encoding !== "gzip") { return textResponse(415, "Unsupported content-encoding"); } const [inspect, forward] = request.body.tee(); body = forward; let hidden: boolean; try { let v0Deepen: boolean | undefined; ({ hidden, command: uploadCommand, v0Deepen } = await inspectUploadRequest(repo, inspect, encoding === "gzip")); if (v0Deepen) { await forward.cancel(); return uploadPackError("shallow clones need git protocol v2 (git 2.18+; remove protocol.version=0)"); } } catch (error) { await forward.cancel(); if (error instanceof MalformedUploadRequestError) return textResponse(400, error.message); throw error; } if (hidden) { await forward.cancel(); return textResponse(403, "Requested objects are not available"); } } const secret = await repoTokenSecret(repo, service === "git-receive-pack" ? "write" : "read"); const upstreamUrl = isInfoRefs ? `${repo.artifacts_remote}/info/refs?service=${service}` : `${repo.artifacts_remote}/${service}`; const headers = copyHeaders(request.headers, FORWARDED_REQUEST_HEADERS); headers.set("authorization", basicAuthHeader(secret)); const meterOp = (op: "push" | "fetch" | "clone", bytes: number) => ctx.waitUntil( meterGitOp({ accountId: repo.owner_id, repoId: repo.id, op, bytes, requestId: randomId() }).catch( (error: unknown) => console.warn("git metering failed", error), ), ); let pushBytes: Promise = Promise.resolve(0); const haves = haveDetector(); // Git only gzips large negotiation bodies, which means `have` lines: treat those as fetches. const gzipRequest = (request.headers.get("content-encoding") ?? "").trim().toLowerCase() === "gzip"; if (!isInfoRefs && service === "git-receive-pack") { pushBytes = new Promise((resolve) => { body = countingBody(body, resolve); }); } else if (!isInfoRefs) { body = countingBody( body, () => undefined, (chunk) => (gzipRequest ? undefined : haves.push(chunk)), ); } const upstream = await fetch(upstreamUrl, { method: request.method, headers, body: isInfoRefs ? null : body }); if (uploadCommand === "ls-refs" && upstream.ok) { const filtered = filterLsRefs(new Uint8Array(await upstream.arrayBuffer()), repo.default_branch); return new Response(filtered, { status: upstream.status, headers: copyHeaders(upstream.headers, FORWARDED_RESPONSE_HEADERS), }); } if (isInfoRefs && upstream.ok) { const filtered = filterAdvertisement(new Uint8Array(await upstream.arrayBuffer()), repo.default_branch); return new Response(filtered, { status: upstream.status, headers: copyHeaders(upstream.headers, FORWARDED_RESPONSE_HEADERS), }); } if (service === "git-upload-pack" && !isInfoRefs && upstream.ok) { return new Response( countingBody(upstream.body, (bytes) => meterOp(gzipRequest || haves.seen ? "fetch" : "clone", bytes)), { status: upstream.status, headers: copyHeaders(upstream.headers, FORWARDED_RESPONSE_HEADERS) }, ); } if (service === "git-receive-pack" && !isInfoRefs && upstream.ok && commands.length > 0) { ctx.waitUntil(pushBytes.then((bytes) => meterOp("push", bytes))); } if (service !== "git-receive-pack" || isInfoRefs || !upstream.ok || commands.length === 0) { return new Response(upstream.body, { status: upstream.status, headers: copyHeaders(upstream.headers, FORWARDED_RESPONSE_HEADERS), }); } const pusherId = auth.viewer?.id ?? null; return observeReceivePack( upstream, async (report) => { await invalidateRefs(repo.id); const ok = successfulRefs(report); const applied = commands.filter((command) => ok.size === 0 || ok.has(command.ref)); for (const command of applied.slice(0, MAX_TRIGGERED_REFS)) { await triggerPush({ source: "proxy", repoId: repo.id, artifactsName: repo.artifacts_name, ref: command.ref, before: command.oldSha, after: command.newSha, pusherId, }); } if (ok.size === 0 && applied.length > 0) { console.warn("receive-pack report had no ok lines", decoder.decode(report).slice(0, 200)); } }, ctx, ); }