Skip to content
File

Blob: src/worker/routes/git.ts

typescript490 lines
1import type { HeadInfo, Ref } from "@/worker/git";
2import type { CacheContext } from "@/worker/cache/cache";
3 
4import {
5 capabilityAdvertisement,
6 parseV2Command,
7 pktLine,
8 flushPkt,
9 concatChunks,
10 getHeadAndRefs,
11} from "@/worker/git";
12import { loadPeeledTagTargets } from "@/worker/git/object-store";
13import { handleFetchV2Streaming } from "@/worker/git/operations/uploadStream";
14import { handleStreamingReceivePackPOST } from "@/worker/git/receive/streamReceivePack";
15import { asBodyInit, gunzip } from "@/worker/common";
16import { buildCacheKeyFrom, cacheOrLoadJSONForRequest } from "@/worker/cache";
17import { markRequestPrivate, responseCacheControl } from "@/worker/cache/policy";
18import { isValidOwnerRepo } from "@/shared/web";
19import { resolveRepositoryRoute, type RepositoryRoute } from "@/worker/repositories/route";
20import {
21 authenticateGitRequest,
22 getBasicCredentials,
23 scheduleTouchPatLastUsedAt,
24 type GitAuthResult,
25} from "@/worker/auth/gitAuth";
26import type { Db } from "@/worker/db/d1/client";
27import type { Logger } from "@/worker/common/logger";
28import { touchRepositoryUpdatedAt } from "@/worker/db/d1/dal/repositories";
29import { workerExecutionContext, type AppContext, type AppRouter } from "./hono";
30 
31type GitService = "git-upload-pack" | "git-receive-pack";
32type PatTouchOp = "read" | "write";
33 
34// Realm string emitted on Basic auth challenges so git CLI prompts the user
35// with a recognisable label.
36const GIT_BASIC_REALM = 'Basic realm="git", charset="UTF-8"';
37 
38function basicChallenge(): Response {
39 return new Response("Authentication required\n", {
40 status: 401,
41 headers: {
42 "Content-Type": "text/plain; charset=utf-8",
43 "WWW-Authenticate": GIT_BASIC_REALM,
44 "Cache-Control": "no-store",
45 },
46 });
47}
48 
49function forbidden(message = "Forbidden\n"): Response {
50 return new Response(message, {
51 status: 403,
52 headers: {
53 "Content-Type": "text/plain; charset=utf-8",
54 "Cache-Control": "no-store",
55 },
56 });
57}
58 
59function gitNotFound(): Response {
60 return new Response("Not found\n", {
61 status: 404,
62 headers: {
63 "Content-Type": "text/plain; charset=utf-8",
64 "Cache-Control": "no-store",
65 },
66 });
67}
68 
69async function decodeUploadPackBody(request: Request): Promise<Uint8Array | Response> {
70 const rawBody = new Uint8Array(await request.arrayBuffer());
71 const contentEncoding = (request.headers.get("Content-Encoding") || "").trim().toLowerCase();
72 
73 if (!contentEncoding || contentEncoding === "identity") {
74 return rawBody;
75 }
76 
77 if (contentEncoding !== "gzip") {
78 return new Response(`Unsupported Content-Encoding: ${contentEncoding}\n`, {
79 status: 415,
80 headers: { "Content-Type": "text/plain; charset=utf-8" },
81 });
82 }
83 
84 try {
85 return await gunzip(rawBody);
86 } catch {
87 return new Response("Invalid gzip request body\n", {
88 status: 400,
89 headers: { "Content-Type": "text/plain; charset=utf-8" },
90 });
91 }
92}
93 
94/**
95 * Handles Git upload-pack (fetch) POST requests.
96 * Supports both protocol v2 and legacy protocol based on Git-Protocol header.
97 */
98async function handleUploadPackPOST(
99 env: Env,
100 route: RepositoryRoute,
101 request: Request,
102 cacheCtx: CacheContext
103) {
104 const decodedBody = await decodeUploadPackBody(request);
105 if (decodedBody instanceof Response) return decodedBody;
106 const body = decodedBody;
107 const gitProto = request.headers.get("Git-Protocol") || "";
108 const { command } = parseV2Command(body);
109 // Accept either explicit v2 header or a v2-formatted body (contains command=...)
110 if (!/version=2/.test(gitProto) && !command) {
111 return new Response("Expected Git protocol v2 (set Git-Protocol: version=2)\n", {
112 status: 400,
113 });
114 }
115 
116 if (command === "ls-refs") {
117 const loader = async (): Promise<{ head: HeadInfo | undefined; refs: Ref[] } | null> => {
118 try {
119 const result = await getHeadAndRefs(env, route.doName, cacheCtx);
120 return { head: result.head, refs: result.refs };
121 } catch {
122 return null;
123 }
124 };
125 const cacheKeyRefs = buildCacheKeyFrom(request, "/_cache/refs", { repo: route.doName });
126 const refsData = await cacheOrLoadJSONForRequest<{ head: HeadInfo | undefined; refs: Ref[] }>(
127 cacheCtx,
128 cacheKeyRefs,
129 loader,
130 60
131 );
132 const { head, refs } = refsData || { refs: [] };
133 
134 // Parse ls-refs arguments (reuse already-read body to avoid double-read of the stream)
135 const { args } = parseV2Command(body);
136 const refPrefixes: string[] = [];
137 let wantPeel = false;
138 for (const a of args) {
139 if (a === "peel") wantPeel = true;
140 else if (a.startsWith("ref-prefix ")) refPrefixes.push(a.slice("ref-prefix ".length));
141 }
142 
143 let filteredRefs = refs;
144 if (refPrefixes.length > 0) {
145 filteredRefs = refs.filter((r) => refPrefixes.some((p) => r.name.startsWith(p)));
146 }
147 
148 let peeledByRef = new Map<string, string>();
149 if (wantPeel) {
150 try {
151 const tagRefs = filteredRefs.filter((r) => r.name.startsWith("refs/tags/"));
152 if (tagRefs.length > 0) {
153 peeledByRef = await loadPeeledTagTargets(env, route.doName, tagRefs, cacheCtx);
154 }
155 } catch {}
156 }
157 
158 const chunks: Uint8Array[] = [];
159 if (head && head.target) {
160 const t =
161 filteredRefs.find((r) => r.name === head.target) ||
162 refs.find((r) => r.name === head.target);
163 const headOid = head.oid ?? t?.oid;
164 const headLineAttrs: string[] = [];
165 headLineAttrs.push(`symref-target:${head.target}`);
166 if (headOid) {
167 const base = [`${headOid} HEAD`, ...headLineAttrs].join(" ");
168 chunks.push(pktLine(base + "\n"));
169 } else {
170 const base = ["unborn HEAD", ...headLineAttrs].join(" ");
171 chunks.push(pktLine(base + "\n"));
172 }
173 }
174 
175 for (const r of filteredRefs) {
176 const attrs: string[] = [];
177 if (wantPeel) {
178 const peeled = peeledByRef.get(r.name);
179 if (peeled) attrs.push(`peeled:${peeled}`);
180 }
181 const line =
182 attrs.length > 0 ? `${r.oid} ${r.name} ${attrs.join(" ")}` : `${r.oid} ${r.name}`;
183 chunks.push(pktLine(line + "\n"));
184 }
185 chunks.push(flushPkt());
186 return new Response(asBodyInit(concatChunks(chunks)), {
187 status: 200,
188 headers: {
189 "Content-Type": "application/x-git-upload-pack-result",
190 "Cache-Control": responseCacheControl(cacheCtx),
191 },
192 });
193 }
194 
195 if (command === "fetch") {
196 return handleFetchV2Streaming(env, route.doName, body, request.signal, cacheCtx);
197 }
198 
199 return new Response("Unsupported command or malformed request\n", { status: 400 });
200}
201 
202async function handleReceivePackPOST(
203 env: Env,
204 route: RepositoryRoute,
205 request: Request,
206 ctx: ExecutionContext,
207 db: Db,
208 log: Logger
209) {
210 return await handleStreamingReceivePackPOST(env, route.doName, request, ctx, {
211 onRepoStateChanged: async ({ changed }) => {
212 if (!changed) return;
213 try {
214 await touchRepositoryUpdatedAt(db, route.repositoryId, Date.now());
215 log.debug("receive:repo-updated-at-touched", { repositoryId: route.repositoryId });
216 } catch (error) {
217 log.warn("receive:repo-updated-at-failed", {
218 repositoryId: route.repositoryId,
219 error: String(error),
220 });
221 }
222 },
223 });
224}
225 
226// Validate URL slug shape before any DB/DO/R2 work. Mirrors `repoKey` validity.
227function validateRouteSlugs(owner: string, repo: string): boolean {
228 return isValidOwnerRepo(owner) && isValidOwnerRepo(repo);
229}
230 
231function normalizeGitRouteRepoSlug(repo: string): string {
232 // Git clients append Smart HTTP endpoints to the clone URL. Accept
233 // clone-style `/repo.git/...` URLs while resolving storage against the
234 // canonical repository slug.
235 return repo.endsWith(".git") ? repo.slice(0, -".git".length) : repo;
236}
237 
238function gitRequestAllowsD1Fallback(request: Request): boolean {
239 const credentials = getBasicCredentials(request);
240 return credentials !== null && credentials.password.length > 0;
241}
242 
243async function resolveGitRoute(
244 c: AppContext,
245 owner: string,
246 repo: string
247): Promise<RepositoryRoute | null> {
248 return await resolveRepositoryRoute(c.env, owner, repo, {
249 mode: gitRequestAllowsD1Fallback(c.req.raw) ? "allow-d1-fallback" : "route-cache-only",
250 db: c.var.db,
251 log: c.var.logFor({ service: "RepoRoute" }),
252 });
253}
254 
255type ResolveGitRouteResult =
256 | { kind: "ok"; route: RepositoryRoute }
257 | { kind: "response"; response: Response };
258 
259async function resolveGitRouteForRequest(
260 c: AppContext,
261 owner: string,
262 repo: string,
263 service: GitService,
264 isDiscovery: boolean
265): Promise<ResolveGitRouteResult> {
266 const route = await resolveGitRoute(c, owner, repo);
267 if (route) return { kind: "ok", route };
268 
269 // Git sends the first receive-pack discovery request before it has
270 // credentials. If the route cache has no candidate yet (or the repo is
271 // private and intentionally absent from ROUTES), challenge so the client
272 // can retry with Basic/PAT. A request that already carried a Basic
273 // password has used D1 fallback and remains a real 404 when unresolved.
274 if (service === "git-receive-pack" && !gitRequestAllowsD1Fallback(c.req.raw)) {
275 return {
276 kind: "response",
277 response: challengeUnresolvedPushRoute(c, owner, repo, isDiscovery),
278 };
279 }
280 return { kind: "response", response: gitNotFound() };
281}
282 
283function challengeUnresolvedPushRoute(
284 c: AppContext,
285 owner: string,
286 repo: string,
287 isDiscovery: boolean
288): Response {
289 const log = c.var.logFor({ service: "GitAcl" });
290 log.info("git-acl:push-route-miss-401-challenge", {
291 owner,
292 repo,
293 discovery: isDiscovery,
294 });
295 return basicChallenge();
296}
297 
298function gateD1FallbackGitAuth(
299 c: AppContext,
300 route: RepositoryRoute,
301 auth: GitAuthResult
302): Response | null {
303 if (route.source !== "d1") return null;
304 if (auth.kind === "pat") return null;
305 const log = c.var.logFor({ service: "GitAcl", repoId: route.doName });
306 if (auth.kind === "pat-rejected" && auth.reason === "grant-missing") {
307 log.info("git-acl:d1-fallback-grant-missing", { reason: auth.reason });
308 return forbidden();
309 }
310 log.info("git-acl:d1-fallback-unauthorized", { reason: auth.kind });
311 return basicChallenge();
312}
313 
314// Decide whether a Git read (info-refs upload-pack, git-upload-pack) is
315// allowed for the resolved route given the authenticated principal. Returns
316// `null` when allowed, otherwise the response to send.
317function gateGitRead(c: AppContext, route: RepositoryRoute, auth: GitAuthResult): Response | null {
318 if (route.visibility === "public") return null;
319 const log = c.var.logFor({ service: "GitAcl", repoId: route.doName });
320 switch (auth.kind) {
321 case "anonymous":
322 // Discovery hop on private upload-pack: 404 to avoid leaking existence.
323 log.info("git-acl:private-404", { reason: "anonymous-read" });
324 return gitNotFound();
325 case "missing-credentials":
326 log.info("git-acl:private-401-challenge", { reason: "missing-credentials" });
327 return basicChallenge();
328 case "pat-rejected":
329 // Any PAT failure on a read is reported as 401 so the client can retry
330 // with fresh creds, EXCEPT grant-missing which is 403 (the user has
331 // proven their identity but lacks access).
332 if (auth.reason === "grant-missing") {
333 log.info("git-acl:pat-rejected", { reason: auth.reason });
334 return forbidden();
335 }
336 log.info("git-acl:pat-rejected", { reason: auth.reason });
337 return basicChallenge();
338 case "pat":
339 return null;
340 }
341}
342 
343// Push (receive-pack) requires a PAT with `level === "push"` regardless of
344// repo visibility. Public repos do NOT fall through to anonymous push: the
345// resolved D1 row is the only authority, and it has no per-repo "anyone can
346// push" toggle. Treat any non-PAT-push principal as an auth challenge or
347// 403 by reason.
348async function gateGitPush(
349 c: AppContext,
350 route: RepositoryRoute,
351 auth: GitAuthResult,
352 isDiscovery: boolean
353): Promise<Response | null> {
354 const log = c.var.logFor({ service: "GitAcl", repoId: route.doName });
355 if (auth.kind === "pat") {
356 if (auth.verified.level !== "push") {
357 log.info("git-acl:push-pull-only", { patId: auth.verified.patId });
358 return forbidden();
359 }
360 return null;
361 }
362 if (auth.kind === "pat-rejected") {
363 if (auth.reason === "grant-missing") {
364 log.info("git-acl:pat-rejected", { reason: auth.reason });
365 return forbidden();
366 }
367 log.info("git-acl:pat-rejected", { reason: auth.reason });
368 return basicChallenge();
369 }
370 // anonymous | missing-credentials -> 401 challenge so the git client
371 // re-issues with Basic credentials.
372 log.info("git-acl:push-401-challenge", {
373 reason: auth.kind === "anonymous" ? "anonymous" : "missing-credentials",
374 discovery: isDiscovery,
375 visibility: route.visibility,
376 });
377 return basicChallenge();
378}
379 
380type GitAuthorizationResult =
381 | { kind: "ok"; cacheCtx: CacheContext }
382 | { kind: "response"; response: Response };
383 
384async function authorizeGitRouteForRequest(
385 c: AppContext,
386 route: RepositoryRoute,
387 service: GitService,
388 isDiscovery: boolean,
389 patTouchOp: PatTouchOp
390): Promise<GitAuthorizationResult> {
391 const cacheCtx = c.var.cacheCtx;
392 if (route.visibility === "private" || service === "git-receive-pack") {
393 markRequestPrivate(cacheCtx);
394 }
395 
396 const auth = await authenticateGitRequest(c.env, c.req.raw, route, { db: c.var.db });
397 const fallbackBlocked = gateD1FallbackGitAuth(c, route, auth);
398 if (fallbackBlocked) return { kind: "response", response: fallbackBlocked };
399 
400 const blocked =
401 service === "git-receive-pack"
402 ? await gateGitPush(c, route, auth, isDiscovery)
403 : gateGitRead(c, route, auth);
404 if (blocked) return { kind: "response", response: blocked };
405 
406 if (auth.kind === "pat") {
407 // PAT `last_used_at` is a visibility signal for token management only;
408 // it is throttled for reads and always attempted for writes.
409 scheduleTouchPatLastUsedAt(
410 c.env,
411 workerExecutionContext(c),
412 auth.verified,
413 patTouchOp,
414 Date.now(),
415 {
416 db: c.var.db,
417 log: c.var.logFor({ service: "GitAuth" }),
418 }
419 );
420 }
421 
422 return { kind: "ok", cacheCtx };
423}
424 
425/**
426 * Registers Git Smart HTTP v2 routes on the router.
427 */
428export function registerGitRoutes(router: AppRouter) {
429 router.get(`/:owner/:repo/info/refs`, async (c) => {
430 const owner = c.req.param("owner");
431 const repo = normalizeGitRouteRepoSlug(c.req.param("repo"));
432 if (!validateRouteSlugs(owner, repo)) return gitNotFound();
433 const url = new URL(c.req.url);
434 const service = url.searchParams.get("service");
435 if (service !== "git-upload-pack" && service !== "git-receive-pack") {
436 return new Response("Missing or unsupported service\n", { status: 400 });
437 }
438 const resolved = await resolveGitRouteForRequest(c, owner, repo, service, true);
439 if (resolved.kind === "response") return resolved.response;
440 const route = resolved.route;
441 const authorized = await authorizeGitRouteForRequest(c, route, service, true, "read");
442 if (authorized.kind === "response") return authorized.response;
443 const { cacheCtx } = authorized;
444 return await capabilityAdvertisement(c.env, service, route.doName, cacheCtx);
445 });
446 
447 router.post(`/:owner/:repo/git-upload-pack`, async (c) => {
448 const owner = c.req.param("owner");
449 const repo = normalizeGitRouteRepoSlug(c.req.param("repo"));
450 if (!validateRouteSlugs(owner, repo)) return gitNotFound();
451 const resolved = await resolveGitRouteForRequest(c, owner, repo, "git-upload-pack", false);
452 if (resolved.kind === "response") return resolved.response;
453 const route = resolved.route;
454 const authorized = await authorizeGitRouteForRequest(
455 c,
456 route,
457 "git-upload-pack",
458 false,
459 "read"
460 );
461 if (authorized.kind === "response") return authorized.response;
462 return handleUploadPackPOST(c.env, route, c.req.raw, authorized.cacheCtx);
463 });
464 
465 router.post(`/:owner/:repo/git-receive-pack`, async (c) => {
466 const owner = c.req.param("owner");
467 const repo = normalizeGitRouteRepoSlug(c.req.param("repo"));
468 if (!validateRouteSlugs(owner, repo)) return gitNotFound();
469 const resolved = await resolveGitRouteForRequest(c, owner, repo, "git-receive-pack", false);
470 if (resolved.kind === "response") return resolved.response;
471 const route = resolved.route;
472 const authorized = await authorizeGitRouteForRequest(
473 c,
474 route,
475 "git-receive-pack",
476 false,
477 "write"
478 );
479 if (authorized.kind === "response") return authorized.response;
480 return await handleReceivePackPOST(
481 c.env,
482 route,
483 c.req.raw,
484 workerExecutionContext(c),
485 c.var.db,
486 c.var.logFor({ service: "ReceiveAcl", repoId: route.doName })
487 );
488 });
489}