/** Pure USERCONTENT_ORIGIN validation and grant encoding (no Worker bindings), so it unit-tests under bun. */ import { bytesToHex, hexToBytes, hmacSha256, utf8 } from "./sync-hash"; export const USERCONTENT_GRANT_PARAM = "uc"; /** Long enough for a page full of images to finish loading, short enough that a leaked link goes stale fast. */ export const USERCONTENT_GRANT_TTL_MS = 15 * 60 * 1000; const MAX_GRANT_PATH = 4096; const MAX_GRANT_LENGTH = 256; /** Read-only paths whose bodies are user-supplied bytes; uploads stay on the forge origin with its cookie and CSRF checks. */ const SERVE_PATHS = [ /^\/[^/]+\/[^/]+\/raw\/.+$/, /^\/[^/]+\/[^/]+\/archive\/.+\.(?:tar\.gz|tgz)$/, /^\/[^/]+\/[^/]+\/releases\/download\/[^/]+\/[^/]+$/, /^\/attachments\/[a-z0-9]+\/[^/]+$/, /^\/avatars\/(?:u\/)?[^/]+$/, /^\/camo\/[^/]+\/[^/]+$/, ]; /** User-content paths that serve the same public bytes to everyone, so their redirects need no viewer grant. */ const PUBLIC_PATHS = [/^\/avatars\/(?:u\/)?[^/]+$/]; export function isPublicUserContentPath(pathname: string): boolean { return PUBLIC_PATHS.some((pattern) => pattern.test(pathname)); } export function isUserContentPath(pathname: string): boolean { return SERVE_PATHS.some((pattern) => pattern.test(pathname)); } function within(host: string, domain: string | undefined): boolean { const base = (domain ?? "") .trim() .toLowerCase() .replace(/^\.+|\.+$/g, ""); return base !== "" && (host === base || host.endsWith(`.${base}`)); } /** * Why a USERCONTENT_ORIGIN is unsafe, or null. User-supplied bytes (raw files, attachments, avatars, proxied * images, release assets) must never share an origin with the forge: it must not be the forge host, must not * sit under SITES_DOMAIN (other user content), and must not be the editor origin. */ export function userContentOriginProblem( raw: string, forgeHost: string, domains: { sites?: string; editor?: string }, ): string | null { let origin: URL; try { origin = new URL(raw); } catch { return "USERCONTENT_ORIGIN is not a valid URL"; } if (origin.pathname !== "/" || origin.search || origin.hash) return "USERCONTENT_ORIGIN must be a bare origin"; const host = origin.hostname.toLowerCase(); if (host === forgeHost.toLowerCase()) return "USERCONTENT_ORIGIN must not be the forge host"; if (within(host, domains.sites)) return "USERCONTENT_ORIGIN must not be under SITES_DOMAIN"; if (domains.editor) { try { if (new URL(domains.editor).hostname.toLowerCase() === host) return "USERCONTENT_ORIGIN must not be the editor origin"; } catch { // An invalid EDITOR_ORIGIN is reported by the editor's own check. } } if (origin.protocol !== "https:" && !within(host, "localhost")) return "USERCONTENT_ORIGIN must use https"; return null; } /** * A recommendation, not a rejection: a subdomain, parent or sibling of the forge is still a separate origin * (scripts and host-only cookies stay isolated) but is same-site, so a separate registrable domain is preferred. * Rejecting it would fall back to serving user bytes from the forge itself, which is strictly worse. */ export function userContentOriginWarning(raw: string, forgeHost: string): string | null { let host: string; try { host = new URL(raw).hostname.toLowerCase(); } catch { return null; } const forge = forgeHost.toLowerCase(); if (within(forge, "localhost")) return null; const site = (value: string) => value.split(".").slice(-2).join("."); return within(host, forge) || within(forge, host) || site(host) === site(forge) ? "USERCONTENT_ORIGIN shares a site with the forge; prefer a separate registrable domain" : null; } function grantPayload(viewerId: string, pathname: string, expiresAt: number): Uint8Array { return utf8(`${viewerId}\n${pathname}\n${expiresAt}`); } /** * A grant lets the user-content origin, which never sees the forge session cookie, serve one private path as * `viewerId` until `expiresAt`: `..`. The path is in the MAC, so a grant * for one file cannot be replayed against another. */ export function encodeGrant(key: Uint8Array, viewerId: string, pathname: string, expiresAt: number): string { const mac = hmacSha256(key, grantPayload(viewerId, pathname, expiresAt)); return `${expiresAt.toString(36)}.${bytesToHex(utf8(viewerId))}.${bytesToHex(mac)}`; } 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; } export type DecodedGrant = { viewerId: string; issuedAt: number }; /** The viewer a grant vouches for on `pathname` and when it was minted, or null when malformed, expired or forged. */ export function decodeGrant(key: Uint8Array, grant: string, pathname: string, now: number): DecodedGrant | null { if (pathname.length > MAX_GRANT_PATH || grant.length > MAX_GRANT_LENGTH) return null; const [expiresRaw, viewerHex, macHex, ...rest] = grant.split("."); if (!expiresRaw || !viewerHex || !macHex || rest.length > 0) return null; const expiresAt = Number.parseInt(expiresRaw, 36); if (!Number.isFinite(expiresAt) || expiresAt <= now || expiresAt - now > USERCONTENT_GRANT_TTL_MS) return null; const viewerBytes = hexToBytes(viewerHex); const mac = hexToBytes(macHex); if (!viewerBytes || !mac || viewerBytes.byteLength === 0) return null; const viewerId = new TextDecoder().decode(viewerBytes); const expected = hmacSha256(key, grantPayload(viewerId, pathname, expiresAt)); return constantTimeEqual(mac, expected) ? { viewerId, issuedAt: expiresAt - USERCONTENT_GRANT_TTL_MS } : null; }