import type { MiddlewareHandler } from "hono"; import type { AppBindings } from "@/worker/hono"; import { stripTrailingSlash } from "@/worker/services/url"; // Worker-response security headers. Static asset responses (served // directly by the Cloudflare Assets binding without going through this // middleware) are covered by `public/_headers`, which Vite copies into // dist/client/_headers at build time. Both must agree. // // Turnstile needs script + frame allowances on script-src/frame-src and // connect-src for siteverify. `frame-ancestors 'none'` blocks tessera // from being embedded — a hostile page must not iframe /authorize or // /sign-in to spoof a consent screen. const CSP = [ "default-src 'self'", "script-src 'self' https://challenges.cloudflare.com", "style-src 'self' 'unsafe-inline'", "img-src 'self' data: https:", "font-src 'self'", "connect-src 'self' https://challenges.cloudflare.com", "frame-src https://challenges.cloudflare.com", "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'", "object-src 'none'", ].join("; "); const SECURITY_HEADERS: Record = { "Content-Security-Policy": CSP, "X-Content-Type-Options": "nosniff", "Referrer-Policy": "strict-origin-when-cross-origin", "X-Frame-Options": "DENY", "Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload", }; // Cache-Control: dynamic worker responses carry user-scoped session and // admin payloads. `no-store` blocks browser/intermediary retention; // `private` is defense-in-depth for caches that ignore `no-store` but // honor `private`. `secretJsonResponse` sets a stricter // `no-store, private, max-age=0` directly so the set-if-not-present // check below preserves the secret-bearing variant. // // Vary: Cookie advertises that the response keys on the session cookie. // Public OIDC documents and JWKS do not key on cookies, so they must not // inherit the default `Vary: Cookie`. const DEFAULT_CACHE_CONTROL = "no-store, private"; const JWKS_CACHE_CONTROL = "public, max-age=300, stale-while-revalidate=600"; const PUBLIC_OIDC_DOCUMENT_PATHS = new Set([ "/api/auth/jwks", "/.well-known/openid-configuration", "/.well-known/oauth-authorization-server", ]); export const securityHeaders: MiddlewareHandler = async (c, next) => { await next(); for (const [name, value] of Object.entries(SECURITY_HEADERS)) { if (!c.res.headers.has(name)) { c.res.headers.set(name, value); } } const path = stripTrailingSlash(c.req.path); const isSuccessful = c.res.status >= 200 && c.res.status < 300; const isPublicOidcDocument = isSuccessful && PUBLIC_OIDC_DOCUMENT_PATHS.has(path); if (isPublicOidcDocument) { if (path === "/api/auth/jwks" && !c.res.headers.has("Cache-Control")) { c.res.headers.set("Cache-Control", JWKS_CACHE_CONTROL); } return; } if (!c.res.headers.has("Cache-Control")) { c.res.headers.set("Cache-Control", DEFAULT_CACHE_CONTROL); } if (!c.res.headers.has("Vary")) { c.res.headers.set("Vary", "Cookie"); } };