File
Blob: src/worker/lib/security-headers.ts
| 1 | import { isLocalRequestUrl } from "@/worker/http"; |
| 2 | import { base64UrlEncode } from "@/lib/encoding"; |
| 3 | |
| 4 | const REFERRER_POLICY = "strict-origin-when-cross-origin"; |
| 5 | const CLOUDFLARE_ANALYTICS_ORIGIN = "https://static.cloudflareinsights.com"; |
| 6 | const CLOUDFLARE_ANALYTICS_CONNECT_ORIGIN = "https://cloudflareinsights.com"; |
| 7 | // Excalidraw's ExcalidrawFontFace unconditionally appends its esm.sh fallback |
| 8 | // URL to every generated `@font-face` src list — even when the primary |
| 9 | // (self-hosted via EXCALIDRAW_ASSET_PATH) resolves. The browser honors the |
| 10 | // primary but still surfaces a CSP violation for the fallback unless we |
| 11 | // allowlist it. Fonts-only; canvas surface only uses this for its built-in |
| 12 | // font set. |
| 13 | const EXCALIDRAW_FONTS_FALLBACK_ORIGIN = "https://esm.sh"; |
| 14 | |
| 15 | function cloneResponse(response: Response): Response { |
| 16 | return new Response(response.body, response); |
| 17 | } |
| 18 | |
| 19 | function maybeGetSentryOrigin(dsn: string | null | undefined): string | null { |
| 20 | if (!dsn) return null; |
| 21 | |
| 22 | try { |
| 23 | return new URL(dsn).origin; |
| 24 | } catch { |
| 25 | return null; |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | function joinDirective(name: string, values: string[]): string { |
| 30 | return values.length > 0 ? `${name} ${values.join(" ")}` : name; |
| 31 | } |
| 32 | |
| 33 | export function createCspNonce(): string { |
| 34 | return base64UrlEncode(crypto.getRandomValues(new Uint8Array(16))); |
| 35 | } |
| 36 | |
| 37 | export function buildDocumentCsp(options: { nonce: string; requestUrl: string; sentryDsn?: string | null }): string { |
| 38 | const { nonce, requestUrl, sentryDsn } = options; |
| 39 | const isLocal = isLocalRequestUrl(requestUrl); |
| 40 | const sentryOrigin = maybeGetSentryOrigin(sentryDsn); |
| 41 | const connectSrc = [ |
| 42 | "'self'", |
| 43 | CLOUDFLARE_ANALYTICS_CONNECT_ORIGIN, |
| 44 | ...(isLocal ? ["http:", "https:", "ws:", "wss:"] : []), |
| 45 | ...(sentryOrigin ? [sentryOrigin] : []), |
| 46 | ]; |
| 47 | const scriptSrc = isLocal |
| 48 | ? ["'self'", CLOUDFLARE_ANALYTICS_ORIGIN, "'unsafe-inline'", "'unsafe-eval'"] |
| 49 | : ["'self'", `'nonce-${nonce}'`, CLOUDFLARE_ANALYTICS_ORIGIN]; |
| 50 | |
| 51 | const directives = [ |
| 52 | joinDirective("default-src", ["'self'"]), |
| 53 | joinDirective("base-uri", ["'self'"]), |
| 54 | joinDirective("object-src", ["'none'"]), |
| 55 | joinDirective("frame-ancestors", ["'none'"]), |
| 56 | joinDirective("form-action", ["'self'"]), |
| 57 | joinDirective("script-src", scriptSrc), |
| 58 | joinDirective("connect-src", connectSrc), |
| 59 | joinDirective("style-src", ["'self'", "'unsafe-inline'"]), |
| 60 | joinDirective("font-src", ["'self'", EXCALIDRAW_FONTS_FALLBACK_ORIGIN]), |
| 61 | joinDirective("img-src", ["'self'", "data:", "blob:", "https:"]), |
| 62 | ...(!isLocal ? ["upgrade-insecure-requests"] : []), |
| 63 | ]; |
| 64 | |
| 65 | return directives.join("; "); |
| 66 | } |
| 67 | |
| 68 | export function applyBaselineSecurityHeaders(response: Response): Response { |
| 69 | const next = cloneResponse(response); |
| 70 | next.headers.set("Referrer-Policy", REFERRER_POLICY); |
| 71 | next.headers.set("X-Content-Type-Options", "nosniff"); |
| 72 | return next; |
| 73 | } |
| 74 | |
| 75 | export function applyDocumentSecurityHeaders( |
| 76 | response: Response, |
| 77 | options: { nonce: string; requestUrl: string; sentryDsn?: string | null }, |
| 78 | ): Response { |
| 79 | const next = applyBaselineSecurityHeaders(response); |
| 80 | next.headers.set("Content-Security-Policy", buildDocumentCsp(options)); |
| 81 | next.headers.set("X-Frame-Options", "DENY"); |
| 82 | return next; |
| 83 | } |
| 84 | |
| 85 | // Strict CSP for the public Sites surface. Scripts are same-origin plus |
| 86 | // Cloudflare Web Analytics only; no inline scripts, eval, or broad third-party |
| 87 | // connect/script origins. Emoji fallback images come from jsdelivr (per the |
| 88 | // Tiptap emoji extension); the rest of img-src stays narrow. |
| 89 | const EMOJI_FALLBACK_ORIGIN = "https://cdn.jsdelivr.net"; |
| 90 | const SITES_CSP_PROD = [ |
| 91 | "default-src 'self'", |
| 92 | `script-src 'self' ${CLOUDFLARE_ANALYTICS_ORIGIN}`, |
| 93 | `connect-src 'self' ${CLOUDFLARE_ANALYTICS_CONNECT_ORIGIN}`, |
| 94 | "style-src 'self' 'unsafe-inline'", |
| 95 | `img-src 'self' data: ${EMOJI_FALLBACK_ORIGIN}`, |
| 96 | "font-src 'self'", |
| 97 | "frame-ancestors 'none'", |
| 98 | "base-uri 'self'", |
| 99 | "form-action 'none'", |
| 100 | ].join("; "); |
| 101 | |
| 102 | // Localhost-only relaxation. Vite's HMR client injects inline scripts and |
| 103 | // requires `unsafe-eval` plus a WebSocket connection. `applySitesSecurityHeaders` |
| 104 | // picks this variant only when the request URL passes `isLocalRequestUrl`, so |
| 105 | // deployed Sites responses (non-localhost host) always get the prod CSP above. |
| 106 | const SITES_CSP_DEV = [ |
| 107 | "default-src 'self'", |
| 108 | "script-src 'self' 'unsafe-inline' 'unsafe-eval'", |
| 109 | "connect-src 'self' http: https: ws: wss:", |
| 110 | "style-src 'self' 'unsafe-inline'", |
| 111 | `img-src 'self' data: blob: ${EMOJI_FALLBACK_ORIGIN}`, |
| 112 | "font-src 'self' data:", |
| 113 | "frame-ancestors 'none'", |
| 114 | "base-uri 'self'", |
| 115 | "form-action 'none'", |
| 116 | ].join("; "); |
| 117 | |
| 118 | export function applySitesSecurityHeaders(response: Response, requestUrl?: string): Response { |
| 119 | const next = applyBaselineSecurityHeaders(response); |
| 120 | const csp = requestUrl && isLocalRequestUrl(requestUrl) ? SITES_CSP_DEV : SITES_CSP_PROD; |
| 121 | next.headers.set("Content-Security-Policy", csp); |
| 122 | next.headers.set("X-Frame-Options", "DENY"); |
| 123 | return next; |
| 124 | } |