Commit8ed1b31
fix(security): 24 private-repo subpages were readable by anonymous visitors
fix(security): 24 private-repo subpages were readable by anonymous visitors Each repo subpage owned its own privacy check and it had drifted badly. A live probe of a private repo found 24 subpages returning 200 to an anonymous visitor while the repo root correctly 404'd: /actions /archaeology /cloud-deployments /contributors /coupling /dependencies /deployments /discussions /explain /gates /health /insights /packages /previews /projects /pushes /queue /wiki /insights/engineering /ai/changelog /ai/tests /docs/tracking /search/nl /search/semantic Between them they disclosed branch and workflow names, CI run history and job logs, deployment history, dependency manifests, contributor lists and wiki content for repositories the caller could not open. The reported finding named five of these; probing every repo-scoped route found the other nineteen. Fixed with one gate in app.tsx over `/:owner/:repo/*` rather than ~20 per-file patches — per-route gating is precisely what produced the drift, and a subpage added tomorrow now inherits the check. Safety properties, all covered by tests: - Registered before every repo router and after softAuth, so owners, collaborators and org members are unaffected (gates on resolveRepoAccess, not an ownerId comparison). - Denies 404, never 403 — a private repo must not confirm it exists. - Paths that merely LOOK like /:owner/:repo — /settings/tokens, /orgs/new, and `/:owner/:repo.git/*` for git Smart HTTP — resolve to null and fall straight through, so git auth and every non-repo page are untouched. - Public repos short-circuit before any access lookup. - A namespace-lookup failure fails OPEN, so a DB blip cannot 404 the whole public site. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 files changed+148−08ed1b31e48ff64f5a15e5d1cb301a20fb1602149
2 changed files+148−0
Addedsrc/__tests__/private-repo-subpage-gate.test.ts+87−0View fileUnifiedSplit
@@ -0,0 +1,87 @@
1/**
2 * Platform-wide private-repo gate for /:owner/:repo/... subpages.
3 *
4 * Every repo subpage owned its own privacy check and it drifted. A live probe
5 * of a private repo on 2026-07-28 found 24 subpages returning 200 to an
6 * anonymous visitor while the repo root correctly 404'd:
7 *
8 * /actions /archaeology /cloud-deployments /contributors /coupling
9 * /dependencies /deployments /discussions /explain /gates /health
10 * /insights /packages /previews /projects /pushes /queue /wiki
11 * /insights/engineering /ai/changelog /ai/tests /docs/tracking
12 * /search/nl /search/semantic
13 *
14 * These leaked branch and workflow names, CI logs, deployment history,
15 * dependency manifests and wiki content for repositories the caller could not
16 * open.
17 *
18 * The gate is registered once in app.tsx. What must not regress: it runs
19 * before the repo routers, after softAuth, denies with 404 rather than 403,
20 * and falls through for anything that is not a resolvable private repo — so
21 * /settings/tokens, /orgs/new and the git Smart HTTP paths are untouched.
22 */
23
24import { describe, expect, it } from "bun:test";
25import { readFileSync } from "fs";
26
27const SRC = readFileSync("src/app.tsx", "utf8");
28const gate = SRC.slice(
29 SRC.indexOf('app.use("/:owner/:repo/*"'),
30 SRC.indexOf('app.route("/", gitRoutes)')
31);
32
33describe("gate placement", () => {
34 it("is registered before every repo router", () => {
35 const gateIdx = SRC.indexOf('app.use("/:owner/:repo/*"');
36 expect(gateIdx).toBeGreaterThan(-1);
37 // gitRoutes is the first router mounted; webRoutes the last.
38 expect(gateIdx).toBeLessThan(SRC.indexOf('app.route("/", gitRoutes)'));
39 expect(gateIdx).toBeLessThan(SRC.indexOf('app.route("/", webRoutes)'));
40 });
41
42 it("runs after softAuth so the viewer is resolved", () => {
43 // Registered earlier and c.get("user") would always be undefined, which
44 // would 404 private repos even for their own owner.
45 expect(SRC.indexOf('app.use("*", softAuth)')).toBeLessThan(
46 SRC.indexOf('app.use("/:owner/:repo/*"')
47 );
48 });
49});
50
51describe("deny behaviour", () => {
52 it("denies with 404, never 403", () => {
53 expect(gate).toContain('access !== "none"');
54 expect(gate).toContain("404");
55 expect(gate).not.toMatch(/,\s*403\s*\)/);
56 });
57
58 it("uses resolveRepoAccess so collaborators and org members still pass", () => {
59 expect(gate).toContain("resolveRepoAccess");
60 // An ownerId equality check would lock out collaborators.
61 expect(gate).not.toMatch(/user\.id\s*!==/);
62 });
63
64 it("answers JSON callers with JSON", () => {
65 expect(gate).toContain("application/json");
66 });
67});
68
69describe("false positives fall through", () => {
70 it("passes through when the repo does not resolve", () => {
71 // /settings/tokens, /orgs/new, /:owner/:repo.git/* all resolve to null.
72 expect(gate).toContain("if (!row || !row.isPrivate) return next()");
73 });
74
75 it("passes through public repos untouched", () => {
76 // Same line: a non-private row short-circuits before any access lookup,
77 // so public browsing costs nothing extra beyond the namespace read.
78 expect(gate).toContain("!row.isPrivate");
79 });
80
81 it("fails OPEN on a namespace lookup error", () => {
82 // A DB blip must not 404 the entire public site. The handler downstream
83 // surfaces its own error instead.
84 const cat = gate.slice(gate.indexOf("} catch {"), gate.indexOf("if (!row"));
85 expect(cat).toContain("return next()");
86 });
87});
Modifiedsrc/app.tsx+61−0View fileUnifiedSplit
@@ -406,6 +406,67 @@ app.use("/mcp", apiRateLimit);
406406// their own cache policies unchanged.
407407app.use("*", noCache);
408408
409/**
410 * Platform-wide private-repository gate for `/:owner/:repo/...` pages.
411 *
412 * Every repo subpage was responsible for its own privacy check, and that
413 * drifted badly: a live probe of a private repo found 24 subpages rendering
414 * 200 to an anonymous visitor — /actions, /deployments, /discussions,
415 * /packages, /projects, /wiki, /insights, /contributors, /gates, /queue,
416 * /previews, /pushes, /health, /coupling, /dependencies, /explain,
417 * /archaeology, /cloud-deployments, /ai/changelog, /ai/tests, /docs/tracking,
418 * /search/nl, /search/semantic, /insights/engineering — while the repo root
419 * correctly 404'd. Each leaked names, history, CI logs or dependency data.
420 *
421 * Gating centrally is the point: ~20 route files each re-implementing the
422 * check is what produced the drift, and a subpage added tomorrow inherits
423 * this automatically.
424 *
425 * Safe against false positives. Paths like /settings/tokens or /orgs/new also
426 * match `/:owner/:repo`, but loadRepoByPath returns null for them and the
427 * request falls straight through — exactly as it does for a repo that does
428 * not exist, so each handler keeps emitting its own 404. `/:owner/:repo.git/*`
429 * (git Smart HTTP) resolves `:repo` as "name.git", also null, so the git
430 * protocol's own auth is untouched.
431 *
432 * Registered before every repo router and after softAuth, so c.get("user")
433 * is populated and owners/collaborators are unaffected.
434 */
435app.use("/:owner/:repo/*", async (c, next) => {
436 const owner = c.req.param("owner");
437 const repo = c.req.param("repo");
438 if (!owner || !repo) return next();
439
440 let row: { id: string; isPrivate: boolean } | null = null;
441 try {
442 const { loadRepoByPath } = await import("./lib/namespace");
443 const found = await loadRepoByPath(owner, repo);
444 row = found ? { id: found.id, isPrivate: found.isPrivate } : null;
445 } catch {
446 // Namespace resolution failed (DB blip) — do not invent a denial for
447 // public content; the handler below will surface its own error.
448 return next();
449 }
450 if (!row || !row.isPrivate) return next();
451
452 const viewer = c.get("user");
453 const { resolveRepoAccess } = await import("./middleware/repo-access");
454 const access = await resolveRepoAccess({
455 repoId: row.id,
456 userId: viewer?.id ?? null,
457 isPublic: false,
458 });
459 if (access !== "none") return next();
460
461 // 404, not 403 — a private repo must not confirm its own existence. Matches
462 // what the repo root already does.
463 const accepts = c.req.header("accept") ?? "";
464 if (accepts.includes("application/json") && !accepts.includes("text/html")) {
465 return c.json({ error: "Not found" }, 404);
466 }
467 return c.html(<NotFoundPage user={viewer ?? null} method={c.req.method} path={c.req.path} />, 404);
468});
469
409470// Git Smart HTTP protocol routes (must be before web routes)
410471app.route("/", gitRoutes);
411472
412473