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

Security: the terminal lists every board on the server; a board's id is its access control #45

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/tempo:scout/38-security-the-terminal-lists-every-board → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/38-security-the-terminal-lists-every-board was merged into main as d9a214f.

Review

Sign in to review this change.
8 files changed +168 −68
README.md +34 −17
@@ -80,19 +80,33 @@ background, reverting if it fails.
8080 the server, so it can't be used to go discover a board by guessing.
8181 - Undo is board-wide, not per-visitor, and unaffected by who's signed
8282 in.
83 - The terminal (`src/tty.ts` draws it, `src/keys.ts` decides what each
84 key does) mirrors the board actions with a
85 letter-key scheme (`j/k/h/l` move, `H/J/K/L` move the focused card,
86 `a`/`e`/`d` add/edit/delete, `c`/`r`/`D` add/rename/delete a column,
87 `f` filter, `u` undo) rather than drag-and-drop, since a terminal has
88 no mouse. An ssh session has no verified identity the way a browser's
89 `x-forge-identity` header does, so the terminal can't tell who's
90 typing: its board picker lists only unowned boards, and a board it
91 creates is unowned too, same as a guest's on the web. `o` opens a
92 board by id, the same way a shared link works on the page, so a kept
93 board someone hands you is still reachable. It has no "keep this
94 board" — ownership only ever changes through the web, where it can be
95 attributed to someone.
83 - The terminal (`src/tty.ts` draws it and reads keys, `src/keys.ts`
84 decides what each key does, `src/session.ts` decides who is asking)
85 mirrors the board actions with a letter-key scheme (`j/k/h/l` move,
86 `H/J/K/L` move the focused card, `a`/`e`/`d` add/edit/delete, `c`/`r`/`D`
87 add/rename/delete a column, `f` filter, `u` undo) rather than
88 drag-and-drop, since a terminal has no mouse. An ssh session carries no
89 `x-forge-identity` header, but the forge sets
90 `FORGE_APP_USER_ID`/`FORGE_APP_USER_ROLE` in the process environment for
91 a session it has already authenticated, so the terminal reads those
92 instead (`src/session.ts`) — the board picker is then scoped exactly
93 like `list_boards` over MCP: a guest sees no boards at all, a signed-in
94 person sees only their own, never another person's or anyone's unowned
95 ones. Unlike the header, nothing here is signed: this trusts that the
96 forge's sshd does not forward a client-supplied environment for those
97 two names (no matching `AcceptEnv`) — **unconfirmed as of this
98 writing**; if it turns out sshd does forward them, a client can set
99 `FORGE_APP_USER_ID` to someone else's id and read that person's board
100 list, uncaught, since there's no signature here the way there is on
101 `x-forge-identity`. `o` opens a board by id regardless of who owns it, the same way
102 a shared link works on the page, so a board someone hands you is still
103 reachable. A board the terminal creates is still unowned, same as a
104 guest's on the web — there's no username in those two env vars to
105 attribute it to, and it has no "keep this board"; ownership only ever
106 changes through the web, where it can be. Board mode's header shows the
107 board's `/b/<id>` address the way the picker does, so a board made with
108 `n` — unowned, and so invisible to the picker even for the person who
109 just made it — stays reachable afterwards.
96110 - The event stream is this one process's: the terminal is a separate
97111 process on the same database, so a change made there shows up on the
98112 next page load, not live.
@@ -129,10 +143,13 @@ The forge sets `PORT`, `FORGE_APP_DATA`, `FORGE_APP_ENV`
129143 `FORGE_APP_IDENTITY_KEY` when it starts the app. `forge.json` says how.
130144 Identity comes only from `x-forge-identity` / `x-forge-signature`,
131145 verified against `FORGE_APP_IDENTITY_KEY` (`src/who.ts`); unset, everyone
132 is anonymous. The terminal has no such header, and nothing else in this
133 repository names who is at the other end of an ssh session, so it never
134 claims an identity: every board it creates is a guest's, same as an
135 anonymous browser's.
146 is anonymous. The terminal has no such header; it reads
147 `FORGE_APP_USER_ID` and `FORGE_APP_USER_ROLE` from the environment
148 instead (`src/session.ts`), which the forge is expected to set on an ssh
149 session it has already authenticated — see the sshd caveat under "What's
150 simulated" — enough to scope which boards the picker lists, not enough
151 to name someone, so every board the terminal creates is still a guest's,
152 same as an anonymous browser's.
136153
137154 ## Dependencies
138155
src/db.test.ts +3 −8
@@ -95,20 +95,15 @@ test("undo returns false with nothing to undo", () => {
9595 expect(db.undo("no-such-board")).toBe(false);
9696 });
9797
98 test("listBoardsForOwner is a person's own boards plus unowned ones, never another person's", () => {
98 test("listBoardsByOwner is a person's own boards only, never unowned ones or another person's", () => {
9999 const mine = db.createBoard("Mine", { id: "u1", name: "Alice" });
100100 const unowned = db.createBoard("Unowned");
101101 const theirs = db.createBoard("Theirs", { id: "u2", name: "Bob" });
102102
103 const visibleToAlice = db.listBoardsForOwner("u1").map((b) => b.id);
103 const visibleToAlice = db.listBoardsByOwner("u1").map((b) => b.id);
104104 expect(visibleToAlice).toContain(mine.id);
105 expect(visibleToAlice).toContain(unowned.id);
105 expect(visibleToAlice).not.toContain(unowned.id);
106106 expect(visibleToAlice).not.toContain(theirs.id);
107
108 const visibleToGuest = db.listBoardsForOwner(null).map((b) => b.id);
109 expect(visibleToGuest).toContain(unowned.id);
110 expect(visibleToGuest).not.toContain(mine.id);
111 expect(visibleToGuest).not.toContain(theirs.id);
112107 });
113108
114109 // Simulates the ssh terminal and the web server as two separate processes
src/db.ts −11
@@ -128,17 +128,6 @@ export function listBoardsByOwner(ownerId: string): Board[] {
128128 return db.query<Board, [string]>(`SELECT ${BOARD_FIELDS} FROM board WHERE owner_id = ?1 ORDER BY created_at DESC`).all(ownerId);
129129 }
130130
131 // The terminal's board picker (src/tty.ts): the same rule the web page's
132 // `/` follows — a person's own boards plus anyone's unowned ones, never
133 // another person's. `ownerId` null (no FORGE_APP_USER) is a guest: only
134 // the unowned boards.
135 export function listBoardsForOwner(ownerId: string | null): Board[] {
136 if (ownerId === null) return db.query<Board, []>(`SELECT ${BOARD_FIELDS} FROM board WHERE owner_id IS NULL ORDER BY created_at DESC`).all();
137 return db
138 .query<Board, [string]>(`SELECT ${BOARD_FIELDS} FROM board WHERE owner_id = ?1 OR owner_id IS NULL ORDER BY created_at DESC`)
139 .all(ownerId);
140 }
141
142131 export function getBoard(id: string): Board | null {
143132 return db.query<Board, [string]>(`SELECT ${BOARD_FIELDS} FROM board WHERE id = ?1`).get(id);
144133 }
src/keys.ts +18 −13
@@ -17,7 +17,7 @@ import { MAX_CARDS_PER_BOARD, MAX_COLUMNS_PER_BOARD } from "./limits.ts";
1717
1818 /** The `src/db.ts` functions the keys reach for, passed in rather than imported. */
1919 export type BoardStore = {
20 listBoardsForOwner(ownerId: string | null): Board[];
20 listBoardsByOwner(ownerId: string): Board[];
2121 getBoard(boardId: string): Board | null;
2222 listColumns(boardId: string): Column[];
2323 listCards(boardId: string): Card[];
@@ -47,6 +47,7 @@ export type Prompt =
4747
4848 export type State = {
4949 mode: Mode;
50 ownerId: string | null;
5051 boardsCursor: number;
5152 boardId?: string;
5253 columnCursor: number;
@@ -58,18 +59,18 @@ export type State = {
5859
5960 export type Outcome = { state: State; quit: boolean };
6061
61 export function initialState(): State {
62 return { mode: "boards", boardsCursor: 0, columnCursor: 0, cardCursor: 0, filter: "", message: "" };
62 export function initialState(ownerId: string | null = null): State {
63 return { mode: "boards", ownerId, boardsCursor: 0, columnCursor: 0, cardCursor: 0, filter: "", message: "" };
6364 }
6465
65 // The terminal has no verified identity — nothing here plays the part of
66 // the forge's signed x-forge-identity header — so it can't tell who is
67 // typing and must not mint ownership on their behalf. A board it creates
68 // is unowned, exactly like a guest's on the web; the picker lists only
69 // unowned boards, and a kept board is reached by its id (press o), the
70 // same way a shared link works on the page.
71 export function visibleBoards(board: BoardStore): Board[] {
72 return board.listBoardsForOwner(null);
66 // The picker is scoped exactly like the MCP's list_boards (src/mcp.ts): a
67 // guest (`ownerId` null — no verified FORGE_APP_USER_ID/ROLE for this ssh
68 // session) sees no boards at all, and a signed-in person sees only their
69 // own, never another person's or anyone's unowned ones. A board's id
70 // remains its access control either way — `o` opens one by id regardless
71 // of who owns it, the same way a shared link works on the page.
72 export function visibleBoards(board: BoardStore, ownerId: string | null): Board[] {
73 return ownerId === null ? [] : board.listBoardsByOwner(ownerId);
7374 }
7475
7576 /** A board id as `src/db.ts` mints them: the only text `o` will look up. */
@@ -118,8 +119,12 @@ function handlePromptKey(state: State, key: string, board: BoardStore): Outcome
118119 function submitPrompt(state: State, prompt: Prompt, text: string, board: BoardStore): State {
119120 switch (prompt.kind) {
120121 case "newBoard": {
122 // Unowned, same as a guest's on the web (there's no name to attribute
123 // it to — see visibleBoards) — so unless it's kept through the web,
124 // its address is the only way back to it. board mode's header shows
125 // it too; this puts it in state as well, so it survives a redraw.
121126 const created = board.createBoard(text || "New board");
122 return { ...state, boardId: created.id, mode: "board", columnCursor: 0, cardCursor: 0 };
127 return { ...state, boardId: created.id, mode: "board", columnCursor: 0, cardCursor: 0, message: `created /b/${created.id}` };
123128 }
124129 case "openById": {
125130 const found = boardIdRe.test(text) ? board.getBoard(text) : null;
@@ -148,7 +153,7 @@ function submitPrompt(state: State, prompt: Prompt, text: string, board: BoardSt
148153 }
149154
150155 function handleBoardsKey(state: State, key: string, board: BoardStore): Outcome {
151 const boards = visibleBoards(board);
156 const boards = visibleBoards(board, state.ownerId);
152157 const next = { ...state };
153158 if (key === "j" || key === "\x1b[B") next.boardsCursor = Math.min(state.boardsCursor + 1, Math.max(0, boards.length - 1));
154159 else if (key === "k" || key === "\x1b[A") next.boardsCursor = Math.max(state.boardsCursor - 1, 0);
src/session.test.ts added +29
@@ -0,0 +1,29 @@
1 import { expect, test } from "bun:test";
2 import { ownerIdFromEnv } from "./session.ts";
3
4 test("role unset is a guest", () => {
5 expect(ownerIdFromEnv({})).toBeNull();
6 });
7
8 test("an unrecognised role is a guest", () => {
9 expect(ownerIdFromEnv({ FORGE_APP_USER_ROLE: "anon", FORGE_APP_USER_ID: "u1" })).toBeNull();
10 expect(ownerIdFromEnv({ FORGE_APP_USER_ROLE: "wizard", FORGE_APP_USER_ID: "u1" })).toBeNull();
11 });
12
13 test("user or admin with no id is a guest", () => {
14 expect(ownerIdFromEnv({ FORGE_APP_USER_ROLE: "user" })).toBeNull();
15 expect(ownerIdFromEnv({ FORGE_APP_USER_ROLE: "user", FORGE_APP_USER_ID: "" })).toBeNull();
16 expect(ownerIdFromEnv({ FORGE_APP_USER_ROLE: "admin", FORGE_APP_USER_ID: "" })).toBeNull();
17 });
18
19 test("an id with no role at all is a guest", () => {
20 expect(ownerIdFromEnv({ FORGE_APP_USER_ID: "u1" })).toBeNull();
21 });
22
23 test("role user with an id is that id", () => {
24 expect(ownerIdFromEnv({ FORGE_APP_USER_ROLE: "user", FORGE_APP_USER_ID: "u1" })).toBe("u1");
25 });
26
27 test("role admin with an id is that id too", () => {
28 expect(ownerIdFromEnv({ FORGE_APP_USER_ROLE: "admin", FORGE_APP_USER_ID: "u2" })).toBe("u2");
29 });
src/session.ts added +19
@@ -0,0 +1,19 @@
1 /**
2 * Who is at the other end of an ssh session — the terminal's counterpart
3 * to `src/who.ts`, which verifies a signed header on an HTTP request.
4 *
5 * There is no header or signature here: the forge sets `FORGE_APP_USER_ID`
6 * and `FORGE_APP_USER_ROLE` in the process environment of a `src/tty.ts`
7 * it spawns for an ssh session it has already authenticated. That is only
8 * as trustworthy as the forge's sshd not forwarding a client-supplied
9 * environment for those two names (an `AcceptEnv` match would let a
10 * stranger set `FORGE_APP_USER_ID` to someone else's id, with no signature
11 * to catch it, unlike `x-forge-identity`) — unconfirmed here; see the
12 * README's "What's simulated".
13 */
14
15 export function ownerIdFromEnv(env: Record<string, string | undefined>): string | null {
16 const role = env.FORGE_APP_USER_ROLE;
17 const id = env.FORGE_APP_USER_ID;
18 return (role === "user" || role === "admin") && !!id ? id : null;
19 }
src/tty.test.ts +52 −15
@@ -39,26 +39,31 @@ const titlesIn = (boardId: string, index: number): string[] => {
3939 };
4040
4141 test("j/k and the arrow keys move the board-list cursor and clamp at both ends", () => {
42 db.createBoard("One");
43 db.createBoard("Two");
44 db.createBoard("Three");
45 const last = visibleBoards(db).length - 1;
42 const owner = { id: "cursor-owner", name: "Cursor" };
43 db.createBoard("One", owner);
44 db.createBoard("Two", owner);
45 db.createBoard("Three", owner);
46 const last = visibleBoards(db, owner.id).length - 1;
47 const signedIn = initialState(owner.id);
4648
47 expect(press(initialState(), "k").boardsCursor).toBe(0);
48 expect(press(initialState(), "\x1b[A").boardsCursor).toBe(0);
49 expect(press(initialState(), "j").boardsCursor).toBe(1);
50 expect(press(initialState(), "\x1b[B", "\x1b[B").boardsCursor).toBe(2);
49 expect(press(signedIn, "k").boardsCursor).toBe(0);
50 expect(press(signedIn, "\x1b[A").boardsCursor).toBe(0);
51 expect(press(signedIn, "j").boardsCursor).toBe(1);
52 expect(press(signedIn, "\x1b[B", "\x1b[B").boardsCursor).toBe(2);
5153
52 const bottom = press(initialState(), ...Array(last + 3).fill("j"));
54 const bottom = press(signedIn, ...Array(last + 3).fill("j"));
5355 expect(bottom.boardsCursor).toBe(last);
5456 expect(press(bottom, "j", "k").boardsCursor).toBe(last - 1);
5557 });
5658
5759 test("enter opens the board under the cursor and b goes back to the list", () => {
58 db.createBoard("Openable");
59 const boards = visibleBoards(db);
60 const owner = { id: "enter-owner", name: "Enter" };
61 db.createBoard("Openable one", owner);
62 db.createBoard("Openable two", owner);
63 const signedIn = initialState(owner.id);
64 const boards = visibleBoards(db, owner.id);
6065
61 const opened = press(initialState(), "j", "\r");
66 const opened = press(signedIn, "j", "\r");
6267 expect(opened.mode).toBe("board");
6368 expect(opened.boardId).toBe(boards[1].id);
6469 expect(opened.columnCursor).toBe(0);
@@ -70,9 +75,25 @@ test("enter opens the board under the cursor and b goes back to the list", () =>
7075 expect(back.boardId).toBe(boards[1].id);
7176 });
7277
73 test("the picker lists unowned boards only, and o opens a kept one by its id", () => {
74 const kept = db.createBoard("Kept", { id: "owner-1", name: "Owner" });
75 expect(visibleBoards(db).map((b) => b.id)).not.toContain(kept.id);
78 test("a guest terminal session cannot list any board — not another person's, not even an unowned one", () => {
79 db.createBoard("Kept", { id: "picker-owner", name: "Owner" });
80 db.createBoard("Unowned");
81 expect(visibleBoards(db, null)).toEqual([]);
82
83 const boardsMode = press(initialState(), "j", "j", "j", "j", "\r");
84 expect(boardsMode.mode).toBe("boards");
85 expect(boardsMode.boardId).toBeUndefined();
86 });
87
88 test("a signed-in picker lists only that person's own boards, never another's or an unowned one", () => {
89 const mine = db.createBoard("Mine", { id: "picker-alice", name: "Alice" });
90 db.createBoard("Theirs", { id: "picker-bob", name: "Bob" });
91 db.createBoard("Nobody's");
92 expect(visibleBoards(db, "picker-alice").map((b) => b.id)).toEqual([mine.id]);
93 });
94
95 test("o opens any board by id, kept or not, regardless of who is at the keyboard", () => {
96 const kept = db.createBoard("Kept", { id: "open-owner", name: "Owner" });
7697
7798 const prompted = press(initialState(), "o");
7899 expect(prompted.prompt).toEqual({ kind: "openById", label: "Open board id", value: "" });
@@ -111,6 +132,22 @@ test("n with an empty name creates a board called New board", () => {
111132 expect(db.getBoard(created.boardId!)!.name).toBe("New board");
112133 });
113134
135 test("a board made with n is unowned and stays reachable by its id after b, for a guest and a signed-in session alike", () => {
136 for (const owner of [null, "n-owner"]) {
137 const created = press(initialState(owner), "n", ...type("Findable"), "\r");
138 expect(created.message).toBe(`created /b/${created.boardId}`);
139 expect(db.getBoard(created.boardId!)!.owner_id).toBeNull();
140
141 // The picker won't list it — it's unowned — but its address, shown in
142 // the message above (and in board mode's header), still opens it.
143 const back = press(created, "b");
144 expect(visibleBoards(db, owner).map((b) => b.id)).not.toContain(created.boardId);
145
146 const reopened = press(back, "o", ...type(created.boardId!), "\r");
147 expect(reopened.boardId).toBe(created.boardId);
148 }
149 });
150
114151 test("hjkl and the arrow keys move the board cursors and clamp at both ends", () => {
115152 const { state, boardId } = openBoard();
116153 const [todo] = db.listColumns(boardId);
src/tty.ts +13 −4
@@ -11,22 +11,29 @@
1111
1212 import * as db from "./db.ts";
1313 import { handleKey, initialState, visibleBoards, visibleCards, type State } from "./keys.ts";
14 import { ownerIdFromEnv } from "./session.ts";
1415
1516 const esc = "\x1b[";
1617 const out = (s: string): void => {
1718 process.stdout.write(s);
1819 };
1920
20 let state: State = initialState();
21 let state: State = initialState(ownerIdFromEnv(process.env));
2122
2223 function draw(): void {
2324 const lines: string[] = [];
2425 const prompt = state.prompt;
2526 if (state.mode === "boards") {
outdated

p

outdated

Medium — the one security decision this pull adds has no test.

ownerIdFromEnv is the new trust boundary: role must be user/admin, id must be non-empty, everything else is a guest. It lives in src/tty.ts, which calls setRawMode, resume and draw at import, so no test can reach it; tty.test.ts only ever hands an id to initialState by hand. Role unset, role anon, role garbage, role set with an empty id, admin → that id — none of those would fail a test today.

AGENTS.md already draws the line: tty.ts is raw mode and drawing, keys.ts is what a key does. Who is at the keyboard is a third concept — put it in its own small module (src/session.ts, say) and test those five cases.

Also on this comment: it asserts "there is also no way for the person at the keyboard to set their own process environment, so this is as trustworthy as the header is." That holds only if the forge's sshd does not pass client-sent environment through (ssh -o SendEnv with a matching AcceptEnv). If it does, a stranger sets FORGE_APP_USER_ID to someone else's id and reads that person's board list — worse than the leak being closed here, because unlike x-forge-identity there is no signature to check. #38 asked for these two vars so following it is right; please confirm with the forge that they're stripped from the client, and if it can't be confirmed, say so in the README rather than stating the equivalence as fact.

26 const boards = visibleBoards(db);
27 const boards = visibleBoards(db, state.ownerId);
2728 lines.push(`${esc}1mtempo${esc}0m ${esc}2mpick a board${esc}0m`);
2829 lines.push("");
29 if (boards.length === 0) lines.push(`${esc}2mNo boards yet. Press n to create one.${esc}0m`);
30 if (boards.length === 0) {
31 lines.push(
32 state.ownerId === null
33 ? `${esc}2mSigned out — press o to open a board by id, or n to create one.${esc}0m`
34 : `${esc}2mNo boards yet. Press n to create one, or o to open one by id.${esc}0m`,
35 );
36 }
3037 boards.forEach((b, i) => {
3138 const mark = i === state.boardsCursor ? "▸" : " ";
3239 const name = i === state.boardsCursor ? `${esc}1m${b.name}${esc}0m` : b.name;
@@ -41,7 +48,9 @@ function draw(): void {
4148 } else {
4249 const board = db.getBoard(state.boardId!)!;
4350 const columns = db.listColumns(state.boardId!);
44 lines.push(`${esc}1mtempo${esc}0m ${esc}2m${board.name}${esc}0m${state.filter ? ` ${esc}2mfilter: ${state.filter}${esc}0m` : ""}`);
51 lines.push(
52 `${esc}1mtempo${esc}0m ${esc}2m${board.name} /b/${board.id}${esc}0m${state.filter ? ` ${esc}2mfilter: ${state.filter}${esc}0m` : ""}`,
53 );
4554 lines.push("");
4655 const columnLines = columns.map((col, ci) => {
4756 const cards = visibleCards(db, state, col.id);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs