38 lines · 1.8 KB
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 });
  }
}