import { HttpError } from "./errors"; import { CAMO_PREFIX, verifyCamoPath } from "./camo-sign"; import { enforceRateLimit, requestIp } from "./limits"; import { assertResolvesPublic } from "./net/resolve-guard"; import { isOwnHost } from "./camo-sign"; import { limitStream } from "./git/ref-sync-protocol"; const MAX_IMAGE_BYTES = 5 * 1024 * 1024; const MAX_REDIRECTS = 3; const UPSTREAM_TIMEOUT_MS = 10_000; const CACHE_TTL_SECONDS = 24 * 60 * 60; const NEGATIVE_TTL_SECONDS = 5 * 60; const USER_AGENT = "gitfor-camo/1.0 (+image proxy)"; const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); const HTTP_OK = 200; const HTTP_NOT_FOUND = 404; const HTTP_UNSUPPORTED = 415; const HTTP_TOO_LARGE = 413; const HTTP_BAD_GATEWAY = 502; const HTTP_METHOD_NOT_ALLOWED = 405; /** Raster formats browsers render in ``; SVG is allowed only because the sandbox CSP makes its scripts inert. */ const IMAGE_TYPE = /^image\/(?:png|jpeg|gif|webp|avif|bmp|x-icon|vnd\.microsoft\.icon|apng|svg\+xml)$/i; const RESPONSE_HEADERS: Record = { "x-content-type-options": "nosniff", "content-disposition": "inline", "content-security-policy": "default-src 'none'; sandbox", "referrer-policy": "no-referrer", "cross-origin-resource-policy": "same-origin", "x-frame-options": "DENY", }; function reply(status: number, message: string): Response { return new Response(`${message}\n`, { status, headers: { ...RESPONSE_HEADERS, "content-type": "text/plain; charset=utf-8", "cache-control": `public, max-age=${NEGATIVE_TTL_SECONDS}`, }, }); } /** Fetches with no credentials, a fixed UA, re-validated redirects and a hard byte cap. */ async function fetchUpstream(url: string): Promise { let current = url; for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { await assertResolvesPublic(current); const response = await fetch(current, { method: "GET", redirect: "manual", headers: { "user-agent": USER_AGENT, accept: "image/*" }, signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), }); const location = response.headers.get("location"); if (!REDIRECT_STATUSES.has(response.status) || !location) return response; await response.body?.cancel(); let next: URL; try { next = new URL(location, current); } catch { throw new HttpError(HTTP_BAD_GATEWAY, "Upstream redirected to an invalid URL"); } if ( (next.protocol !== "http:" && next.protocol !== "https:") || next.username || next.password || isOwnHost(next.hostname) ) { throw new HttpError(HTTP_BAD_GATEWAY, "Upstream redirected to an unsupported URL"); } current = next.href; } throw new HttpError(HTTP_BAD_GATEWAY, "Upstream redirected too many times"); } async function proxyImage(request: Request, upstreamUrl: string, cacheKey: Request): Promise { let upstream: Response; try { upstream = await fetchUpstream(upstreamUrl); } catch (error) { if (error instanceof HttpError) return reply(error.status === HTTP_BAD_GATEWAY ? HTTP_BAD_GATEWAY : HTTP_NOT_FOUND, error.message); return reply(HTTP_BAD_GATEWAY, "Upstream image could not be fetched"); } if (upstream.status !== HTTP_OK || !upstream.body) { await upstream.body?.cancel(); return reply(HTTP_NOT_FOUND, "Upstream image not available"); } const contentType = (upstream.headers.get("content-type") ?? "").split(";", 1)[0].trim(); if (!IMAGE_TYPE.test(contentType)) { await upstream.body.cancel(); return reply(HTTP_UNSUPPORTED, "Upstream did not return an image"); } const declared = Number(upstream.headers.get("content-length") ?? "0"); if (declared > MAX_IMAGE_BYTES) { await upstream.body.cancel(); return reply(HTTP_TOO_LARGE, "Image is too large to proxy"); } // Buffer (under the cap) so an oversized or truncated upstream yields an error instead of a half image in cache. let bytes: Uint8Array; try { bytes = new Uint8Array(await new Response(limitStream(upstream.body, MAX_IMAGE_BYTES)).arrayBuffer()); } catch { return reply(HTTP_TOO_LARGE, "Image is too large to proxy"); } const response = new Response(bytes, { headers: { ...RESPONSE_HEADERS, "content-type": contentType, "content-length": String(bytes.byteLength), "cache-control": `public, max-age=${CACHE_TTL_SECONDS}`, }, }); if (request.method === "GET") await (await caches.open("camo")).put(cacheKey, response.clone()); return response; } /** * Same-origin image proxy for external images in rendered markdown (GitHub's "camo"): only URLs signed by * `camoUrl` are fetched, upstream never sees the visitor (no cookies, no referrer, fixed UA), responses are * capped, typed as images only and served under a sandbox CSP. */ export async function handleCamoRoutes(request: Request, url: URL): Promise { if (!url.pathname.startsWith(CAMO_PREFIX)) return null; if (request.method !== "GET" && request.method !== "HEAD") return reply(HTTP_METHOD_NOT_ALLOWED, "Method not allowed"); const upstreamUrl = verifyCamoPath(url.pathname); if (!upstreamUrl) return reply(HTTP_NOT_FOUND, "Not found"); const cacheKey = new Request(`${url.origin}${url.pathname}`); const cache = await caches.open("camo"); const hit = await cache.match(cacheKey); if (hit) return hit; await enforceRateLimit("HEAVY_LIMITER", `camo:ip:${requestIp(request)}`); return proxyImage(request, upstreamUrl, cacheKey); }