pike/glyph Public Public: anyone can read this; nobody can change it.
forked from apps/glyph
workbenchthis machine
Files 20b002cea95af30a05955adee40ddec8a6b7120a
| 1 | /** |
| 2 | * The door: what every request passes before a route sees it, and before |
| 3 | * a byte of its body is read. Three checks, in order: |
| 4 | * |
| 5 | * 1. **Rationing.** Writes are rationed per person (per address, for a |
| 6 | * stranger), and a stranger's calls to the API are rationed too. |
| 7 | * 2. **Same origin.** A write that a browser says came from another site |
| 8 | * is refused: that is another page making this person's browser act. |
| 9 | * 3. **The contract.** A request under a resource's `api` is that |
| 10 | * resource's verb (see docs/CONTRACT.md in the forge: `GET api` lists, |
| 11 | * `POST api` creates, `GET|PATCH|DELETE api/<id>` read, update, |
| 12 | * delete). If the table lets this person through that door for no row |
| 13 | * at all, they are refused here — 401 for a stranger, 403 for anyone |
| 14 | * else. The action asks again, about the real row; the door only turns |
| 15 | * away who could never pass. `/openapi.json` is shut to strangers when |
| 16 | * the table opens no door to them. |
| 17 | * |
| 18 | * Nothing here compares roles by hand: the answer is `decide`'s, from the |
| 19 | * same table the actions read. Refused writes go into the audit trail. |
| 20 | */ |
| 21 | |
| 22 | import type { Database } from "bun:sqlite"; |
| 23 | import { record } from "./audit.ts"; |
| 24 | import { decide, VERBS, type Contract, type Verb } from "./can.ts"; |
| 25 | import { actorOf, contract } from "./contract.ts"; |
| 26 | import { publicOrigin } from "./env.ts"; |
| 27 | import { json, text } from "./http.ts"; |
| 28 | import type { Who } from "./who.ts"; |
| 29 | |
| 30 | // A browser on another site can make a person's browser POST here; it cannot |
| 31 | // make that request look same-origin. Browsers say where a request came from |
| 32 | // (Sec-Fetch-Site, or Origin); a request that names another site is refused. |
| 33 | // A script sends neither and is judged by its identity instead. |
| 34 | export const sameOrigin = (request: Request): boolean => { |
| 35 | const site = request.headers.get("sec-fetch-site"); |
| 36 | if (site !== null) return site === "same-origin" || site === "none"; |
| 37 | const origin = request.headers.get("origin"); |
| 38 | return origin === null || origin === publicOrigin; |
| 39 | }; |
| 40 | |
| 41 | // A bucket of `burst` tokens that refills at `perSecond`. In memory, so a |
| 42 | // restart forgets — which is the right size for one process on one machine. |
| 43 | export interface Rate { |
| 44 | readonly burst: number; |
| 45 | readonly perSecond: number; |
| 46 | } |
| 47 | export const WRITES: Rate = { burst: 20, perSecond: 2 }; |
| 48 | /** A stranger's API calls, reads too. Behind the forge every stranger shares one address, so this is all of them together. */ |
| 49 | export const STRANGERS: Rate = { burst: 60, perSecond: 10 }; |
| 50 | const buckets = new Map<string, { tokens: number; at: number }>(); |
| 51 | export function allow( |
| 52 | key: string, |
| 53 | rate: Rate = WRITES, |
| 54 | now = Date.now(), |
| 55 | ): boolean { |
| 56 | const bucket = buckets.get(key) ?? { tokens: rate.burst, at: now }; |
| 57 | bucket.tokens = Math.min( |
| 58 | rate.burst, |
| 59 | bucket.tokens + ((now - bucket.at) / 1000) * rate.perSecond, |
| 60 | ); |
| 61 | bucket.at = now; |
| 62 | buckets.set(key, bucket); |
| 63 | if (buckets.size > 10_000) |
| 64 | for (const [k, b] of buckets) if (now - b.at > 60_000) buckets.delete(k); |
| 65 | if (bucket.tokens < 1) return false; |
| 66 | bucket.tokens -= 1; |
| 67 | return true; |
| 68 | } |
| 69 | |
| 70 | const ROW_VERB: Readonly<Record<string, Verb>> = { |
| 71 | GET: "read", |
| 72 | PATCH: "update", |
| 73 | DELETE: "delete", |
| 74 | }; |
| 75 | const SET_VERB: Readonly<Record<string, Verb>> = { |
| 76 | GET: "list", |
| 77 | POST: "create", |
| 78 | }; |
| 79 | |
| 80 | /** The resource and verb a request is, by the contract's `api` paths; undefined for any other path. */ |
| 81 | export function doorOf( |
| 82 | method: string, |
| 83 | pathname: string, |
| 84 | table: Contract = contract, |
| 85 | ): { resource: string; verb: Verb } | undefined { |
| 86 | for (const [resource, { api }] of Object.entries(table.resources)) { |
| 87 | if (api === undefined) continue; |
| 88 | if (pathname === api) { |
| 89 | const verb = SET_VERB[method]; |
| 90 | return verb === undefined ? undefined : { resource, verb }; |
| 91 | } |
| 92 | const rest = pathname.startsWith(`${api}/`) |
| 93 | ? pathname.slice(api.length + 1) |
| 94 | : undefined; |
| 95 | if (rest !== undefined && rest !== "" && !rest.includes("/")) { |
| 96 | const verb = ROW_VERB[method]; |
| 97 | return verb === undefined ? undefined : { resource, verb }; |
| 98 | } |
| 99 | } |
| 100 | return undefined; |
| 101 | } |
| 102 | |
| 103 | /** |
| 104 | * Could `who` pass this door for any row at all? The door asks about a row |
| 105 | * that names them in every column a role is held by — the best row they |
| 106 | * could hope for — so it refuses only what no row would open. |
| 107 | */ |
| 108 | export function mayKnock( |
| 109 | who: Who, |
| 110 | resource: string, |
| 111 | verb: Verb, |
| 112 | table: Contract = contract, |
| 113 | ): boolean { |
| 114 | const actor = actorOf(who); |
| 115 | const columns = Object.values(table.roles).flatMap((role) => |
| 116 | role.held.by === "row" ? [role.held.column] : [], |
| 117 | ); |
| 118 | const best = |
| 119 | verb === "list" || verb === "create" || actor.id === null |
| 120 | ? undefined |
| 121 | : Object.fromEntries(columns.map((column) => [column, actor.id])); |
| 122 | return decide(table, resource, verb, actor, best) !== "no"; |
| 123 | } |
| 124 | |
| 125 | /** Does the table open any door with an `api` to a stranger? Then the API's description is theirs to read. */ |
| 126 | export const openToStrangers = (table: Contract = contract): boolean => |
| 127 | Object.entries(table.resources).some( |
| 128 | ([resource, { api }]) => |
| 129 | api !== undefined && |
| 130 | VERBS.some((verb) => mayKnock({ role: "anon" }, resource, verb, table)), |
| 131 | ); |
| 132 | const describedToStrangers = openToStrangers(); |
| 133 | |
| 134 | /** |
| 135 | * The door. `undefined` lets the request through to its route; a response |
| 136 | * is the refusal. `from` is the caller's address, for rationing a stranger. |
| 137 | */ |
| 138 | export function door( |
| 139 | db: Database, |
| 140 | request: Request, |
| 141 | url: URL, |
| 142 | who: Who, |
| 143 | from: string, |
| 144 | ): Response | undefined { |
| 145 | const method = request.method === "HEAD" ? "GET" : request.method; |
| 146 | const writing = method !== "GET"; |
| 147 | const stranger = who.role === "anon"; |
| 148 | const api = |
| 149 | url.pathname.startsWith("/api/") || |
| 150 | url.pathname === "/mcp" || |
| 151 | url.pathname === "/openapi.json"; |
| 152 | const key = stranger ? `ip:${from}` : `user:${who.id}`; |
| 153 | |
| 154 | if (writing && !allow(key)) return text("too many requests", 429); |
| 155 | if (stranger && api && !allow(`api:${from}`, STRANGERS)) |
| 156 | return text("too many requests", 429); |
| 157 | |
| 158 | const found = doorOf(method, url.pathname); |
| 159 | if (writing && !sameOrigin(request)) { |
| 160 | record(db, { |
| 161 | who, |
| 162 | resource: found?.resource ?? url.pathname, |
| 163 | verb: found?.verb ?? method, |
| 164 | outcome: "cross_site", |
| 165 | }); |
| 166 | return text("forbidden", 403); |
| 167 | } |
| 168 | |
| 169 | const shut = |
| 170 | found === undefined |
| 171 | ? url.pathname === "/openapi.json" && stranger && !describedToStrangers |
| 172 | : !mayKnock(who, found.resource, found.verb); |
| 173 | if (!shut) return undefined; |
| 174 | const outcome = stranger ? "signin_required" : "forbidden"; |
| 175 | if (writing && found !== undefined) |
| 176 | record(db, { |
| 177 | who, |
| 178 | resource: found.resource, |
| 179 | verb: found.verb, |
| 180 | target: |
| 181 | url.pathname === contract.resources[found.resource]?.api |
| 182 | ? undefined |
| 183 | : url.pathname.split("/").at(-1), |
| 184 | outcome, |
| 185 | }); |
| 186 | return stranger |
| 187 | ? json({ error: outcome, message: "Sign in first." }, 401) |
| 188 | : json({ error: outcome, message: "You may not." }, 403); |
| 189 | } |