import { basicAuthHeader, repoTokenSecret } from "./artifacts"; import { invalidateRefs } from "./refs"; import { limitStream, packStream, parseReportStatus, parseSyncableRefs, receivePackBody, uploadPackRequest, } from "./ref-sync-protocol"; import type { AdvertisedRef, RefCommand } from "./ref-sync-protocol"; import { badRequest } from "../errors"; import { assertResolvesPublic } from "../net/resolve-guard"; import { publicUrl } from "../events/emit"; import type { RepoRow } from "~/lib/types"; const MAX_HAVES = 256; const ADVERTISEMENT_TIMEOUT_MS = 30_000; const UPLOAD_TIMEOUT_MS = 10 * 60_000; const MAX_ADVERTISEMENT_BYTES = 8 * 1024 * 1024; const MAX_REDIRECTS = 3; const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); const AUTH_STATUSES = new Set([401, 403]); const HTTP_NOT_FOUND = 404; const USER_AGENT = "git/gitfor"; const IPV4_LITERAL = /^\d{1,3}(\.\d{1,3}){3}$/; const BLOCKED_SUFFIXES = [".workers.dev", ".localhost", ".local", ".internal"]; export type ScreenResult = { body: ReadableStream; rejected: Map }; /** Thrown by relayPack when `screen` refuses refs; nothing was sent to the target. */ export class RelayScreenRejected extends Error { constructor(readonly rejected: Map) { super(`Refused ${rejected.size} ref(s)`); } } /** `trusted` sources are our own Artifacts remotes; anything else is a user-supplied URL. */ export type RelaySource = { url: string; authorization: string | null; trusted?: boolean }; /** Rejects user-supplied URLs that are not plain https to a public host (no IP literals or our own zone). */ export function assertPublicHttpsUrl(raw: string): URL { let url: URL; try { url = new URL(raw); } catch { throw badRequest("Invalid source URL"); } const host = url.hostname.toLowerCase().replace(/\.$/, ""); const ownHost = new URL(publicUrl("/")).hostname.toLowerCase(); const blocked = url.protocol !== "https:" || url.username !== "" || url.password !== "" || host === "localhost" || host.startsWith("[") || host.includes(":") || IPV4_LITERAL.test(host) || !host.includes(".") || host === ownHost || host.endsWith(`.${ownHost}`) || BLOCKED_SUFFIXES.some((suffix) => host.endsWith(suffix)); if (blocked) throw badRequest("Only public https:// sources can be synced"); return url; } /** Fetches a user-supplied URL, re-validating (hostname rules and DNS) every redirect hop instead of letting fetch follow them. */ export async function guardedFetch(source: RelaySource, url: string, init: RequestInit): Promise { if (source.trusted) return fetch(url, init); let current = assertPublicHttpsUrl(url).toString(); for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { await assertResolvesPublic(current); const response = await fetch(current, { ...init, redirect: "manual" }); const location = response.headers.get("location"); if (!REDIRECT_STATUSES.has(response.status) || !location) return response; await response.body?.cancel(); if (init.method && init.method !== "GET") throw badRequest("The source redirected an upload-pack request"); const next = assertPublicHttpsUrl(new URL(location, current).toString()); if (next.origin !== new URL(current).origin) { const headers = new Headers(init.headers); headers.delete("authorization"); init = { ...init, headers }; } current = next.toString(); } throw badRequest("The source redirected too many times"); } async function readCapped(response: Response, maxBytes: number): Promise { if (Number(response.headers.get("content-length") ?? "0") > maxBytes || !response.body) { await response.body?.cancel(); if (!response.body) return new Uint8Array(0); throw badRequest("The source's ref list is too large"); } try { return new Uint8Array(await new Response(limitStream(response.body, maxBytes)).arrayBuffer()); } catch { throw badRequest("The source's ref list is too large"); } } export function trimUrl(url: string): string { return url.replace(/\/$/, ""); } /** `includePeeled` (with `includeAll`) also returns `^{}` lines, whose SHA is the peeled commit. */ export async function advertisement( source: RelaySource, includeAll = false, includePeeled = false, ): Promise { const headers: Record = { "user-agent": USER_AGENT }; if (source.authorization) headers.authorization = source.authorization; const response = await guardedFetch(source, `${trimUrl(source.url)}/info/refs?service=git-upload-pack`, { headers, signal: AbortSignal.timeout(ADVERTISEMENT_TIMEOUT_MS), }); if (AUTH_STATUSES.has(response.status)) { throw badRequest(`The source requires authentication (HTTP ${response.status}); it is private or does not exist`); } if (response.status === HTTP_NOT_FOUND) throw badRequest("The source repository was not found (HTTP 404)"); if (!response.ok) throw badRequest(`Could not list refs (HTTP ${response.status})`); return parseSyncableRefs(await readCapped(response, MAX_ADVERTISEMENT_BYTES), includeAll, includePeeled); } export async function artifactsSource(repo: RepoRow): Promise { return { url: repo.artifacts_remote, authorization: basicAuthHeader(await repoTokenSecret(repo, "read")), trusted: true, }; } /** * Streams the objects for `wants` from the source's upload-pack straight into the target's receive-pack * together with `commands`, so nothing is buffered in the Worker. `maxBytes` aborts oversized packs; * `bytes` is the pack size actually relayed. */ export async function relayPack(input: { source: RelaySource; /** A forge repo (pushed into Artifacts) or an external remote such as a GitHub mirror. */ target: RepoRow | RelaySource; wants: string[]; haves: string[]; commands: RefCommand[]; maxBytes?: number; /** Inspects the full receive-pack body before it is sent (protection, push scanning); may refuse refs. */ screen?: (body: ReadableStream) => Promise; }): Promise<{ accepted: number; rejected: string[]; bytes: number }> { const headers: Record = { "content-type": "application/x-git-upload-pack-request", accept: "application/x-git-upload-pack-result", "user-agent": USER_AGENT, }; if (input.source.authorization) headers.authorization = input.source.authorization; const upload = await guardedFetch(input.source, `${trimUrl(input.source.url)}/git-upload-pack`, { method: "POST", headers, body: uploadPackRequest(input.wants, input.haves.slice(0, MAX_HAVES)), signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS), }); if (!upload.ok || !upload.body) throw badRequest(`The source refused to send objects (HTTP ${upload.status})`); const raw = await packStream(upload.body); let bytes = 0; const pack = (input.maxBytes ? limitStream(raw, input.maxBytes) : raw).pipeThrough( new TransformStream({ transform(chunk, controller) { bytes += chunk.byteLength; controller.enqueue(chunk); }, }), ); const target = input.target; const remote: RelaySource = "artifacts_remote" in target ? { url: target.artifacts_remote, authorization: basicAuthHeader(await repoTokenSecret(target, "write")), trusted: true, } : target; const pushHeaders: Record = { "content-type": "application/x-git-receive-pack-request", accept: "application/x-git-receive-pack-result", "user-agent": USER_AGENT, }; if (remote.authorization) pushHeaders.authorization = remote.authorization; let pushBody = receivePackBody(input.commands, pack); if (input.screen) { const screened = await input.screen(pushBody); if (screened.rejected.size > 0) { await screened.body.cancel().catch(() => undefined); throw new RelayScreenRejected(screened.rejected); } pushBody = screened.body; } const push = await guardedFetch(remote, `${trimUrl(remote.url)}/git-receive-pack`, { method: "POST", headers: pushHeaders, body: pushBody, signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS), }); if (!push.ok) { throw new Error(`The target rejected the relayed refs (HTTP ${push.status}): ${(await push.text()).slice(0, 300)}`); } const report = parseReportStatus(new Uint8Array(await push.arrayBuffer())); if (!report.unpackOk) throw new Error("The target could not unpack the relayed objects"); if ("artifacts_remote" in target) await invalidateRefs(target.id); return { accepted: report.accepted, rejected: report.rejected, bytes }; }