import { env } from "cloudflare:workers"; import { userContentOriginFor } from "./usercontent-origin"; import { bytesToHex, hexToBytes, hkdfSha256, hmacSha256, utf8 } from "./sync-hash"; export const CAMO_PREFIX = "/camo/"; const CAMO_SALT = "gitfor-camo"; const CAMO_INFO = "image-proxy-v1"; const MAX_URL_LENGTH = 2048; /** One canonical spelling per URL, so case changes can't mint fresh cache keys. */ const LOWER_HEX = /^[0-9a-f]+$/; let cachedKey: { secret: string; key: Uint8Array } | null = null; /** MAC key derived from SECRETS_KEY with HKDF so the proxy never sees the master key directly. */ function camoKey(secret: string | undefined): Uint8Array | null { if (!secret) return null; if (!cachedKey || cachedKey.secret !== secret) cachedKey = { secret, key: hkdfSha256(utf8(secret), CAMO_SALT, CAMO_INFO) }; return cachedKey.key; } /** Only absolute http(s) URLs without credentials are proxied; anything else is dropped by the caller. */ export function proxiableImageUrl(raw: string): string | null { if (raw.length > MAX_URL_LENGTH) return null; let url: URL; try { url = new URL(raw); } catch { return null; } if (url.protocol !== "http:" && url.protocol !== "https:") return null; if (url.username || url.password || !url.hostname) return null; if (isOwnHost(url.hostname)) return null; url.hash = ""; return url.href; } function hostOf(value: string | undefined | null): string | null { if (!value) return null; try { return new URL(value.includes("://") ? value : `https://${value}`).hostname.toLowerCase().replace(/\.+$/, ""); } catch { return null; } } /** * The forge, user-content, editor and sites hosts are never proxied: no self-fetch loops or relaying * our own user content. Checked when signing, when verifying and on every redirect hop. */ export function isOwnHost(hostname: string): boolean { const host = hostname.toLowerCase().replace(/\.+$/, ""); const forge = hostOf(env.PUBLIC_URL); const derivedUserContent = forge ? hostOf(userContentOriginFor(new URL(env.PUBLIC_URL))) : null; const exact = [forge, hostOf(env.USERCONTENT_ORIGIN), derivedUserContent, hostOf(env.EDITOR_ORIGIN)]; const zones = [hostOf(env.SITES_DOMAIN)]; return exact.includes(host) || zones.some((zone) => zone !== null && (host === zone || host.endsWith(`.${zone}`))); } export function signCamo(url: string, secret: string | undefined = env.SECRETS_KEY): string | null { const key = camoKey(secret); if (!key) return null; return bytesToHex(hmacSha256(key, utf8(url))); } /** `/camo//`; `null` when the URL is not proxiable or no signing key is configured. */ export function camoUrl(raw: string, secret: string | undefined = env.SECRETS_KEY): string | null { const url = proxiableImageUrl(raw); if (!url) return null; const signature = signCamo(url, secret); if (!signature) return null; return `${CAMO_PREFIX}${signature}/${bytesToHex(utf8(url))}`; } function constantTimeEqual(left: Uint8Array, right: Uint8Array): boolean { if (left.byteLength !== right.byteLength) return false; let diff = 0; for (let index = 0; index < left.byteLength; index++) diff |= left[index] ^ right[index]; return diff === 0; } /** Returns the upstream URL when the path carries a valid signature for it; `null` otherwise. */ export function verifyCamoPath(pathname: string, secret: string | undefined = env.SECRETS_KEY): string | null { if (!pathname.startsWith(CAMO_PREFIX)) return null; const [signatureHex, urlHex, ...rest] = pathname.slice(CAMO_PREFIX.length).split("/"); if (!signatureHex || !urlHex || rest.length > 0) return null; if (!LOWER_HEX.test(signatureHex) || !LOWER_HEX.test(urlHex)) return null; const signature = hexToBytes(signatureHex); const urlBytes = hexToBytes(urlHex); if (!signature || !urlBytes || urlBytes.byteLength > MAX_URL_LENGTH) return null; const url = new TextDecoder().decode(urlBytes); if (proxiableImageUrl(url) !== url) return null; const key = camoKey(secret); if (!key) return null; return constantTimeEqual(signature, hmacSha256(key, utf8(url))) ? url : null; }