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);
}