Skip to content
File

Blob: src/worker/lib/security-headers.ts

typescript125 lines
1import { isLocalRequestUrl } from "@/worker/http";
2import { base64UrlEncode } from "@/lib/encoding";
3 
4const REFERRER_POLICY = "strict-origin-when-cross-origin";
5const CLOUDFLARE_ANALYTICS_ORIGIN = "https://static.cloudflareinsights.com";
6const 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.
13const EXCALIDRAW_FONTS_FALLBACK_ORIGIN = "https://esm.sh";
14 
15function cloneResponse(response: Response): Response {
16 return new Response(response.body, response);
17}
18 
19function 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 
29function joinDirective(name: string, values: string[]): string {
30 return values.length > 0 ? `${name} ${values.join(" ")}` : name;
31}
32 
33export function createCspNonce(): string {
34 return base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)));
35}
36 
37export 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 
68export 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 
75export 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.
89const EMOJI_FALLBACK_ORIGIN = "https://cdn.jsdelivr.net";
90const 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.
106const 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 
118export 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}