Skip to content
File

Blob: src/worker/security.ts

typescript112 lines
1import { SessionRecord, WebSocketTicketRecord } from "@/shared/contracts";
2 
3const 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 
17export const ADMIN_ACCESS_UNAVAILABLE_MESSAGE =
18 "Admin access is unavailable because tessera OIDC is not configured or cannot be discovered.";
19 
20export class PublicError extends Error {
21 constructor(message: string) {
22 super(message);
23 this.name = "PublicError";
24 }
25}
26 
27const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
28 
29export function isLoopbackHostname(hostname: string) {
30 return LOOPBACK_HOSTNAMES.has(hostname.toLowerCase());
31}
32 
33export function getPublicErrorMessage(error: unknown, fallback: string) {
34 if (error instanceof PublicError) {
35 return error.message;
36 }
37 
38 return fallback;
39}
40 
41export function issueNoStoreHeaders(headers: Headers) {
42 headers.set("Cache-Control", "no-store, max-age=0");
43 headers.set("Pragma", "no-cache");
44}
45 
46export 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 
79export 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 
89export 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 
101export 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}