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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6200-a-repository-scoped-token-reads-api-v1-h → main 1 commit fork 2 threads · 2 unresolved
4 files
changed +133
−11
src/actions/search.ts +31 −1
| @@ -15,7 +15,7 @@ import { isActive } from "../core/users.ts"; | ||
| 15 | 15 | import { canSeeSealed } from "../core/sealed.ts"; |
| 16 | 16 | import type { App } from "../app.ts"; |
| 17 | 17 | import { listOrigins } from "../core/origins.ts"; |
| 18 | import { listReposVisibleTo } from "../core/repos.ts"; | |
| 18 | import { canRead, listReposVisibleTo } from "../core/repos.ts"; | |
| 19 | 19 | import { |
| 20 | 20 | cleanQuery, |
| 21 | 21 | grepRepos, |
| @@ -26,6 +26,8 @@ import { | ||
| 26 | 26 | MAX_CODE_HITS, |
| 27 | 27 | MAX_SEARCH_REPOS, |
| 28 | 28 | type IssueHit, |
| 29 | type RepoHit, | |
| 30 | type CodeHit, | |
| 29 | 31 | type SearchKind, |
| 30 | 32 | type SearchResults, |
| 31 | 33 | } from "../core/search.ts"; |
| @@ -215,3 +217,31 @@ async function liveIssues( | ||
| 215 | 217 | clearTimeout(timer); |
| 216 | 218 | } |
| 217 | 219 | } |
| 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) { | |
| 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"; | ||
| 164 | 164 | import { getHold, type Hold } from "../core/holds.ts"; |
| 165 | 165 | import { watchMilestone } from "../actions/watches.ts"; |
| 166 | 166 | 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"; | |
| 168 | 169 | import { |
| 169 | 170 | commitToBranch, |
| 170 | 171 | createLabel, |
| @@ -2372,13 +2373,17 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2372 | 2373 | return refused; |
| 2373 | 2374 | } |
| 2374 | 2375 | 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); | |
| 2375 | 2383 | 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), | |
| 2382 | 2387 | ); |
| 2383 | 2388 | return timePhase(ctx.request, "render", () => json(found)); |
| 2384 | 2389 | } finally { |
| @@ -5355,9 +5360,19 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 5355 | 5360 | router.add("GET", "/api/v1/explore", async (ctx) => { |
| 5356 | 5361 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 5357 | 5362 | 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", () => | |
| 5359 | 5365 | explore(ctx.app, ctx.auth.user, query), |
| 5360 | 5366 | ); |
| 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 |
||
| 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 | ); | |
| 5361 | 5376 | return timePhase(ctx.request, "render", () => json({ query: exploreLine(query), items })); |
| 5362 | 5377 | }); |
| 5363 | 5378 | |
| @@ -6203,6 +6218,15 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 6203 | 6218 | ), |
| 6204 | 6219 | ); |
| 6205 | 6220 | 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(); | |
| 6206 | 6230 | return timePhase(ctx.request, "render", () => |
| 6207 | 6231 | json(found.value, 200, { |
| 6208 | 6232 | "cache-control": "no-store", |
src/http/run_gate.ts +17 −1
| @@ -4,7 +4,9 @@ | ||
| 4 | 4 | * A run token (`core/run_token.ts`) reaches the repository its run works on |
| 5 | 5 | * and the crew member's fork of it the run pushes to — their API under |
| 6 | 6 | * `/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`, | |
| 8 | 10 | * the same as a repository the crew member cannot see: other repositories |
| 9 | 11 | * (the crew member's own, the owner's private ones), the account, tokens, |
| 10 | 12 | * 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 { | ||
| 50 | 52 | return method === "POST" && rest === "/git-upload-pack"; |
| 51 | 53 | } |
| 52 | 54 | |
| 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 | ||
| 53 | 67 | /** The refusal for this request, or undefined when it may proceed. */ |
| 54 | 68 | export function runGate( |
| 55 | 69 | app: App, |
| @@ -65,6 +79,8 @@ export function runGate( | ||
| 65 | 79 | const path = url.pathname; |
| 66 | 80 | const method = request.method; |
| 67 | 81 | |
| 82 | if ((method === "GET" || method === "HEAD") && FORGE_READS.has(path)) return undefined; | |
| 83 | ||
| 68 | 84 | for (const own of [repo, fork]) { |
| 69 | 85 | const api = under(path, "/api/v1/repos", own); |
| 70 | 86 | if (api !== undefined) { |
test/run-token.test.ts +53 −1
| @@ -11,6 +11,7 @@ import { join } from "node:path"; | ||
| 11 | 11 | import { commitToBranch, createRepo, deleteRepo, forkRepo } from "../src/actions/repos.ts"; |
| 12 | 12 | import { createApp, type App } from "../src/app.ts"; |
| 13 | 13 | import type { Repo } from "../src/core/repos.ts"; |
| 14 | import { createApp as insertApp } from "../src/core/apps.ts"; | |
| 14 | 15 | import { setMember } from "../src/core/roles.ts"; |
| 15 | 16 | import { mintRunToken } from "../src/core/run_token.ts"; |
| 16 | 17 | import { createToken, verifyToken } from "../src/core/tokens.ts"; |
| @@ -99,8 +100,10 @@ describe("a run token", () => { | ||
| 99 | 100 | if (!made.ok) throw new Error(made.error); |
| 100 | 101 | alice = made.value; |
| 101 | 102 | 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" }); | |
| 102 | 104 | 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" }); | |
| 104 | 107 | await make("gods-eye-view-2", "private", [{ path: "README.md", content: "lookalike\n" }]); |
| 105 | 108 | const forked = await forkRepo(app, alice, work, "gev-fork"); |
| 106 | 109 | if (!forked.ok) throw new Error(String(forked.error)); |
| @@ -206,4 +209,53 @@ describe("a run token", () => { | ||
| 206 | 209 | expect((await deleteRepo(app, alice, doomed)).ok).toBe(true); |
| 207 | 210 | expect(verifyToken(app.db, token.token).ok).toBe(false); |
| 208 | 211 | }); |
| 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 | }); | |
| 209 | 261 | }); |
new above
offline
Low: each repo is searched with the full
limitand the results are concatenated, so a run token can get up to 2×limitrows per kind. That's harmless (still within scope and each call is capped), but trimming the merged lists tolimitwould keep the contract the same as for other callers.