Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

api-v2-repo-privacy.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

api-v2-repo-privacy.test.tsBlame102 lines · 1 contributor
f815168ccantynz-alt1/**
2 * Private repositories must not be readable through the v2 REST API.
3 *
4 * Regression test for a live data leak found 2026-07-27: five git-content
5 * endpoints under /api/v2/repos/:owner/:repo gated only on `repoExists()`,
6 * which is a bare filesystem probe (Bun.file(<repoPath>/HEAD).exists()) and
7 * never reads the repositories row — so it never sees isPrivate. Anonymous
8 * callers could read branches, commit history (with author emails), the full
9 * recursive tree, and raw file contents of any private repo, while the HTML
10 * surface correctly returned 404.
11 *
12 * These are structural assertions rather than live HTTP calls: the leak was a
13 * missing gate, and what must not regress is that the gate exists and is
14 * mounted at the router rather than sprinkled per-handler.
15 */
16
17import { describe, expect, it } from "bun:test";
18import { readFileSync } from "fs";
19
20const SRC = readFileSync("src/routes/api-v2.ts", "utf8");
21
22describe("api-v2 repo privacy gate", () => {
23 it("is mounted on the whole /repos/:owner/:repo subtree", () => {
24 expect(SRC).toContain('apiv2.use("/repos/:owner/:repo", repoPrivacyGate)');
25 expect(SRC).toContain('apiv2.use("/repos/:owner/:repo/*", repoPrivacyGate)');
26 });
27
28 it("denies with 404, never 403 — a private repo must not confirm it exists", () => {
29 const gate = SRC.slice(
30 SRC.indexOf("async function repoPrivacyGate"),
31 SRC.indexOf("// ─── Helper")
32 );
33 expect(gate).toContain('access === "none"');
34 expect(gate).toContain('c.json({ error: "Not found" }, 404)');
35 // No 403 as an actual returned status (the word appears in a comment
36 // explaining why 404 is the right choice, hence matching the call shape).
37 expect(gate).not.toMatch(/,\s*403\s*\)/);
38 });
39
40 it("uses resolveRepoAccess, not an ownerId comparison", () => {
41 const gate = SRC.slice(
42 SRC.indexOf("async function repoPrivacyGate"),
43 SRC.indexOf("// ─── Helper")
44 );
45 // An ownerId equality check would wrongly deny collaborators and org
46 // members — that was the flaw in the pre-existing inline checks.
47 expect(gate).toContain("resolveRepoAccess");
48 expect(gate).not.toMatch(/user\.id\s*!==\s*\w*[Oo]wner/);
49 });
50
51 it("runs after apiAuth so the viewer identity is populated", () => {
52 // If the gate were registered before apiAuth, c.get("user") would always
53 // be undefined and every private repo would 404 even for its owner.
54 expect(SRC.indexOf('apiv2.use("*", apiAuth)')).toBeLessThan(
55 SRC.indexOf('apiv2.use("/repos/:owner/:repo", repoPrivacyGate)')
56 );
57 });
58
59 it("lets unknown repos fall through to each handler's own 404", () => {
60 const gate = SRC.slice(
61 SRC.indexOf("async function repoPrivacyGate"),
62 SRC.indexOf("// ─── Helper")
63 );
64 expect(gate).toContain("if (!resolved) return next()");
65 });
66});
67
68describe("repoExists is not a privacy check", () => {
69 it("never consults the repositories table", () => {
70 const repo = readFileSync("src/git/repository.ts", "utf8");
71 const fn = repo.slice(
72 repo.indexOf("export async function repoExists"),
73 repo.indexOf("export async function repoExists") + 400
74 );
75 // Documents WHY the router gate is required: this helper cannot answer
76 // "may this caller see it?", only "is there a directory on disk?".
77 expect(fn).not.toContain("isPrivate");
78 expect(fn).not.toContain("repositories");
79 });
80});
fde8ff2ccantynz-alt81
82describe("GET /api/users/:username/repos does not leak private repos", () => {
83 const SRC_API = readFileSync("src/routes/api.ts", "utf8");
84 const handler = SRC_API.slice(
85 SRC_API.indexOf('api.get("/users/:username/repos"'),
86 SRC_API.indexOf('api.get("/repos/:owner/:name"')
87 );
88
89 it("filters each row through resolveRepoAccess", () => {
90 expect(handler).toContain("resolveRepoAccess");
91 expect(handler).toContain('access === "none"');
92 });
93
94 it("strips diskPath from the response", () => {
95 // diskPath discloses the server's on-disk storage layout.
96 expect(handler).toMatch(/diskPath: _diskPath, \.\.\.rest/);
97 });
98
99 it("does not return the raw rows", () => {
100 expect(handler).not.toContain("return c.json(repos)");
101 });
102});