g1t/apps/sudo/workers/app.ts

71 lines2,877 bytesCodeBlame
1import { RouterContextProvider, createRequestHandler } from "react-router";
2
3import { authorize, isSameOrigin, readSettings } from "../app/lib/access";
4import { denied, secure } from "../app/lib/guard";
5import { staffContext, zoneContext } from "../app/lib/staff";
6import { readZone } from "../app/lib/time";
7
8const requestHandler = createRequestHandler(
9 () => import("virtual:react-router/server-build"),
10 import.meta.env.MODE,
11);
12
13/** Files the build emits for the pages; still behind the same check. */
14const ASSET = /^\/(?:assets\/[\w.-]+|favicon\.svg)$/;
15
16/**
17 * Every request, assets included, passes the same gate before anything
18 * is served:
19 *
20 * 1. sudo is configured, or nothing is served at all;
21 * 2. Cloudflare Access's token verifies (signature, audience, issuer, time);
22 * 3. its email is on the staff list;
23 * 4. a change is a POST from sudo's own pages.
24 */
25async function handle(request: Request, env: Env): Promise<Response> {
26 const settings = readSettings(env);
27 if (!settings) {
28 return denied(
29 403,
30 "sudo is not configured",
31 "ACCESS_TEAM_DOMAIN, ACCESS_AUD and STAFF_EMAILS must all be set before sudo will answer. See apps/sudo/README.md.",
32 );
33 }
34
35 const auth = await authorize(request, settings);
36 if (!auth.ok) {
37 console.warn(JSON.stringify({ event: "sudo.denied", reason: auth.reason, email: auth.email ?? null, path: new URL(request.url).pathname }));
38 return auth.reason === "not staff"
39 ? denied(403, "Not staff", `${auth.email} is signed in, but is not on sudo's staff list.`)
40 : denied(403, "Not allowed", "sudo is for g1t staff, signed in through Cloudflare Access.");
41 }
42
43 const { method } = request;
44 if (method !== "GET" && method !== "HEAD" && method !== "POST") {
45 return denied(405, "Method not allowed", "sudo takes GET and POST only.");
46 }
47 if (method === "POST" && !isSameOrigin(request)) {
48 console.warn(JSON.stringify({ event: "sudo.cross_site", email: auth.email, origin: request.headers.get("origin") }));
49 return denied(403, "Refused", "Changes are only accepted from sudo's own pages.");
50 }
51
52 const { pathname } = new URL(request.url);
53 if (method !== "POST" && ASSET.test(pathname)) {
54 return env.ASSETS.fetch(request);
55 }
56
57 if (method === "POST") {
58 console.log(JSON.stringify({ event: "sudo.change", email: auth.email, path: pathname }));
59 }
60 const context = new RouterContextProvider();
61 context.set(staffContext, { email: auth.email });
62 // Times in the staff member's zone: their choice (/timezone), else where Cloudflare places them.
63 context.set(zoneContext, readZone(request.headers.get("cookie"), (request as { cf?: { timezone?: unknown } }).cf?.timezone));
64 return requestHandler(request, context);
65}
66
67export default {
68 async fetch(request, env) {
69 return secure(await handle(request, env));
70 },
71} satisfies ExportedHandler<Env>;