Commitfde8ff2
fix(security): GET /api/users/:username/repos leaked private repos + diskPath
fix(security): GET /api/users/:username/repos leaked private repos + diskPath Sibling of the leak fixed in 8102dd4. That commit gated the single-repo handler GET /api/repos/:owner/:name but left the list endpoint directly above it untouched, so it kept returning `select()` — every row, every column — for whatever username you asked about. Verified live before the fix: an anonymous GET /api/users/ccantynz/repos returned two private repositories (ops-bootstrap, Vapron) with isPrivate:true, ownerId, description and diskPath. - Filter each row through resolveRepoAccess() so owners, collaborators and org members keep full visibility while anonymous callers see only public repos. - Strip diskPath from every row; it discloses the server storage layout and was never meant to reach an API client. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 files changed+48−2fde8ff2e5b47b1ba4a2af7285a2e37ee6ea42783
2 changed files+48−2
Modifiedsrc/__tests__/api-v2-repo-privacy.test.ts+22−0View fileUnifiedSplit
@@ -78,3 +78,25 @@ describe("repoExists is not a privacy check", () => {
7878 expect(fn).not.toContain("repositories");
7979 });
8080});
81
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});
Modifiedsrc/routes/api.ts+26−2View fileUnifiedSplit
@@ -159,12 +159,36 @@ api.get("/users/:username/repos", async (c) => {
159159 .where(eq(users.username, username));
160160 if (!owner) return c.json({ error: "User not found" }, 404);
161161
162 const repos = await db
162 // Sibling fix to GET /repos/:owner/:name: that handler was gated but this
163 // list was not, so it kept returning every private repo the user owns —
164 // name, description, ownerId and the internal diskPath — to anonymous
165 // callers. Filter per row through the same access helper the HTML surface
166 // uses, so collaborators and org members keep seeing what they should.
167 const rows = await db
163168 .select()
164169 .from(repositories)
165170 .where(eq(repositories.ownerId, owner.id));
166171
167 return c.json(repos);
172 const viewer = c.get("user");
173 const { resolveRepoAccess } = await import("../middleware/repo-access");
174 const visible = (
175 await Promise.all(
176 rows.map(async (r) => {
177 if (!r.isPrivate) return r;
178 const access = await resolveRepoAccess({
179 repoId: r.id,
180 userId: viewer?.id ?? null,
181 isPublic: false,
182 });
183 return access === "none" ? null : r;
184 })
185 )
186 ).filter((r): r is (typeof rows)[number] => r !== null);
187
188 // diskPath leaks the server's storage layout — never serialize it.
189 const safe = visible.map(({ diskPath: _diskPath, ...rest }) => rest);
190
191 return c.json(safe);
168192 } catch (err) {
169193 console.error("[api] /users/:username/repos:", err);
170194 return c.json({ error: "Service unavailable" }, 503);
171195