import { handleAttachmentRoutes } from "./attachments"; import { viewerFromSessionCookie } from "./auth"; import { handleAvatarRoutes } from "./avatars"; import { handleCamoRoutes } from "./camo"; import { handleRawRoutes } from "./raw"; import { userContentUrl } from "./usercontent-grant"; import { forgeOriginFor, isUserContentOrigin, userContentOriginFor } from "./usercontent-origin"; import { isPublicUserContentPath, isUserContentPath, USERCONTENT_GRANT_PARAM } from "./usercontent-policy"; const READ_METHODS = new Set(["GET", "HEAD"]); const PUBLIC_HOP_CACHE = "public, max-age=3600"; /** Headers every user-content response carries, whatever the handler set: no sniffing, no scripts, no cookies. */ function harden(response: Response, forgeOrigin: string): Response { const headers = new Headers(response.headers); headers.delete("set-cookie"); headers.set("x-content-type-options", "nosniff"); if (!headers.has("content-security-policy")) headers.set("content-security-policy", "default-src 'none'; sandbox"); headers.set("referrer-policy", "no-referrer"); headers.set("cross-origin-resource-policy", "cross-origin"); headers.set("access-control-allow-origin", forgeOrigin); headers.set("vary", [headers.get("vary"), "Origin"].filter(Boolean).join(", ")); return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); } /** * Requests on the user-content origin: only the read-only serving routes exist there. Anything else (pages, * server functions, uploads, APIs) is a 404, so a payload that lands on this origin finds nothing to call. */ export async function handleUserContentOrigin(request: Request, url: URL): Promise { if (!isUserContentOrigin(url)) return null; const forgeOrigin = forgeOriginFor(url); if (request.method === "OPTIONS") { return new Response(null, { status: 204, headers: { "access-control-allow-origin": forgeOrigin, "access-control-allow-methods": "GET, HEAD", "access-control-max-age": "86400", }, }); } if (!READ_METHODS.has(request.method) || !isUserContentPath(url.pathname)) { return harden(new Response("Not found\n", { status: 404, headers: { "content-type": "text/plain" } }), forgeOrigin); } const response = (await handleAvatarRoutes(request, url)) ?? (await handleAttachmentRoutes(request, url)) ?? (await handleCamoRoutes(request, url)) ?? (await handleRawRoutes(request, url)) ?? new Response("Not found\n", { status: 404, headers: { "content-type": "text/plain" } }); return harden(response, forgeOrigin); } /** * On the forge origin, browser reads of user content hop to the user-content origin with a grant for the * signed-in viewer (like GitHub's `raw` -> `raw.githubusercontent.com?token=` redirect), so existing links keep * working while no user bytes are rendered from the forge itself. Token clients (`Authorization` header) are * served in place: HTTP clients drop credentials on cross-origin redirects, and a non-browser caller has no * forge cookies for a hostile file to reach. */ export async function redirectToUserContent(request: Request, url: URL): Promise { if (!READ_METHODS.has(request.method) || !isUserContentPath(url.pathname)) return null; if (!userContentOriginFor(url)) return null; const viewer = await viewerFromSessionCookie(request); // Token clients stay on the forge, but a cookie session never serves user bytes here, even when the browser also replays Basic credentials. if (request.headers.has("authorization") && !viewer) return null; const params = new URLSearchParams(url.search); params.delete(USERCONTENT_GRANT_PARAM); const search = params.size > 0 ? `?${params.toString()}` : ""; // A per-viewer grant carries a timestamp, so it would give public bytes a new URL (and a browser cache miss) // on every page load. if (isPublicUserContentPath(url.pathname)) { return new Response(null, { status: 302, headers: { location: userContentUrl(url, url.pathname, search, null), "cache-control": PUBLIC_HOP_CACHE }, }); } return new Response(null, { status: 302, headers: { location: userContentUrl(url, url.pathname, search, viewer), "cache-control": viewer ? "private, no-store" : "public, max-age=60", vary: "Cookie, Authorization", }, }); }