1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 | /**
* Platform-wide private-repo gate for /:owner/:repo/... subpages.
*
* Every repo subpage owned its own privacy check and it drifted. A live probe
* of a private repo on 2026-07-28 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
*
* These leaked branch and workflow names, CI logs, deployment history,
* dependency manifests and wiki content for repositories the caller could not
* open.
*
* The gate is registered once in app.tsx. What must not regress: it runs
* before the repo routers, after softAuth, denies with 404 rather than 403,
* and falls through for anything that is not a resolvable private repo — so
* /settings/tokens, /orgs/new and the git Smart HTTP paths are untouched.
*/
import { describe, expect, it } from "bun:test";
import { readFileSync } from "fs";
const SRC = readFileSync("src/app.tsx", "utf8");
const gate = SRC.slice(
SRC.indexOf('app.use("/:owner/:repo/*"'),
SRC.indexOf('app.route("/", gitRoutes)')
);
describe("gate placement", () => {
it("is registered before every repo router", () => {
const gateIdx = SRC.indexOf('app.use("/:owner/:repo/*"');
expect(gateIdx).toBeGreaterThan(-1);
// gitRoutes is the first router mounted; webRoutes the last.
expect(gateIdx).toBeLessThan(SRC.indexOf('app.route("/", gitRoutes)'));
expect(gateIdx).toBeLessThan(SRC.indexOf('app.route("/", webRoutes)'));
});
it("runs after softAuth so the viewer is resolved", () => {
// Registered earlier and c.get("user") would always be undefined, which
// would 404 private repos even for their own owner.
expect(SRC.indexOf('app.use("*", softAuth)')).toBeLessThan(
SRC.indexOf('app.use("/:owner/:repo/*"')
);
});
});
describe("deny behaviour", () => {
it("denies with 404, never 403", () => {
expect(gate).toContain('access !== "none"');
expect(gate).toContain("404");
expect(gate).not.toMatch(/,\s*403\s*\)/);
});
it("uses resolveRepoAccess so collaborators and org members still pass", () => {
expect(gate).toContain("resolveRepoAccess");
// An ownerId equality check would lock out collaborators.
expect(gate).not.toMatch(/user\.id\s*!==/);
});
it("answers JSON callers with JSON", () => {
expect(gate).toContain("application/json");
});
});
describe("false positives fall through", () => {
it("passes through when the repo does not resolve", () => {
// /settings/tokens, /orgs/new, /:owner/:repo.git/* all resolve to null.
expect(gate).toContain("if (!row || !row.isPrivate) return next()");
});
it("passes through public repos untouched", () => {
// Same line: a non-private row short-circuits before any access lookup,
// so public browsing costs nothing extra beyond the namespace read.
expect(gate).toContain("!row.isPrivate");
});
it("fails OPEN on a namespace lookup error", () => {
// A DB blip must not 404 the entire public site. The handler downstream
// surfaces its own error instead.
const cat = gate.slice(gate.indexOf("} catch {"), gate.indexOf("if (!row"));
expect(cat).toContain("return next()");
});
});
|