flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/apps/sudo/app/lib/guard.ts

85 lines3,662 bytesCodeBlame
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 */
11export 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
23const 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. */
37export 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
46const DENIED_STYLE = `
47:root{color-scheme:dark}
48body{margin:0;min-height:100vh;display:grid;place-items:center;background:#0f0f11;color:#ededef;
49font:15px/1.6 Inter,ui-sans-serif,system-ui,sans-serif;-webkit-font-smoothing:antialiased}
50main{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;
52padding:.1rem .6rem;font:600 12px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.02em}
53h1{margin:1rem 0 .5rem;font-size:1.25rem;letter-spacing:-.01em}
54p{margin:0;color:#a0a0a8}code{color:#ededef;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.9em}`;
55
56let styleHash: Promise<string> | null = null;
57
58function 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
65function 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 */
73export 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}