Skip to content
File

Blob: src/worker/middleware/headers.ts

typescript82 lines
1import type { MiddlewareHandler } from "hono";
2 
3import type { AppBindings } from "@/worker/hono";
4import { stripTrailingSlash } from "@/worker/services/url";
5 
6// Worker-response security headers. Static asset responses (served
7// directly by the Cloudflare Assets binding without going through this
8// middleware) are covered by `public/_headers`, which Vite copies into
9// dist/client/_headers at build time. Both must agree.
10//
11// Turnstile needs script + frame allowances on script-src/frame-src and
12// connect-src for siteverify. `frame-ancestors 'none'` blocks tessera
13// from being embedded — a hostile page must not iframe /authorize or
14// /sign-in to spoof a consent screen.
15const CSP = [
16 "default-src 'self'",
17 "script-src 'self' https://challenges.cloudflare.com",
18 "style-src 'self' 'unsafe-inline'",
19 "img-src 'self' data: https:",
20 "font-src 'self'",
21 "connect-src 'self' https://challenges.cloudflare.com",
22 "frame-src https://challenges.cloudflare.com",
23 "frame-ancestors 'none'",
24 "base-uri 'self'",
25 "form-action 'self'",
26 "object-src 'none'",
27].join("; ");
28 
29const SECURITY_HEADERS: Record<string, string> = {
30 "Content-Security-Policy": CSP,
31 "X-Content-Type-Options": "nosniff",
32 "Referrer-Policy": "strict-origin-when-cross-origin",
33 "X-Frame-Options": "DENY",
34 "Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
35};
36 
37// Cache-Control: dynamic worker responses carry user-scoped session and
38// admin payloads. `no-store` blocks browser/intermediary retention;
39// `private` is defense-in-depth for caches that ignore `no-store` but
40// honor `private`. `secretJsonResponse` sets a stricter
41// `no-store, private, max-age=0` directly so the set-if-not-present
42// check below preserves the secret-bearing variant.
43//
44// Vary: Cookie advertises that the response keys on the session cookie.
45// Public OIDC documents and JWKS do not key on cookies, so they must not
46// inherit the default `Vary: Cookie`.
47const DEFAULT_CACHE_CONTROL = "no-store, private";
48const JWKS_CACHE_CONTROL = "public, max-age=300, stale-while-revalidate=600";
49 
50const PUBLIC_OIDC_DOCUMENT_PATHS = new Set([
51 "/api/auth/jwks",
52 "/.well-known/openid-configuration",
53 "/.well-known/oauth-authorization-server",
54]);
55 
56export const securityHeaders: MiddlewareHandler<AppBindings> = async (c, next) => {
57 await next();
58 for (const [name, value] of Object.entries(SECURITY_HEADERS)) {
59 if (!c.res.headers.has(name)) {
60 c.res.headers.set(name, value);
61 }
62 }
63 
64 const path = stripTrailingSlash(c.req.path);
65 const isSuccessful = c.res.status >= 200 && c.res.status < 300;
66 const isPublicOidcDocument = isSuccessful && PUBLIC_OIDC_DOCUMENT_PATHS.has(path);
67 
68 if (isPublicOidcDocument) {
69 if (path === "/api/auth/jwks" && !c.res.headers.has("Cache-Control")) {
70 c.res.headers.set("Cache-Control", JWKS_CACHE_CONTROL);
71 }
72 return;
73 }
74 
75 if (!c.res.headers.has("Cache-Control")) {
76 c.res.headers.set("Cache-Control", DEFAULT_CACHE_CONTROL);
77 }
78 if (!c.res.headers.has("Vary")) {
79 c.res.headers.set("Vary", "Cookie");
80 }
81};