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