g1t/apps/sudo/app/lib/guard.ts
| 1 | /** |
| 2 | * What every sudo response carries, and the page shown to whoever is |
| 3 | * turned away. No Workers imports, so it can be tested under Node. |
| 4 | */ |
| 5 | |
| 6 | /** |
| 7 | * The pages ship no JavaScript, so no script may run at all; styles and |
| 8 | * images come only from sudo itself, fonts from Google Fonts, and forms |
| 9 | * post only back to sudo. |
| 10 | */ |
| 11 | export const CONTENT_SECURITY_POLICY = [ |
| 12 | "default-src 'none'", |
| 13 | "script-src 'none'", |
| 14 | "style-src 'self' https://fonts.googleapis.com", |
| 15 | "font-src https://fonts.gstatic.com", |
| 16 | "img-src 'self' data:", |
| 17 | "form-action 'self'", |
| 18 | "frame-ancestors 'none'", |
| 19 | "base-uri 'none'", |
| 20 | "upgrade-insecure-requests", |
| 21 | ].join("; "); |
| 22 | |
| 23 | const HEADERS: Record<string, string> = { |
| 24 | "cache-control": "no-store", |
| 25 | "x-robots-tag": "noindex, nofollow, noarchive", |
| 26 | "x-frame-options": "DENY", |
| 27 | "x-content-type-options": "nosniff", |
| 28 | // Same-origin keeps the Referer that the same-origin check falls back on. |
| 29 | "referrer-policy": "same-origin", |
| 30 | "strict-transport-security": "max-age=63072000; includeSubDomains", |
| 31 | "cross-origin-opener-policy": "same-origin", |
| 32 | "cross-origin-resource-policy": "same-origin", |
| 33 | "permissions-policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()", |
| 34 | }; |
| 35 | |
| 36 | /** The response with sudo's headers; a policy it already set is kept. */ |
| 37 | export function secure(response: Response): Response { |
| 38 | const secured = new Response(response.body, response); |
| 39 | for (const [name, value] of Object.entries(HEADERS)) secured.headers.set(name, value); |
| 40 | if (!secured.headers.has("content-security-policy")) { |
| 41 | secured.headers.set("content-security-policy", CONTENT_SECURITY_POLICY); |
| 42 | } |
| 43 | return secured; |
| 44 | } |
| 45 | |
| 46 | const DENIED_STYLE = ` |
| 47 | :root{color-scheme:dark} |
| 48 | body{margin:0;min-height:100vh;display:grid;place-items:center;background:#0f0f11;color:#ededef; |
| 49 | font:15px/1.6 Inter,ui-sans-serif,system-ui,sans-serif;-webkit-font-smoothing:antialiased} |
| 50 | main{max-width:28rem;padding:2rem 1rem;text-align:center} |
| 51 | .badge{display:inline-block;border:1px solid #b6a8ff66;color:#b6a8ff;background:#b6a8ff1a;border-radius:999px; |
| 52 | padding:.1rem .6rem;font:600 12px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.02em} |
| 53 | h1{margin:1rem 0 .5rem;font-size:1.25rem;letter-spacing:-.01em} |
| 54 | p{margin:0;color:#a0a0a8}code{color:#ededef;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.9em}`; |
| 55 | |
| 56 | let styleHash: Promise<string> | null = null; |
| 57 | |
| 58 | function hashOfStyle(): Promise<string> { |
| 59 | styleHash ??= crypto.subtle |
| 60 | .digest("SHA-256", new TextEncoder().encode(DENIED_STYLE)) |
| 61 | .then((digest) => btoa(String.fromCharCode(...new Uint8Array(digest)))); |
| 62 | return styleHash; |
| 63 | } |
| 64 | |
| 65 | function escapeHtml(text: string): string { |
| 66 | return text.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`); |
| 67 | } |
| 68 | |
| 69 | /** |
| 70 | * A self-contained page for a refusal: its one inline stylesheet is |
| 71 | * allowed by its hash, and nothing else is. |
| 72 | */ |
| 73 | export async function denied(status: number, title: string, message: string): Promise<Response> { |
| 74 | const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"> |
| 75 | <meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex, nofollow"> |
| 76 | <title>${escapeHtml(title)} · sudo</title><style>${DENIED_STYLE}</style></head> |
| 77 | <body><main><span class="badge">sudo</span><h1>${escapeHtml(title)}</h1><p>${escapeHtml(message)}</p></main></body></html>`; |
| 78 | return new Response(html, { |
| 79 | status, |
| 80 | headers: { |
| 81 | "content-type": "text/html; charset=utf-8", |
| 82 | "content-security-policy": `default-src 'none'; style-src 'sha256-${await hashOfStyle()}'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'`, |
| 83 | }, |
| 84 | }); |
| 85 | } |