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

A repository-scoped token reads /api/v1/here, /api/v1/search and /api/v1/explore within its scope, instead of a 404 that anonymous does not get #6208

Merged opened this

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

Merged

scout/forge:scout/6200-a-repository-scoped-token-reads-api-v1-h → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/6200-a-repository-scoped-token-reads-api-v1-h was merged into main as 4a1b936.

Review

Sign in to review this change.
4 files changed +133 −11
src/actions/search.ts +31 −1
@@ -15,7 +15,7 @@ import { isActive } from "../core/users.ts";
1515 import { canSeeSealed } from "../core/sealed.ts";
1616 import type { App } from "../app.ts";
1717 import { listOrigins } from "../core/origins.ts";
18 import { listReposVisibleTo } from "../core/repos.ts";
18 import { canRead, listReposVisibleTo } from "../core/repos.ts";
1919 import {
2020 cleanQuery,
2121 grepRepos,
@@ -26,6 +26,8 @@ import {
2626 MAX_CODE_HITS,
2727 MAX_SEARCH_REPOS,
2828 type IssueHit,
29 type RepoHit,
30 type CodeHit,
2931 type SearchKind,
3032 type SearchResults,
3133 } from "../core/search.ts";
@@ -215,3 +217,31 @@ async function liveIssues(
215217 clearTimeout(timer);
216218 }
217219 }
220
221 /**
222 * Search only `repos`, the ones a repository-scoped credential reaches, and
223 * only those the actor can read. Nothing outside them is searched, so
224 * nothing outside them can appear; origins are never asked.
225 */
226 export async function searchWithin(
227 app: App,
228 actor: User | undefined,
229 repos: readonly Repo[],
230 input: SearchInput,
231 ): Promise<SearchResults> {
232 const hits = { repos: [] as RepoHit[], issues: [] as IssueHit[], code: [] as CodeHit[] };
233 const truncated = { repos: false, issues: false, code: false };
234 let moreCode = false;
235 for (const repo of repos) {

Low: each repo is searched with the full limit and the results are concatenated, so a run token can get up to 2× limit rows per kind. That's harmless (still within scope and each call is capped), but trimming the merged lists to limit would keep the contract the same as for other callers.

236 if (!canRead(app.db, repo, actor)) continue;
237 const one = await search(app, actor, { ...input, live: false, repo });
238 hits.repos.push(...one.repos);
239 hits.issues.push(...one.issues);
240 hits.code.push(...one.code);
241 truncated.repos ||= one.truncated.repos;
242 truncated.issues ||= one.truncated.issues;
243 truncated.code ||= one.truncated.code;
244 moreCode ||= one.moreCode;
245 }
246 return { ...hits, truncated, moreCode };
247 }
src/http/api.ts +32 −8
@@ -164,7 +164,8 @@ import { holdPull, releasePull } from "../actions/holds.ts";
164164 import { getHold, type Hold } from "../core/holds.ts";
165165 import { watchMilestone } from "../actions/watches.ts";
166166 import { THOUGHT_MAX, keepThought } from "../actions/thought.ts";
167 import { search } from "../actions/search.ts";
167 import { search, searchWithin } from "../actions/search.ts";
168 import { runRepos } from "./run_gate.ts";
168169 import {
169170 commitToBranch,
170171 createLabel,
@@ -2372,13 +2373,17 @@ export function apiRoutes(router: Router<Handler>): void {
23722373 return refused;
23732374 }
23742375 try {
2376 const input = {
2377 q: query,
2378 kind,
2379 ...(q.has("limit") ? { limit: Number(q.get("limit")) } : {}),
2380 live: q.get("live") === "1" || q.get("live") === "true",
2381 };
2382 const mine = runRepos(ctx.app, ctx.auth);
23752383 const found = await timePhaseAsync(ctx.request, "search", () =>
2376 search(ctx.app, ctx.auth.user, {
2377 q: query,
2378 kind,
2379 ...(q.has("limit") ? { limit: Number(q.get("limit")) } : {}),
2380 live: q.get("live") === "1" || q.get("live") === "true",
2381 }),
2384 mine === undefined
2385 ? search(ctx.app, ctx.auth.user, input)
2386 : searchWithin(ctx.app, ctx.auth.user, mine, input),
23822387 );
23832388 return timePhase(ctx.request, "render", () => json(found));
23842389 } finally {
@@ -5355,9 +5360,19 @@ export function apiRoutes(router: Router<Handler>): void {
53555360 router.add("GET", "/api/v1/explore", async (ctx) => {
53565361 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
53575362 const query = parseExploreQuery(ctx.url.searchParams.get("q") ?? "");
5358 const items = await timePhaseAsync(ctx.request, "explore", () =>
5363 const mine = runRepos(ctx.app, ctx.auth);
5364 const all = await timePhaseAsync(ctx.request, "explore", () =>
53595365 explore(ctx.app, ctx.auth.user, query),
53605366 );
5367 // A run token is shown only the rows of its own repositories.
5368 const items =
5369 mine === undefined
5370 ? all

Low: explore is computed forge-wide (capped) and filtered afterwards, so a run whose repository isn't in the first page of explore rows gets items: []. That fails closed, so it's not a leak, but it can look empty for a busy forge. A follow-up could pass the scope into explore instead. The mine.some(...) line is also long and repeats the owner/name comparison in here. One inRunScope(mine, owner, name) helper in run_gate.ts would serve both.

5371 : all.filter(
5372 (row) =>
5373 row.repo !== undefined &&
5374 mine.some((r) => r.ownerName.toLowerCase() === row.repo!.owner.toLowerCase() && r.name.toLowerCase() === row.repo!.name.toLowerCase()),
5375 );
53615376 return timePhase(ctx.request, "render", () => json({ query: exploreLine(query), items }));
53625377 });
53635378
@@ -6203,6 +6218,15 @@ export function apiRoutes(router: Router<Handler>): void {
62036218 ),
62046219 );
62056220 if (!found.ok) return notFound();
6221 // A run token sees repository pages of its own repositories, and nothing
6222 // else: not the forge, not another repository, not even that one exists.
6223 const mine = runRepos(ctx.app, ctx.auth);
6224 const at = found.value.here;
6225 if (
6226 mine !== undefined &&
6227 !(at.kind === "repository" && mine.some((r) => `${r.ownerName}/${r.name}`.toLowerCase() === at.name.toLowerCase()))
6228 )
6229 return notFound();
62066230 return timePhase(ctx.request, "render", () =>
62076231 json(found.value, 200, {
62086232 "cache-control": "no-store",
src/http/run_gate.ts +17 −1
@@ -4,7 +4,9 @@
44 * A run token (`core/run_token.ts`) reaches the repository its run works on
55 * and the crew member's fork of it the run pushes to — their API under
66 * `/api/v1/repos/<owner>/<name>` and their git — and may read the
7 * repository's fork parent. Every other request answers `not_found`,
7 * repository's fork parent, and reads `here`, `search`, `explore` and the
8 * OpenAPI address, whose handlers answer from those two repositories alone.
9 * Every other request answers `not_found`,
810 * the same as a repository the crew member cannot see: other repositories
911 * (the crew member's own, the owner's private ones), the account, tokens,
1012 * secrets, admin, MCP and the web pages. Allow-listed, so a route added
@@ -50,6 +52,18 @@ function gitRead(method: string, url: URL, repo: Repo): boolean {
5052 return method === "POST" && rest === "/git-upload-pack";
5153 }
5254
55 /** Forge-wide reads a run token may make; each handler narrows its answer to `runRepos`. */
56 const FORGE_READS = new Set(["/api/v1/here", "/api/v1/search", "/api/v1/explore", "/api/v1/openapi.json"]);
57
58 /** The repositories a run token reaches by name: its own and its fork. Undefined for any other credential. */
59 export function runRepos(app: App, auth: Auth): Repo[] | undefined {
60 if (auth.run === undefined) return undefined;
61 return [auth.run.repoId, auth.run.forkId]
62 .filter((id, i, all) => all.indexOf(id) === i)
63 .map((id) => getRepoById(app.db, id))
64 .filter((r): r is Repo => r !== undefined);
65 }
66
5367 /** The refusal for this request, or undefined when it may proceed. */
5468 export function runGate(
5569 app: App,
@@ -65,6 +79,8 @@ export function runGate(
6579 const path = url.pathname;
6680 const method = request.method;
6781
82 if ((method === "GET" || method === "HEAD") && FORGE_READS.has(path)) return undefined;
83
6884 for (const own of [repo, fork]) {
6985 const api = under(path, "/api/v1/repos", own);
7086 if (api !== undefined) {
test/run-token.test.ts +53 −1
@@ -11,6 +11,7 @@ import { join } from "node:path";
1111 import { commitToBranch, createRepo, deleteRepo, forkRepo } from "../src/actions/repos.ts";
1212 import { createApp, type App } from "../src/app.ts";
1313 import type { Repo } from "../src/core/repos.ts";
14 import { createApp as insertApp } from "../src/core/apps.ts";
1415 import { setMember } from "../src/core/roles.ts";
1516 import { mintRunToken } from "../src/core/run_token.ts";
1617 import { createToken, verifyToken } from "../src/core/tokens.ts";
@@ -99,8 +100,10 @@ describe("a run token", () => {
99100 if (!made.ok) throw new Error(made.error);
100101 alice = made.value;
101102 work = await make("gods-eye-view", "public", [{ path: "README.md", content: INJECTION }]);
103 insertApp(app.db, { repoId: work.id, slug: "alice-gods-eye-view", access: "public" });
102104 await make("vault", "private", [{ path: "SECRET.md", content: "the private file\n" }]);
103 await make("public", "public", [{ path: "README.md", content: "hi\n" }]);
105 const open = await make("public", "public", [{ path: "README.md", content: "hi\n" }]);
106 insertApp(app.db, { repoId: open.id, slug: "alice-public", access: "public" });
104107 await make("gods-eye-view-2", "private", [{ path: "README.md", content: "lookalike\n" }]);
105108 const forked = await forkRepo(app, alice, work, "gev-fork");
106109 if (!forked.ok) throw new Error(String(forked.error));
@@ -206,4 +209,53 @@ describe("a run token", () => {
206209 expect((await deleteRepo(app, alice, doomed)).ok).toBe(true);
207210 expect(verifyToken(app.db, token.token).ok).toBe(false);
208211 });
212
213 test("reads here, search, explore and the OpenAPI address inside its scope only", async () => {
214 const text = async (path: string, token = secret) => {
215 const res = await api(path, {}, token);
216 return { status: res.status, body: await res.text() };
217 };
218 // Anonymous-equivalent reads that the personal token can make, as the baseline.
219 expect((await text("/api/v1/here?path=/alice/public", personal)).status).toBe(200);
220
221 const own = await text("/api/v1/here?path=/alice/gods-eye-view");
222 expect(own.status).toBe(200);
223 expect(own.body).toContain("alice/gods-eye-view");
224 expect((await text("/api/v1/here?path=/alice/gev-fork")).status).toBe(200);
225 for (const path of ["/alice/public", "/alice/vault", "/alice/vault/issues/1", "/alice/Public", "/", "/explore", "/search", "/nobody/nothing"])
226 expect((await text(`/api/v1/here?path=${path}`)).status).toBe(404);
227
228 const open = await api("/api/v1/openapi.json", { redirect: "manual" });
229 expect(open.status).toBe(308);
230 expect(open.headers.get("location")).toBe("/contract/openapi.json");
231
232 const mine = await text("/api/v1/search?q=gods-eye");
233 expect(mine.status).toBe(200);
234 expect(mine.body).toContain("gods-eye-view");
235 expect(mine.body).not.toContain("gods-eye-view-2");
236 const scope = new Set(["gods-eye-view", "gev-fork"]);
237 for (const q of ["public", "vault", "the private file", "lookalike", "gods-eye", "hi"]) {
238 const res = await text(`/api/v1/search?q=${encodeURIComponent(q)}`);
239 expect(res.status).toBe(200);
240 const found = JSON.parse(res.body) as {
241 repos: { owner: string; name: string }[];
242 issues: { fullName?: string }[];
243 code: { repo: { owner: string; name: string } }[];
244 };
245 for (const hit of [...found.repos, ...found.code.map((c) => c.repo)]) expect(scope.has(hit.name)).toBe(true);
246 for (const hit of found.issues) expect(hit.fullName?.endsWith("/gods-eye-view") || hit.fullName?.endsWith("/gev-fork")).toBe(true);
247 expect(res.body).not.toContain("lookalike");
248 }
249 expect((await text("/api/v1/search?q=public", personal)).body).toContain("alice/public");
250
251 const explored = await text("/api/v1/explore");
252 expect(explored.status).toBe(200);
253 expect(explored.body).toContain("gods-eye-view");
254 const names = (JSON.parse(explored.body) as { items: { repo?: { name: string } }[] }).items.map((i) => i.repo?.name);
255 expect(names.length).toBeGreaterThan(0);
256 for (const name of names) expect(scope.has(name ?? "")).toBe(true);
257 const wide = (JSON.parse((await text("/api/v1/explore", personal)).body) as { items: { repo?: { name: string } }[] }).items;
258 expect(wide.some((i) => i.repo?.name === "public")).toBe(true);
259 expect(JSON.parse((await text("/api/v1/explore?q=public")).body).items).toEqual([]);
260 });
209261 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs