Skip to the content

Capsid menu

workers/security-headers.ts

107 lines and 6 functions in DrDustinEdwards/carrel, at commit d5d5e82 on 2026-10-24. This file on GitHub.

Functions

The functions of security-headers.ts
FunctionLinesComplexityCoveredCRAPMutation
isHttpsOrigin36 to 361100%OkCRAP 1No data: mutation testing has not run on this repo yet
appPolicy47 to 651100%OkCRAP 1No data: mutation testing has not run on this repo yet
mutable84 to 91275%OkCRAP 2.1No data: mutation testing has not run on this repo yet
apply93 to 1054100%OkCRAP 4No data: mutation testing has not run on this repo yet
withSecurityHeaders108 to 1141100%OkCRAP 1No data: mutation testing has not run on this repo yet
withDoorSecurityHeaders117 to 1191100%OkCRAP 1No data: mutation testing has not run on this repo yet

Imports

It imports no other module of this repo. Imported by workers/app.ts, workers/csp-report.ts.

Source

// Every security header Carrel sends, in one place: the portfolio's shared set from
// @dustinedwards/site-runtime (the OWASP Secure Headers Project's own values), Carrel's few
// deviations beside the reason for each, and the Content-Security-Policy built from the short list of
// what Carrel loads. workers/app.ts applies it to every response on both hosts, redirects and the
// gate's refusals included. Nothing else in the repo sets a security header, except a route that
// chooses its own policy on purpose (the preview, the print export), which is kept.

import { buildContentSecurityPolicy, buildPolicy, generateNonce, reportingEndpoints } from "@dustinedwards/site-runtime/csp";
import { applyHeaderSet, buildSecurityHeaders } from "@dustinedwards/site-runtime/headers";

/** Where browsers send violation reports: a path on the AI door, the one Carrel host outside Access. */
export const CSP_REPORT_PATH = "/csp-report";
/** The `report-to` group name, declared by the Reporting-Endpoints header. */
const CSP_REPORT_GROUP = "csp";

/** The browser host's set: the shared standard exactly. A header not overridden is OSHP's value. */
const securityHeaders: Readonly<Record<string, string>> = Object.freeze(buildSecurityHeaders());

/** The AI door's set: the standard with one deviation. */
export const doorSecurityHeaders: Readonly<Record<string, string>> = Object.freeze(
  buildSecurityHeaders({
    overrides: {
      "Cross-Origin-Opener-Policy": {
        value: null,
        reason:
          "AI clients (claude.ai among them) open /authorize in a popup and wait for it through window.opener; same-origin would sever that opener and the sign-in would never return.",
      },
    },
  }),
);

/** A nonce from the platform's randomness; a derived or fixed value would pass every test and protect nothing. */
export const newNonce = generateNonce;

/** An image origin as a policy may name it; anything else is dropped rather than failing every page. */
const isHttpsOrigin = (origin: string) => /^https:\/\/[^\s;,'"]+$/.test(origin);

/**
 * The policy for every page Carrel renders. Every response is private and `no-store`, so a fresh
 * nonce per request is sound; it goes on React Router's scripts and on CodeMirror's inline <style>,
 * which cannot be turned off.
 *
 * `imageOrigins` are the configured sites' origins: the media library and the editor show each file
 * from the site that serves it, and nothing else may load an image. `reportOrigin` is the AI door's
 * origin, whose /csp-report is reachable without an Access session.
 */
export function appPolicy(nonce: string, imageOrigins: string[] = [], reportOrigin = ""): string {
  return buildContentSecurityPolicy({
    nonce,
    sources: {
      // 'strict-dynamic': the nonced entry scripts load the route chunks.
      script: ["'strict-dynamic'"],
      img: ["data:", ...imageOrigins.filter(isHttpsOrigin)],
    },
    // The editor's palettes are placed with style attributes, which nonces do not cover.
    styleAttributesInline: true,
    // Carrel's hosts are excluded from Web Analytics, and HTML is sent no-transform, so the beacon is
    // never injected: the policy is not loosened for it.
    cloudflareWebAnalytics: false,
    // The preview iframe, which is Carrel's own route serving the site's page under its own policy.
    extraDirectives: ["frame-src 'self'"],
    reportUri: `${reportOrigin}${CSP_REPORT_PATH}`,
    reportTo: CSP_REPORT_GROUP,
  }).value;
}

/**
 * The AI door's policy. Its only page is the consent form, which has one inline <style> and no
 * script. form-action is left out on purpose: approving submits into a redirect chain (POST
 * /authorize, the Access sign-in, /callback, then the client's own redirect_uri), a browser checks
 * form-action on every hop, and the last hop is whatever any client names, so no list can be right
 * (capsid shipped form-action 'self' on its consent page and broke sign-in for 26 days).
 */
export const DOOR_POLICY = buildPolicy([
  "default-src 'none'",
  "style-src 'unsafe-inline'",
  "base-uri 'none'",
  "frame-ancestors 'none'",
  `report-uri ${CSP_REPORT_PATH}`,
  `report-to ${CSP_REPORT_GROUP}`,
]);

/** The response itself when its headers can be changed, else a copy that can (Response.redirect's cannot). */
function mutable(response: Response): Response {
  try {
    response.headers.set("X-Content-Type-Options", "nosniff");
    return response;
  } catch {
    return new Response(response.body, { status: response.status, statusText: response.statusText, headers: new Headers(response.headers) });
  }
}

function apply(response: Response, set: Readonly<Record<string, string>>, policy: string, reportOrigin: string): Response {
  const out = mutable(response);
  // The preview alone may be framed, and only by Carrel itself: its route says SAMEORIGIN, and nothing
  // a route says can loosen framing further than that.
  const framedByCarrel = out.headers.get("X-Frame-Options")?.trim().toUpperCase() === "SAMEORIGIN";
  // A route that set its own policy (the sandboxed preview, the print export) keeps it.
  const ownPolicy = out.headers.has("Content-Security-Policy");
  applyHeaderSet(out.headers, set);
  if (framedByCarrel) out.headers.set("X-Frame-Options", "SAMEORIGIN");
  if (!ownPolicy) out.headers.set("Content-Security-Policy", policy);
  if (reportOrigin) out.headers.set("Reporting-Endpoints", reportingEndpoints(CSP_REPORT_GROUP, new URL(CSP_REPORT_PATH, reportOrigin)));
  return out;
}

/** Every response on the browser host: the shared set and the app policy for this request's nonce. */
export function withSecurityHeaders(
  response: Response,
  nonce: string,
  { imageOrigins = [], reportOrigin = "" }: { imageOrigins?: string[]; reportOrigin?: string } = {},
): Response {
  return apply(response, securityHeaders, appPolicy(nonce, imageOrigins, reportOrigin), reportOrigin);
}

/** Every response on the AI door: its set and its policy. */
export function withDoorSecurityHeaders(response: Response, doorOrigin: string): Response {
  return apply(response, doorSecurityHeaders, DOOR_POLICY, doorOrigin);
}