File
Blob: src/worker/security.ts
| 1 | import { SessionRecord, WebSocketTicketRecord } from "@/shared/contracts"; |
| 2 | |
| 3 | const APP_CONTENT_SECURITY_POLICY = [ |
| 4 | "default-src 'self'", |
| 5 | "script-src 'self' https://challenges.cloudflare.com", |
| 6 | "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", |
| 7 | "img-src 'self' data: blob:", |
| 8 | "font-src 'self' https://fonts.gstatic.com data:", |
| 9 | "connect-src 'self' ws: wss:", |
| 10 | "frame-src 'self' blob: https://challenges.cloudflare.com", |
| 11 | "object-src 'none'", |
| 12 | "base-uri 'self'", |
| 13 | "frame-ancestors 'none'", |
| 14 | "form-action 'self'", |
| 15 | ].join("; "); |
| 16 | |
| 17 | export const ADMIN_ACCESS_UNAVAILABLE_MESSAGE = |
| 18 | "Admin access is unavailable because tessera OIDC is not configured or cannot be discovered."; |
| 19 | |
| 20 | export class PublicError extends Error { |
| 21 | constructor(message: string) { |
| 22 | super(message); |
| 23 | this.name = "PublicError"; |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); |
| 28 | |
| 29 | export function isLoopbackHostname(hostname: string) { |
| 30 | return LOOPBACK_HOSTNAMES.has(hostname.toLowerCase()); |
| 31 | } |
| 32 | |
| 33 | export function getPublicErrorMessage(error: unknown, fallback: string) { |
| 34 | if (error instanceof PublicError) { |
| 35 | return error.message; |
| 36 | } |
| 37 | |
| 38 | return fallback; |
| 39 | } |
| 40 | |
| 41 | export function issueNoStoreHeaders(headers: Headers) { |
| 42 | headers.set("Cache-Control", "no-store, max-age=0"); |
| 43 | headers.set("Pragma", "no-cache"); |
| 44 | } |
| 45 | |
| 46 | export function withSecurityHeaders(request: Request, response: Response) { |
| 47 | const headers = new Headers(response.headers); |
| 48 | const url = new URL(request.url); |
| 49 | const contentType = headers.get("content-type") ?? ""; |
| 50 | const isHtml = contentType.includes("text/html"); |
| 51 | const isApiRoute = url.pathname.startsWith("/api/"); |
| 52 | |
| 53 | headers.set("Referrer-Policy", "no-referrer"); |
| 54 | headers.set("X-Content-Type-Options", "nosniff"); |
| 55 | headers.set( |
| 56 | "Permissions-Policy", |
| 57 | "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()", |
| 58 | ); |
| 59 | |
| 60 | if (url.protocol === "https:") { |
| 61 | headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); |
| 62 | } |
| 63 | |
| 64 | if (isApiRoute || isHtml) { |
| 65 | issueNoStoreHeaders(headers); |
| 66 | } |
| 67 | |
| 68 | if (isHtml) { |
| 69 | headers.set("Content-Security-Policy", APP_CONTENT_SECURITY_POLICY); |
| 70 | } |
| 71 | |
| 72 | return new Response(response.body, { |
| 73 | status: response.status, |
| 74 | statusText: response.statusText, |
| 75 | headers, |
| 76 | }); |
| 77 | } |
| 78 | |
| 79 | export function isAllowedWebSocketOrigin(request: Request) { |
| 80 | const origin = request.headers.get("origin"); |
| 81 | if (!origin) { |
| 82 | return false; |
| 83 | } |
| 84 | |
| 85 | const url = new URL(request.url); |
| 86 | return origin === url.origin; |
| 87 | } |
| 88 | |
| 89 | export function decodeWebSocketTicket(raw: string | null) { |
| 90 | if (!raw) { |
| 91 | return null; |
| 92 | } |
| 93 | |
| 94 | try { |
| 95 | return WebSocketTicketRecord.assertDecode(JSON.parse(raw)); |
| 96 | } catch { |
| 97 | return null; |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | export function decodeSessionRecord(raw: string | null) { |
| 102 | if (!raw) { |
| 103 | return null; |
| 104 | } |
| 105 | |
| 106 | try { |
| 107 | return SessionRecord.assertDecode(JSON.parse(raw)); |
| 108 | } catch { |
| 109 | return null; |
| 110 | } |
| 111 | } |