File
Blob: src/worker/middleware/headers.ts
| 1 | import type { MiddlewareHandler } from "hono"; |
| 2 | |
| 3 | import type { AppBindings } from "@/worker/hono"; |
| 4 | import { 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. |
| 15 | const 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 | |
| 29 | const 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`. |
| 47 | const DEFAULT_CACHE_CONTROL = "no-store, private"; |
| 48 | const JWKS_CACHE_CONTROL = "public, max-age=300, stale-while-revalidate=600"; |
| 49 | |
| 50 | const PUBLIC_OIDC_DOCUMENT_PATHS = new Set([ |
| 51 | "/api/auth/jwks", |
| 52 | "/.well-known/openid-configuration", |
| 53 | "/.well-known/oauth-authorization-server", |
| 54 | ]); |
| 55 | |
| 56 | export 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 | }; |