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

A private repository is absent from every public door, proven by one sweep #12413

Merged opened this

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

Merged

scout/forge:scout/12405-a-private-repository-is-absent-from-ever → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/12405-a-private-repository-is-absent-from-ever was merged into main as d5f479b.

Review

Sign in to review this change.
1 file changed +286 −0
test/private-sweep.test.ts added +286
@@ -0,0 +1,286 @@
1 /**
2 * #12405: a private repository is absent from every public door. One private
3 * repository carries a distinctive word in its name, description, issue and
4 * commit message; every door in `DOORS` is fetched as a stranger and as a
5 * signed-in person who is not a member, and the word is in none of the bodies
6 * or headers. A door that knows who is asking (`roleAware`) shows it to the
7 * owner. A new public door is one more line in `DOORS`.
8 *
9 * The doors that list repositories page on with a cursor; those pages are
10 * followed to the end, so a private row cannot hide on page two.
11 *
12 * A private repository has no per-person collaborator here: a person who is
13 * not the owner reads one through a team of the org that owns it (`canRead`
14 * in `core/repos.ts`). So the member is carol, on a read team of `acme`,
15 * which owns a second private repository (`TEAM`); she sees it where the
16 * door knows who is asking, and bob and a stranger never do.
17 */
18 import { afterAll, beforeAll, expect, test } from "bun:test";
19 import { mkdtemp, rm } from "node:fs/promises";
20 import { tmpdir } from "node:os";
21 import { join } from "node:path";
22 import { createApp, type App } from "../src/app.ts";
23 import { openIssue } from "../src/actions/issues.ts";
24 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
25 import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts";
26 import { createRepo as createCoreRepo, getRepo } from "../src/core/repos.ts";
27 import { createSession } from "../src/core/sessions.ts";
28 import { createToken } from "../src/core/tokens.ts";
29 import { createUser, type User } from "../src/core/users.ts";
30 import { serve, type Servers } from "../src/http/server.ts";
31 import { promote } from "./helpers/members.ts";
32
33 /** Lower-case, so a door that lower-cases or slugs it is still caught. */
34 const NAME = "zqxsecretvault";
35 const DESCRIPTION = "zqxdescword";
36 const ISSUE = "zqxissueword";
37 const COMMIT = "zqxcommitword";
38 /** The org's private repository, read by its team and nobody else. */
outdated

DOORS is missing /api/v1/repos, /api/v1/users/alice, /api/v1/users/alice/repos and /mcp (tools/list and a repo list or search tool). Add them here, with roleAware on the listing doors.

39 const TEAM = "zqxteamvault";
40 const WORDS = [NAME, DESCRIPTION, ISSUE, COMMIT, TEAM];
41 /** A word nothing ever had, one per word: the control a door asked by a word is held to. */
42 const never = (word: string) => `zqxneverwas${WORDS.indexOf(word)}`;
43
44 interface Door {
45 readonly path: string;
46 /** POST, with this JSON body; a GET when absent. */
47 readonly post?: unknown;
48 /** Signed in by a bearer token rather than a cookie (MCP takes no cookie). */
49 readonly bearer?: boolean;
50 /** The owner sees the private repository's name here. */
51 readonly roleAware?: boolean;
52 }
53
54 const rpc = (method: string, params?: unknown) => ({ jsonrpc: "2.0", id: 1, method, ...(params === undefined ? {} : { params }) });
55 const tool = (name: string, args: Record<string, unknown>) => rpc("tools/call", { name, arguments: args });
56
57 /** Every public door, in one place: add a new one here. */
58 const DOORS: readonly Door[] = [
59 { path: "/explore" },
60 { path: "/explore.atom" },
61 { path: `/explore?q=${NAME}` },
62 { path: "/explore?owner=alice" },
63 { path: "/explore?sort=new" },
64 { path: "/explore.atom?owner=alice" },
65 { path: "/api/v1/explore" },
66 { path: "/api/v1/explore?owner=alice" },
67 { path: "/api/v1/explore?sort=new" },
68 { path: `/api/v1/search?q=${NAME}`, roleAware: true },
69 { path: `/api/v1/search?q=${NAME}&kind=repos`, roleAware: true },
70 { path: `/api/v1/search?q=${ISSUE}&kind=issues` },
71 { path: `/search?q=${NAME}`, roleAware: true },
72 { path: `/search?q=${ISSUE}&kind=issues` },
73 { path: "/api/v1/repos", roleAware: true },
outdated

body() only does GET, so POST doors like /mcp can't be swept. Add method and body to Door and pass them through.

74 { path: "/api/v1/users/alice" },
75 { path: "/api/v1/users/alice/repos", roleAware: true },
76 { path: "/api/v1/users/acme/repos" },
77 { path: "/api/v1/repos/alice/open/issues" },
78 { path: `/api/v1/repos/alice/${NAME}/issues` },
79 { path: "/mcp", post: rpc("tools/list") },
80 { path: "/mcp", post: rpc("tools/list"), bearer: true },
81 { path: "/mcp", post: tool("explore", {}), bearer: true },
82 { path: "/mcp", post: tool("explore", { owner: "alice" }), bearer: true },
83 { path: "/mcp", post: tool("search", { q: NAME }), bearer: true, roleAware: true },
84 { path: "/mcp", post: tool("search", { q: ISSUE, kind: "issues" }), bearer: true },
85 { path: "/mcp", post: tool("issues", { repo: `alice/${NAME}` }), bearer: true },
86 { path: "/sitemap.xml" },
87 { path: "/robots.txt" },
88 { path: "/llms.txt" },
89 { path: "/.well-known/agent-card.json" },
90 { path: "/.well-known/mcp.json" },
91 { path: "/.well-known/api-catalog" },
92 { path: "/.well-known/forge" },
93 { path: "/feed" },
94 { path: "/api/v1/feed" },
95 { path: "/changelog.atom" },
96 { path: "/alice" },
97 { path: "/acme" },
98 { path: "/alice/open/issues" },
99 { path: "/alice/open/forks" },
100 { path: "/alice/open/activity.atom" },
101 { path: `/alice/${NAME}/activity.atom` },
102 { path: "/og/site.svg" },
103 { path: `/og/repo/alice~${NAME}.svg` },
104 { path: `/og/issue/alice~${NAME}~1.svg` },
105 { path: `/alice/${NAME}` },
106 { path: `/alice/${NAME}/issues` },
107 { path: `/alice/${NAME}/issues/1` },
108 { path: `/acme/${TEAM}` },
109 { path: `/api/v1/repos/alice/${NAME}` },
110 { path: `/api/v1/repos/acme/${TEAM}` },
111 ];
112
113 /** The doors that page on with `next_cursor`, at one row a page where they take a limit. */
114 const PAGED = ["/api/v1/repos?limit=1", "/api/v1/users/alice/repos?limit=1", "/api/v1/explore"];
115
116 /** Where carol, on acme's read team, sees the org's private repository. */
117 const MEMBER_DOORS: readonly Door[] = [
118 { path: "/api/v1/repos" },
119 { path: "/api/v1/users/acme/repos" },
120 { path: `/api/v1/search?q=${TEAM}` },
121 { path: `/search?q=${TEAM}` },
122 { path: `/acme/${TEAM}` },
123 { path: `/api/v1/repos/acme/${TEAM}` },
124 { path: "/mcp", post: tool("search", { q: TEAM }), bearer: true },
125 ];
126
127 let dataDir: string;
128 let app: App;
129 let servers: Servers;
130 let alice: User;
131 let bob: User;
132 let carol: User;
133
134 interface Visitor {
outdated

Only the owner is tried as a member. Add a collaborator who is not the owner, or document why that is out of scope.

135 readonly cookie: string;
136 readonly token: string;
137 }
138
139 /** Status, headers and body together (lower-cased), and the body alone. */
140 async function body(door: Door, who?: Visitor): Promise<{ status: number; text: string; content: string }> {
141 const headers: Record<string, string> = {};
142 if (who !== undefined) {
143 if (door.bearer) headers["authorization"] = `Bearer ${who.token}`;
144 else headers["cookie"] = who.cookie;
145 }
146 if (door.post !== undefined) {
147 headers["content-type"] = "application/json";
148 headers["accept"] = "application/json, text/event-stream";
149 }
150 const res = await fetch(`${servers.url}${door.path}`, {
151 method: door.post === undefined ? "GET" : "POST",
152 headers,
153 redirect: "manual",
154 ...(door.post === undefined ? {} : { body: JSON.stringify(door.post) }),
155 });
156 const head = [...res.headers].map(([k, v]) => `${k}: ${v}`).join("\n");
157 const content = (await res.text()).toLowerCase();
158 return { status: res.status, text: `${head.toLowerCase()}\n\n${content}`, content };
159 }
160
161 const label = (door: Door) => (door.post === undefined ? door.path : `${door.path} ${JSON.stringify(door.post)}`);
162 const visitor = (u: User): Visitor => ({
163 cookie: `forge_session=${createSession(app.db, u.id).token}`,
164 token: createToken(app.db, u.id, "sweep", "rw").token,
165 });
166
167 /** The door with every word it was asked by swapped for a word nothing ever had. */
168 function neverDoor(door: Door, asked: readonly string[]): Door {
169 const swap = (s: string) => asked.reduce((t, w) => t.replaceAll(w, never(w)), s);
170 return {
171 ...door,
172 path: swap(door.path),
173 ...(door.post === undefined ? {} : { post: JSON.parse(swap(JSON.stringify(door.post))) }),
174 };
175 }
176
177 beforeAll(async () => {
178 dataDir = await mkdtemp(join(tmpdir(), "forge-private-sweep-"));
179 app = createApp({ dataDir, port: 0 });
180 servers = await serve(app);
181 const made: User[] = [];
182 for (const username of ["alice", "bob", "carol"]) {
183 const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase" });
184 if (!u.ok) throw new Error(u.error);
185 made.push(u.value);
186 }
187 [alice, bob, carol] = made as [User, User, User];
188 promote(app.db, alice, bob, carol);
189
190 const open = await createRepo(app, alice, { name: "open", visibility: "public", description: "A public neighbour" });
191 if (!open.ok) throw new Error(String(open.error));
192 const o = await commitToBranch(app, alice, getRepo(app.db, "alice", "open")!, { message: "Start open", files: [{ path: "README.md", content: "# Open\n" }] });
193 if (!o.ok) throw new Error(String(o.error));
194
195 const r = await createRepo(app, alice, { name: NAME, visibility: "private", description: `The ${DESCRIPTION} plans` });
196 if (!r.ok) throw new Error(String(r.error));
197 const repo = getRepo(app.db, "alice", NAME)!;
198 const c = await commitToBranch(app, alice, repo, { message: `Add the ${COMMIT}`, files: [{ path: "README.md", content: `# ${NAME}\n` }] });
199 if (!c.ok) throw new Error(String(c.error));
200 const i = openIssue(app, alice, repo, { title: `Fix the ${ISSUE}`, body: `About ${ISSUE}` });
201 if (!i.ok) throw new Error(String(i.error));
202
203 const org = createOrg(app.db, "acme", alice.id);
204 if (!org.ok) throw new Error(org.error);
205 const readers = createTeam(app.db, org.value.id, "Readers", "read");
206 if (!readers.ok) throw new Error(readers.error);
207 addTeamMember(app.db, readers.value, carol.id);
208 const t = await createCoreRepo(app.db, app.reposRoot, { ownerId: org.value.id, name: TEAM, visibility: "private" });
209 if (!t.ok) throw new Error(String(t.error));
210 });
211
212 afterAll(async () => {
213 servers.stop();
214 app.close();
215 await rm(dataDir, { recursive: true, force: true });
216 });
217
218 for (const who of ["a stranger", "a signed-in non-member"] as const) {
219 test(`no public door shows the private repository to ${who}`, async () => {
220 const as = who === "a stranger" ? undefined : visitor(bob);
221 const leaks: string[] = [];
222 for (const door of DOORS) {
223 const { status, text, content } = await body(door, as);
224 if (status >= 500) leaks.push(`${label(door)} answers ${status}`);
225 // A door asked by a word may echo the word it was asked; it must answer
226 // as it does for a word that never existed, and show nothing else.
227 const asked = WORDS.filter((w) => label(door).includes(w));
228 for (const word of WORDS.filter((w) => !asked.includes(w)))
229 if (text.includes(word)) leaks.push(`${label(door)} (${status}) shows ${word}`);
230 if (asked.length > 0) {
231 const other = await body(neverDoor(door, asked), as);
232 if (other.status !== status) leaks.push(`${label(door)} answers ${status}, a word that never was ${other.status}`);
233 const back = asked.reduce((t, w) => t.replaceAll(never(w), w), other.content);
234 if (back !== content) leaks.push(`${label(door)} answers differently from a word that never was`);
235 }
236 }
237 expect(leaks).toEqual([]);
238 });
239
240 test(`every page of a paged list leaves the private repositories out for ${who}`, async () => {
241 const as = who === "a stranger" ? undefined : visitor(bob);
242 const leaks: string[] = [];
243 for (const first of PAGED) {
244 let path: string | undefined = first;
245 let pages = 0;
246 while (path !== undefined && pages < 20) {
247 const { status, text, content } = await body({ path }, as);
248 expect({ path, status }).toEqual({ path, status: 200 });
249 for (const word of WORDS) if (text.includes(word)) leaks.push(`${path} shows ${word}`);
250 const next = (JSON.parse(content) as { next_cursor?: string | null }).next_cursor;
251 path = next ? `${first}${first.includes("?") ? "&" : "?"}cursor=${encodeURIComponent(next)}` : undefined;
252 pages++;
253 }
254 }
255 expect(leaks).toEqual([]);
256 });
257 }
258
259 test("the owner sees the private repository where the door knows who is asking", async () => {
260 const as = visitor(alice);
261 const roleAware = DOORS.filter((d) => d.roleAware);
262 expect(roleAware.length).toBeGreaterThan(0);
263 for (const door of roleAware) {
264 const { status, text } = await body(door, as);
265 expect({ door: label(door), status, seen: text.includes(NAME) }).toEqual({ door: label(door), status: 200, seen: true });
266 }
267 });
268
269 test("the owner reaches the private repository's own pages", async () => {
270 const as = visitor(alice);
271 for (const path of [`/alice/${NAME}`, `/api/v1/repos/alice/${NAME}`]) {
272 const { status, text } = await body({ path }, as);
273 expect({ path, status, seen: text.includes(NAME) }).toEqual({ path, status: 200, seen: true });
274 }
275 });
276
277 test("a member who is not the owner sees the org's private repository where the door knows who is asking", async () => {
278 const as = visitor(carol);
279 for (const door of MEMBER_DOORS) {
280 const { status, text } = await body(door, as);
281 expect({ door: label(door), status, seen: text.includes(TEAM) }).toEqual({ door: label(door), status: 200, seen: true });
282 }
283 // and not alice's own private repository, which no team of hers reads
284 const { text } = await body({ path: "/api/v1/repos" }, as);
285 expect(text.includes(NAME)).toBe(false);
286 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs