Public: anyone can read this; the crew can change it.

Security: a board link is 41 bits of guessing — widen the id and put a brake on misses #125

Closed opened this
Closed without merging

scout/tempo:scout/72-security-a-board-link-is-41-bits-of-gues → main 2 commits fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
10 files changed +190 −23
README.md +16 −1
@@ -142,7 +142,12 @@ background, reverting if it fails.
142142 in adds *attribution and a list you can find it in later*, not
143143 privacy. Anyone with the link can still see and edit any board,
144144 signed in or not — tempo never asks a visitor to sign in just to use
145 a board they were given the link to.
145 a board they were given the link to. That id is 12 lowercase letters
146 and digits — 36^12, about 4.7×10^18 possibilities, roughly 62 bits —
147 drawn from the platform's CSPRNG without modulo bias (`randomId` in
148 `src/db.ts`). A board made before this was widened kept its original
149 8-character id — 36^8, about 2.8×10^12, roughly 41 bits — and still
150 works; every id check accepts either length.
146151 - tempo asks crawlers to stay out of `/b/` (and `/mcp`): every response
147152 under those paths carries `x-robots-tag: noindex, nofollow, noarchive`,
148153 a board page repeats it in a `<meta name="robots">`, and `/robots.txt`
@@ -219,6 +224,16 @@ and the disk, so `src/limits.ts` names a floor:
219224 flood of new boards, *not* per-person fairness: costing a stranger's
220225 flood is the whole point, and it's cheap to check before anything is
221226 written.
227 - **Asking for a board that isn't there** is throttled the same way — a
228 single, app-wide token bucket (20 a minute, `boardMissBucket` in
229 `src/limits.ts`), because a plain read that answers 404 costs nothing to
230 ask for again and again, and the id is this app's entire access control
231 (see "What's simulated"). This is not per-caller fairness either, for
232 the same reason `boardCreationBucket` isn't: a real board's own visitor
233 never has occasion to ask for one that doesn't exist, so nothing
234 legitimate ever spends from it, and turning a scanner's fast, free 404s
235 into a slow trickle of 429s is the whole point. Past the bucket, a miss
236 answers `429` with `retry-after` instead of another 404.
222237 - **Every other write** — a card or column added, moved, edited or
223238 deleted, a board renamed, claimed or undone, and the two writing MCP
224239 tools — is throttled at 300 a minute *per caller*: a signed-in write is
src/db.test.ts +22
@@ -196,6 +196,28 @@ test("a write from another process holding the write lock does not fail claimBoa
196196 expect(db.getBoard(board.id)!.name).toBe("touched-by-other-process");
197197 }, 15000);
198198
199 test("a new board's id is 12 characters — the same length as a column or card's, not the old 8", () => {
200 const board = db.createBoard("Board");
201 expect(board.id).toMatch(/^[a-z0-9]{12}$/);
202 });
203
204 test("randomId draws from its alphabet without modulo bias", () => {
205 const sample = db.randomId(200_000);
206 const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
207 const counts = new Map<string, number>();
208 for (const ch of sample) counts.set(ch, (counts.get(ch) ?? 0) + 1);
209 const expected = sample.length / alphabet.length;
210 for (const ch of alphabet) {
211 const count = counts.get(ch) ?? 0;
212 // A biased draw (plain byte % 36) puts the alphabet's first 4 letters
213 // at 8/256 instead of 7/256 — noticeably more than 10% high. A flat
214 // draw's own sampling noise at this size is under 2%, so 10% either
215 // way only catches a real bias, never a fluke.
216 expect(count).toBeGreaterThan(expected * 0.9);
217 expect(count).toBeLessThan(expected * 1.1);
218 }
219 });
220
199221 test("every board returned by createBoard has exactly its three default columns", () => {
200222 for (let i = 0; i < 5; i++) {
201223 const board = db.createBoard(`Board ${i}`);
src/db.ts +21 −9
@@ -81,13 +81,24 @@ export type Card = {
8181 updated_at: string;
8282 };
8383
84 // An id doubles as the unguessable part of a board's URL, so it comes
85 // from the platform's CSPRNG, never Math.random.
84 // An id doubles as the unguessable part of a board's URL, so it comes from
85 // the platform's CSPRNG, never Math.random. 256 is not a multiple of
86 // alphabet.length, so a plain `byte % alphabet.length` draws the first
87 // `256 % alphabet.length` letters of the alphabet slightly more often than
88 // the rest; rejecting any byte at or past the largest multiple of
89 // alphabet.length that fits in a byte removes that bias, at the cost of
90 // occasionally discarding a byte and drawing another.
8691 const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
87 function randomId(length: number): string {
88 const bytes = new Uint8Array(length);
89 crypto.getRandomValues(bytes);
90 return Array.from(bytes, (b) => alphabet[b % alphabet.length]).join("");
92 const REJECT_AT = alphabet.length * Math.floor(256 / alphabet.length);
93 export function randomId(length: number): string {
94 const byte = new Uint8Array(1);
95 let id = "";
96 while (id.length < length) {
97 crypto.getRandomValues(byte);
98 if (byte[0] >= REJECT_AT) continue;
99 id += alphabet[byte[0] % alphabet.length];
100 }
101 return id;
91102 }
92103
93104 type Payload = {
@@ -136,8 +147,9 @@ function logAction(boardId: string, label: string, payload: Omit<Payload, "label
136147 // revision is a counter bumped in the same transaction as every write that
137148 // changes what that scope shows, never derived from the action log — the
138149 // log is trimmed and undone, so it is not a clock.
139 // "__boards__" is not a valid board id (those are 8 lowercase/digit
140 // characters from randomId), so it cannot collide with one.
150 // "__boards__" is not a valid board id (those are 8 or 12 lowercase/digit
151 // characters from randomId — 8 from before a board link's id was widened,
152 // 12 since; see "Limits" in the README), so it cannot collide with one.
141153 const BOARDS_SCOPE = "__boards__";
142154
143155 function bumpRevision(scope: string): void {
@@ -165,7 +177,7 @@ const BOARD_FIELDS = "id, name, owner_id, owner_name, created_at";
165177
166178 export function createBoard(name: string, owner?: { id: string; name: string }): Board {
167179 return db.transaction((): Board => {
168 const id = randomId(8);
180 const id = randomId(12);
169181 db.query(`INSERT INTO board (id, name, owner_id, owner_name) VALUES (?1, ?2, ?3, ?4)`).run(
170182 id,
171183 name,
src/keys.ts +4 −2
@@ -77,8 +77,10 @@ export function visibleBoards(board: BoardStore, ownerId: string | null): Board[
7777 return ownerId === null ? [] : board.listBoardsByOwner(ownerId);
7878 }
7979
80 /** A board id as `src/db.ts` mints them: the only text `o` will look up. */
81 const boardIdRe = /^[a-z0-9]{8}$/;
80 // A board id as `src/db.ts` mints them, the only text `o` will look up: 12
81 // characters now, or 8 from before a board link's id was widened (see
82 // README, "Limits") — both still open.
83 const boardIdRe = /^[a-z0-9]{8}([a-z0-9]{4})?$/;
8284
8385 /** The cards of one column that the current filter leaves showing. */
8486 export function visibleCards(board: BoardStore, state: State, columnId: string): Card[] {
src/limits.test.ts +21 −1
@@ -2,7 +2,18 @@ import { beforeEach, expect, test } from "bun:test";
22 import { mkdtempSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
5 import { boardCreationBucket, callerKey, KeyedTokenBucket, MAX_CARDS_PER_BOARD, MAX_COLUMNS_PER_BOARD, MAX_UNDO_DEPTH, resetLimits, TokenBucket, writeBucket } from "./limits.ts";
5 import {
6 boardCreationBucket,
7 boardMissBucket,
8 callerKey,
9 KeyedTokenBucket,
10 MAX_CARDS_PER_BOARD,
11 MAX_COLUMNS_PER_BOARD,
12 MAX_UNDO_DEPTH,
13 resetLimits,
14 TokenBucket,
15 writeBucket,
16 } from "./limits.ts";
617 import { ANON } from "./who.ts";
718
819 process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-"));
@@ -126,6 +137,15 @@ test("writeBucket is its own bucket, separate from board creation's: draining on
126137 expect(boardCreationBucket.take()).toBe(true);
127138 });
128139
140 test("boardMissBucket is its own bucket, separate from writes and board creation", () => {
141 while (boardMissBucket.take()) {
142 /* spend every token boardMissBucket has */
143 }
144 expect(writeBucket.take("miss-drain-test")).toBe(true);
145 expect(boardCreationBucket.take()).toBe(true);
146 boardMissBucket.reset();
147 });
148
129149 test("a keyed bucket refuses one caller without touching another's tokens", () => {
130150 let now = 0;
131151 const bucket = new KeyedTokenBucket(3, 10, () => now);
src/limits.ts +10
@@ -112,6 +112,15 @@ export class TokenBucket {
112112 // written, that a single app-wide brake is enough — see README.
113113 export const boardCreationBucket = new TokenBucket(MAX_BOARDS_CREATED_PER_MINUTE);
114114
115 // Asking for a board that isn't there costs nothing today — a plain read,
116 // no write to even fail — so nothing before this stood between a scanner
117 // and asking as fast as the network allows. A real board's own visitor has
118 // no occasion to ever ask for one that doesn't exist, so nothing legitimate
119 // spends from this; a single app-wide bucket, same reasoning as
120 // boardCreationBucket above, not per-caller fairness (see README).
121 export const MAX_BOARD_MISSES_PER_MINUTE = 20;
122 export const boardMissBucket = new TokenBucket(MAX_BOARD_MISSES_PER_MINUTE);
123
115124 /**
116125 * One TokenBucket per caller, so a stranger flooding writes only ever
117126 * spends their own bucket dry, never anyone else's — the mistake an
@@ -186,6 +195,7 @@ export const writeBucket = new KeyedTokenBucket(MAX_WRITES_PER_MINUTE, MAX_WRITE
186195 */
187196 export function resetLimits(): void {
188197 boardCreationBucket.reset();
198 boardMissBucket.reset();
189199 writeBucket.reset();
190200 }
191201
src/mcp.ts +3 −1
@@ -21,7 +21,9 @@ function publish(boardId: string, who: Who, kind: bus.EventKind, ids: Record<str
2121 noteRevision(boardId, db.boardRevision(boardId));
2222 }
2323
24 const BOARD_ID = /^[a-z0-9]{8}$/;
24 // 8 characters from before a board link's id was widened to 12; both still
25 // work (see README, "Limits").
26 const BOARD_ID = /^[a-z0-9]{8}([a-z0-9]{4})?$/;
2527 const ENTITY_ID = /^[a-z0-9]{12}$/;
2628
2729 type Result<T> = { ok: true; value: T } | { ok: false; error: string; message: string };
src/routes.ts +28 −5
@@ -7,7 +7,15 @@
77 import * as bus from "./bus.ts";
88 import type { EventKind } from "./bus.ts";
99 import * as db from "./db.ts";
10 import { boardCreationBucket, callerKey, MAX_CARDS_PER_BOARD, MAX_COLUMNS_PER_BOARD, MAX_SUBSCRIBERS_PER_BOARD, writeBucket } from "./limits.ts";
10 import {
11 boardCreationBucket,
12 boardMissBucket,
13 callerKey,
14 MAX_CARDS_PER_BOARD,
15 MAX_COLUMNS_PER_BOARD,
16 MAX_SUBSCRIBERS_PER_BOARD,
17 writeBucket,
18 } from "./limits.ts";
1119 import { rpc } from "./mcp.ts";
1220 import { boardPage, landingPage } from "./pages/board.ts";
1321 import * as presence from "./presence.ts";
@@ -16,7 +24,9 @@ import { cleanText } from "./text.ts";
1624 import { noteRevision } from "./watch.ts";
1725 import type { Who } from "./who.ts";
1826
19 const BOARD_ID = /^[a-z0-9]{8}$/;
27 // 8 characters from before a board link's id was widened to 12; both still
28 // work (see README, "Limits").
29 const BOARD_ID = /^[a-z0-9]{8}([a-z0-9]{4})?$/;
2030 const ENTITY_ID = /^[a-z0-9]{12}$/;
2131
2232 // The client that made a write, so its own tab can ignore the echo of a
@@ -138,6 +148,19 @@ function writeRefused(request: Request, who: Who, boardId: string, key: string):
138148 return boardBad(request, who, boardId, "too many changes just now — try again shortly", 429, { "retry-after": retryAfter });
139149 }
140150
151 // A well-formed id naming no board is a miss — a stale link or a guess,
152 // indistinguishable from here — and it spends one of boardMissBucket's
153 // tokens (src/limits.ts): plenty for the rare stale link, not for a scan
154 // asking as fast as the network allows. Once the bucket is dry this answers
155 // 429 instead of another 404, so a scanner can no longer ask for free.
156 function boardMiss(): Response {
157 if (boardMissBucket.take()) return new Response("board not found", { status: 404 });
158 return new Response("too many missing boards asked for just now — try again shortly", {
159 status: 429,
160 headers: { "retry-after": String(boardMissBucket.retryAfterSeconds()) },
161 });
162 }
163
141164
142165 // A missing content-type is left alone: it parses as an empty form today
143166 // (a bare `curl -X POST` with no body), and neither the board script nor a
@@ -284,7 +307,7 @@ async function dispatch(request: Request, url: URL, env: string, who: Who, ip: s
284307 if (parts.length === 2) {
285308 if (method !== "GET") return methodNotAllowed("GET, HEAD");
286309 const board = db.getBoard(boardId);
287 if (!board) return new Response("board not found", { status: 404 });
310 if (!board) return boardMiss();
288311 const columns = db.listColumns(boardId);
289312 const cards = db.listCards(boardId);
290313 const query = url.searchParams.get("q") ?? "";
@@ -292,11 +315,11 @@ async function dispatch(request: Request, url: URL, env: string, who: Who, ip: s
292315 }
293316
294317 if (method === "GET" && parts.length === 3 && parts[2] === "events") {
295 if (!db.getBoard(boardId)) return new Response("board not found", { status: 404 });
318 if (!db.getBoard(boardId)) return boardMiss();
296319 return eventStream(boardId, request, who);
297320 }
298321
299 if (!db.getBoard(boardId)) return new Response("board not found", { status: 404 });
322 if (!db.getBoard(boardId)) return boardMiss();
300323
301324 if (method === "POST" && parts.length === 3 && parts[2] === "claim") {
302325 if (who.role === "anon") return new Response("sign in to keep a board", { status: 401 });
src/server.test.ts +55 −4
@@ -8,9 +8,17 @@ process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-"));
88 process.env.PORT = "0";
99 process.env.FORGE_APP_IDENTITY_KEY = "test-key";
1010 const { server } = await import("./server.ts");
11 const { sign } = await import("./who.ts");
12 const { MAX_CARDS_PER_BOARD, MAX_COLUMNS_PER_BOARD, MAX_REQUEST_BODY_BYTES, MAX_SUBSCRIBERS_PER_BOARD, resetLimits } =
13 await import("./limits.ts");
11 const { route } = await import("./routes.ts");
12 const { sign, ANON } = await import("./who.ts");
13 const {
14 MAX_BOARD_MISSES_PER_MINUTE,
15 MAX_CARDS_PER_BOARD,
16 MAX_COLUMNS_PER_BOARD,
17 MAX_REQUEST_BODY_BYTES,
18 MAX_SUBSCRIBERS_PER_BOARD,
19 boardMissBucket,
20 resetLimits,
21 } = await import("./limits.ts");
1422 const db = await import("./db.ts");
1523 const { THEME_JS } = await import("./pages/board.ts");
1624 // The rate limits are process-wide state shared by every test file in one
@@ -112,7 +120,7 @@ test("creates a board with default columns and a shareable URL", async () => {
112120 const created = await fetch(base + "/boards", { method: "POST", body: new URLSearchParams({ name: "Launch" }), redirect: "manual" });
113121 expect(created.status).toBe(303);
114122 const location = created.headers.get("location")!;
115 expect(location).toMatch(/^\/b\/[a-z0-9]{8}$/);
123 expect(location).toMatch(/^\/b\/[a-z0-9]{12}$/);
116124 const page = await (await fetch(base + location)).text();
117125 expect(page).toContain("Launch");
118126 expect(page).toContain("To do");
@@ -120,6 +128,27 @@ test("creates a board with default columns and a shareable URL", async () => {
120128 expect(page).toContain("Done");
121129 });
122130
131 test("a board with the old 8-character id still opens, through the page and through MCP", async () => {
132 // A board created before ids were widened to 12 characters (issue #72) —
133 // inserted directly, since db.createBoard only ever mints 12 now.
134 const legacyId = "legacy8x";
135 db.db.query("INSERT INTO board (id, name) VALUES (?1, ?2)").run(legacyId, "Legacy board");
136 const columnId = db.createColumn(legacyId, "To do");
137 if (typeof columnId === "string") throw new Error("expected a Column");
138
139 const page = await fetch(base + `/b/${legacyId}`);
140 expect(page.status).toBe(200);
141 expect(await page.text()).toContain("Legacy board");
142
143 const rpcCall = (body: unknown) =>
144 fetch(base + "/mcp", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
145 const listed = await (
146 await rpcCall({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "list_columns", arguments: { board_id: legacyId } } })
147 ).json();
148 const columns = JSON.parse(listed.result.content[0].text);
149 expect(columns.map((c: { name: string }) => c.name)).toEqual(["To do"]);
150 });
151
123152 test("404s for an unknown board", async () => {
124153 const response = await fetch(base + "/b/zzzzzzzz");
125154 expect(response.status).toBe(404);
@@ -130,6 +159,28 @@ test("rejects a malformed board id", async () => {
130159 expect(response.status).toBe(404);
131160 });
132161
162 // route() directly, not fetch: boardMissBucket refills off the real clock
163 // (see limits.ts), and a socket round trip is enough time for a token to
164 // trickle back at this bucket's rate — same reasoning as write-limit.test.ts.
165 test("scanning for boards that don't exist runs the bucket dry: a miss costs 429, not another free 404", async () => {
166 boardMissBucket.reset();
167 const miss = () => {
168 const request = new Request(base + "/b/zzzzzzzzzzzz");
169 return route(request, new URL(request.url), "test", ANON, "203.0.113.9").then((r) => r!);
170 };
171
172 for (let i = 0; i < MAX_BOARD_MISSES_PER_MINUTE; i++) {
173 const response = await miss();
174 expect(response.status).toBe(404);
175 }
176
177 const refused = await miss();
178 expect(refused.status).toBe(429);
179 expect(refused.headers.get("retry-after")).toMatch(/^\d+$/);
180
181 boardMissBucket.reset();
182 });
183
133184 test("adds, moves, edits and deletes a card, and undo reverses the last change", async () => {
134185 const id = await newBoard();
135186 const boardUrl = `/b/${id}`;
src/tty.test.ts +10
@@ -109,6 +109,16 @@ test("o opens any board by id, kept or not, regardless of who is at the keyboard
109109 expect(opened.boardId).toBe(kept.id);
110110 });
111111
112 test("o opens a board that still has the old 8-character id, the same as a 12-character one", () => {
113 // A board created before ids were widened to 12 characters (issue #72) —
114 // inserted directly, since db.createBoard only ever mints 12 now.
115 db.db.query("INSERT INTO board (id, name) VALUES (?1, ?2)").run("legacy8c", "Legacy");
116
117 const opened = press(initialState(), "o", ...type("legacy8c"), "\r");
118 expect(opened.mode).toBe("board");
119 expect(opened.boardId).toBe("legacy8c");
120 });
121
112122 test("o with an id that is not a board, or not an id at all, stays in the list", () => {
113123 for (const text of ["", "nosuchbd", "../../etc", "'; drop table board--"]) {
114124 const after = press(initialState(), "o", ...type(text), "\r");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs