import { isLoopbackHost } from "./deployment"; const PAGE_CSP_DIRECTIVES = ["frame-ancestors 'none'", "base-uri 'self'", "object-src 'none'", "form-action 'self'"]; const IMG_SRC = "img-src 'self' data: blob:"; const HSTS = "max-age=31536000"; const SWITCHING_PROTOCOLS = 101; /** The forge's own HTML pages: images only from the forge, user-content origin and analytics host. */ export function pageCsp(contentOrigin: string | null, analyticsHost: string | null | undefined): string { const imgSrc = [IMG_SRC, contentOrigin, analyticsHost].filter(Boolean).join(" "); return [imgSrc, ...PAGE_CSP_DIRECTIVES].join("; "); } /** * Baseline headers for every forge-origin response (DEPLOY-P7). Values a handler already set win, so routes * with their own policy (OAuth consent, raw files, camo, unsubscribe) keep it. Editor, user-content, sites and * pages origins never pass through here. */ export function withSecurityHeaders(response: Response, request: Request): Response { const url = new URL(request.url); const wanted: Array<[string, string]> = [ ["x-content-type-options", "nosniff"], ["referrer-policy", "strict-origin-when-cross-origin"], ["x-frame-options", "DENY"], ]; if (url.protocol === "https:" && !isLoopbackHost(url.hostname)) wanted.push(["strict-transport-security", HSTS]); const missing = wanted.filter(([name]) => !response.headers.has(name)); if (missing.length === 0 || response.status === SWITCHING_PROTOCOLS) return response; try { for (const [name, value] of missing) response.headers.set(name, value); return response; } catch { const headers = new Headers(response.headers); for (const [name, value] of missing) headers.set(name, value); return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); } }