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, loadProtections, protectionFor } from "../protection"; import { triggerPush } from "../events/push-trigger"; import { scanPushBody } from "../security/push-protection"; import { basicAuthHeader, repoTokenSecret } from "./artifacts"; import { concatBytes, packetText, parseAllPackets, readUntilFlush, reportStatusResponse } from "./pktline"; import { invalidateRefs } from "./refs"; import { ZERO_SHA } from "./store"; import type { Permission, RepoRow, Viewer } from "~/lib/types"; 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 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="stig"', "content-type": "text/plain" }, }); } 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) commands.push({ oldSha, newSha, ref }); } return { commands, capabilities }; } async function checkProtections( repo: RepoRow, permission: Permission, commands: Command[], ): Promise> { const violations = new Map(); const protections = await loadProtections(repo.id); if (protections.length === 0) return violations; for (const command of commands) { if (!command.ref.startsWith("refs/heads/")) continue; const branch = command.ref.slice("refs/heads/".length); const violation = directUpdateViolation( protectionFor(protections, branch), permission, command.newSha === ZERO_SHA, ); if (violation) violations.set(command.ref, violation); } return violations; } function rejectPush( commands: Command[], violations: Map, capabilities: string[], message = "stig: 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 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[] = []; if (service === "git-receive-pack" && !isInfoRefs && request.body) { const [inspect, forward] = request.body.tee(); body = forward; const encoded = request.headers.get("content-encoding") === "gzip"; const lines = await readUntilFlush( encoded ? inspect.pipeThrough(new DecompressionStream("gzip")) : inspect, MAX_COMMAND_BYTES, ); const parsed = parseCommands(lines); commands = parsed.commands; if (auth.jobToken) await markJobTokenPush( repo.id, commands.map((command) => command.newSha), ); 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 violations = await checkProtections(repo, auth.permission, commands); if (violations.size > 0) { await forward.cancel(); return rejectPush(commands, violations, parsed.capabilities); } const scan = await scanPushBody(repo, auth.viewer?.id ?? null, forward, encoded); 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; } 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 upstream = await fetch(upstreamUrl, { method: request.method, headers, body: isInfoRefs ? null : body }); 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, ); }