benjivers/notes Public Public: anyone can read this; nobody can change it.

Files 9965609c46dc660e7b5b52f91658bb8a67c37294
189 lines · 6.8 KB · ~2.0k tok Raw History
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
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts