Skip to content

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

118 linesCodeBlameRaw
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 policy of every answer that is not a page (data, redirects, files):
8 * no script may run at all; styles, images and fonts come only from sudo
9 * itself, and forms post only back to sudo.
10 */
11export const CONTENT_SECURITY_POLICY = [
12 "default-src 'none'",
13 "script-src 'none'",
14 "style-src 'self'",
15 "font-src 'self'",
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/** A fresh nonce for one page: 128 random bits, base64. */
24export function makeNonce(): string {
25 const bytes = crypto.getRandomValues(new Uint8Array(16));
26 return btoa(String.fromCharCode(...bytes));
27}
28
29/**
30 * The policy of a page rendered with `nonce` (entry.server.tsx): as strict
31 * as every other answer's, except that the page runs sudo's own scripts,
32 * its files from /assets and the inline scripts React Router writes, each
33 * carrying the page's nonce, and nothing else, no other site's and no
34 * other inline script. Those scripts fetch the next page's data from sudo
35 * itself and nowhere else. Still no inline style: React sets a style
36 * through the DOM, which the policy allows.
37 */
38export function pagePolicy(nonce: string): string {
39 return [
40 "default-src 'none'",
41 `script-src 'self' 'nonce-${nonce}'`,
42 "style-src 'self'",
43 "font-src 'self'",
44 "img-src 'self' data:",
45 "connect-src 'self'",
46 "form-action 'self'",
47 "frame-ancestors 'none'",
48 "base-uri 'none'",
49 "upgrade-insecure-requests",
50 ].join("; ");
51}
52
53const HEADERS: Record<string, string> = {
54 "cache-control": "no-store",
55 "x-robots-tag": "noindex, nofollow, noarchive",
56 "x-frame-options": "DENY",
57 "x-content-type-options": "nosniff",
58 // Same-origin keeps the Referer that the same-origin check falls back on.
59 "referrer-policy": "same-origin",
60 "strict-transport-security": "max-age=63072000; includeSubDomains",
61 "cross-origin-opener-policy": "same-origin",
62 "cross-origin-resource-policy": "same-origin",
63 "permissions-policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()",
64};
65
66/**
67 * The response with sudo's headers. A policy it already set is kept: a
68 * page's, with its nonce, or a refusal's.
69 */
70export function secure(response: Response): Response {
71 const secured = new Response(response.body, response);
72 for (const [name, value] of Object.entries(HEADERS)) secured.headers.set(name, value);
73 if (!secured.headers.has("content-security-policy")) {
74 secured.headers.set("content-security-policy", CONTENT_SECURITY_POLICY);
75 }
76 return secured;
77}
78
79const DENIED_STYLE = `
80:root{color-scheme:dark}
81body{margin:0;min-height:100vh;display:grid;place-items:center;background:#0f0f11;color:#ededef;
82font:15px/1.6 'Hanken Grotesk',ui-sans-serif,system-ui,sans-serif;-webkit-font-smoothing:antialiased}
83main{max-width:28rem;padding:2rem 1rem;text-align:center}
84.badge{display:inline-block;border:1px solid #b6a8ff66;color:#b6a8ff;background:#b6a8ff1a;border-radius:999px;
85padding:.1rem .6rem;font:600 12px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.02em}
86h1{margin:1rem 0 .5rem;font-size:1.25rem;letter-spacing:-.01em}
87p{margin:0;color:#a0a0a8}code{color:#ededef;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.9em}`;
88
89let styleHash: Promise<string> | null = null;
90
91function hashOfStyle(): Promise<string> {
92 styleHash ??= crypto.subtle
93 .digest("SHA-256", new TextEncoder().encode(DENIED_STYLE))
94 .then((digest) => btoa(String.fromCharCode(...new Uint8Array(digest))));
95 return styleHash;
96}
97
98function escapeHtml(text: string): string {
99 return text.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
100}
101
102/**
103 * A self-contained page for a refusal: its one inline stylesheet is
104 * allowed by its hash, and nothing else is.
105 */
106export async function denied(status: number, title: string, message: string): Promise<Response> {
107 const html = `<!doctype html><html lang="en"><head><meta charset="utf-8">
108<meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex, nofollow">
109<title>${escapeHtml(title)} · sudo</title><style>${DENIED_STYLE}</style></head>
110<body><main><span class="badge">sudo</span><h1>${escapeHtml(title)}</h1><p>${escapeHtml(message)}</p></main></body></html>`;
111 return new Response(html, {
112 status,
113 headers: {
114 "content-type": "text/html; charset=utf-8",
115 "content-security-policy": `default-src 'none'; style-src 'sha256-${await hashOfStyle()}'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'`,
116 },
117 });
118}