Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit754596c

fix(security): POST /api/repos and /api/setup were fully unauthenticated

fix(security): POST /api/repos and /api/setup were fully unauthenticated

Neither endpoint had any auth middleware, and csrfProtect does not cover
them — src/middleware/csrf.ts explicitly returns next() for paths under
/api/.

POST /api/repos took the target namespace from `owner` in the request body
and never looked at the caller, so anyone on the internet could create
repositories inside any user's namespace.

POST /api/setup upserts a user and seeds any not-yet-existing username with
the fixed password "changeme". On a populated instance that is anonymous
account creation plus username squatting — register a victim's preferred
handle, or an official-looking name, with a password you already know.

- /api/repos: require an authenticated viewer (softAuth has already resolved
  session cookie, OAuth bearer or `glc_` PAT, so an inline check returns a
  JSON 401 instead of the HTML redirect requireAuth would issue). Auth is the
  first gate, before body parsing, so the error shape cannot be used to probe
  what the endpoint would have accepted. `owner` is now optional and must
  match the caller if supplied — refused with 403 rather than silently
  rewritten, which would make a forged request appear to succeed. Org
  placement still runs the existing orgSlug admin check.
- /api/setup: restrict to genuine first-run, i.e. the users table is empty.
  404 rather than 403 so a live instance does not advertise it. The
  user-count probe fails CLOSED with 503 — an unreachable database is not
  permission to bootstrap an account.

No production callers exist for either; the only references were tests.
Three existing tests asserted 400 on anonymous POSTs, which only held
because the endpoint was open — updated to expect 401.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ccantynz-alt committed on July 27, 2026Parent: a1beefb
4 files changed+16410754596cef0b404719e15142e3a04074af78cf210
4 changed files+164−10
Addedsrc/__tests__/api-write-auth.test.ts+97−0View fileUnifiedSplit
1/**
2 * Unauthenticated write endpoints on the legacy /api surface.
3 *
4 * POST /api/repos had no auth middleware at all and took the target namespace
5 * from `owner` in the request body — anyone on the internet could create a
6 * repository inside any user's namespace. csrfProtect does not mitigate it:
7 * src/middleware/csrf.ts explicitly returns next() for paths under /api/.
8 *
9 * POST /api/setup was likewise open and upserts a user, seeding any username
10 * that does not yet exist with the fixed password "changeme" — anonymous
11 * account creation and username squatting on a populated instance.
12 */
13
14import { describe, expect, it } from "bun:test";
15import app from "../app";
16import { readFileSync } from "fs";
17
18async function postJson(path: string, body: unknown, headers: Record<string, string> = {}) {
19 return app.fetch(
20 new Request(`http://localhost${path}`, {
21 method: "POST",
22 headers: { "content-type": "application/json", ...headers },
23 body: JSON.stringify(body),
24 })
25 );
26}
27
28describe("POST /api/repos requires authentication", () => {
29 it("rejects an anonymous caller with 401, not 400/500", async () => {
30 const res = await postJson("/api/repos", {
31 name: "pwned",
32 owner: "someoneelse",
33 });
34 expect(res.status).toBe(401);
35 const body = await res.json();
36 expect(body.error).toBe("Authentication required");
37 });
38
39 it("rejects before parsing the body, so a malformed body still 401s", async () => {
40 // Auth must be the first gate — otherwise the shape of the error leaks
41 // whether the endpoint would have accepted the request.
42 const res = await app.fetch(
43 new Request("http://localhost/api/repos", {
44 method: "POST",
45 headers: { "content-type": "application/json" },
46 body: "{not json",
47 })
48 );
49 expect(res.status).toBe(401);
50 });
51
52 it("an invalid bearer token does not pass as authenticated", async () => {
53 const res = await postJson(
54 "/api/repos",
55 { name: "pwned" },
56 { authorization: "Bearer glc_deadbeefdeadbeefdeadbeefdeadbeef" }
57 );
58 expect(res.status).toBe(401);
59 });
60});
61
62describe("POST /api/setup is first-run only", () => {
63 it("404s on an instance that already has users", async () => {
64 const res = await postJson("/api/setup", {
65 username: "squatter",
66 email: "a@b.c",
67 repoName: "x",
68 });
69 // 404 (not 403) so a live instance does not advertise the endpoint.
70 // 503 is acceptable here: no DATABASE_URL in the unit-test environment
71 // means the user-count probe cannot run, and it must not fall through.
72 expect([404, 503]).toContain(res.status);
73 expect(res.status).not.toBe(200);
74 });
75});
76
77describe("the namespace check itself", () => {
78 const SRC = readFileSync("src/routes/api.ts", "utf8");
79 const handler = SRC.slice(
80 SRC.indexOf('api.post("/repos"'),
81 SRC.indexOf('api.get("/repos")') > 0
82 ? SRC.indexOf('api.get("/repos")')
83 : SRC.indexOf('api.get("/users/:username/repos"')
84 );
85
86 it("forces the owner to the authenticated user", () => {
87 expect(handler).toContain("body.owner = viewer.username");
88 });
89
90 it("refuses a mismatched owner rather than silently rewriting it", () => {
91 // Silently rewriting would make a forged request look like it succeeded
92 // against the attacker's own namespace.
93 expect(handler).toContain("body.owner !== viewer.username");
94 expect(handler).toContain("403");
95 });
96});
97
Modifiedsrc/__tests__/api.test.ts+4−3View fileUnifiedSplit
3030 expect([404, 503]).toContain(res.status);
3131 });
3232
33 it("POST /api/repos returns 400 without required fields, never 500", async () => {
33 it("POST /api/repos returns 401 for an anonymous caller, never 500", async () => {
3434 const res = await app.request("/api/repos", {
3535 method: "POST",
3636 headers: { "Content-Type": "application/json" },
3737 body: JSON.stringify({}),
3838 });
39 // Validation happens before DB access — always 400.
40 expect(res.status).toBe(400);
39 // Was 400: the handler had no auth gate, so an anonymous request reached
40 // field validation. Auth is now the first check, so it never gets there.
41 expect(res.status).toBe(401);
4142 });
4243
4344 it("GET /api/users/:u/repos returns 404 or 503, never 500", async () => {
Modifiedsrc/__tests__/green-ecosystem.test.ts+7−5View fileUnifiedSplit
618618 expect(loc.startsWith("/login")).toBe(true);
619619 });
620620
621 it("POST /api/repos with orgSlug still validates required fields", async () => {
621 // Both of these previously asserted 400, which only held because the
622 // endpoint had no auth gate and an anonymous caller reached field
623 // validation. Authentication is now the first check.
624 it("POST /api/repos with orgSlug requires authentication first", async () => {
622625 const res = await app.request("/api/repos", {
623626 method: "POST",
624627 headers: { "Content-Type": "application/json" },
625628 body: JSON.stringify({ orgSlug: "acme" }),
626629 });
627 // Missing name + owner → 400 before any DB access.
628 expect(res.status).toBe(400);
630 expect(res.status).toBe(401);
629631 });
630632
631 it("POST /api/repos rejects invalid repo names before DB access", async () => {
633 it("POST /api/repos rejects an anonymous caller before name validation", async () => {
632634 const res = await app.request("/api/repos", {
633635 method: "POST",
634636 headers: { "Content-Type": "application/json" },
637639 owner: "alice",
638640 }),
639641 });
640 expect(res.status).toBe(400);
642 expect(res.status).toBe(401);
641643 });
642644});
643645
Modifiedsrc/routes/api.ts+56−2View fileUnifiedSplit
1818
1919// Create repository
2020api.post("/repos", async (c) => {
21 // This handler had NO auth middleware and took the target namespace from
22 // `owner` in the request body, so anyone on the internet could create
23 // repositories inside any user's namespace. csrfProtect does not help:
24 // src/middleware/csrf.ts explicitly exempts paths under /api/.
25 //
26 // softAuth (applied globally in app.tsx) has already resolved a session
27 // cookie, an OAuth bearer, or a `glc_` PAT into c.get("user"), so an inline
28 // check is enough — and returns a JSON 401 rather than the HTML redirect
29 // requireAuth would issue for a cookie-less caller.
30 const viewer = c.get("user");
31 if (!viewer) {
32 return c.json(
33 { error: "Authentication required", hint: "Use Authorization: Bearer <token> header" },
34 401
35 );
36 }
37
2138 let body: {
2239 name: string;
2340 owner: string;
3148 return c.json({ error: "Invalid JSON body" }, 400);
3249 }
3350
34 if (!body.name || !body.owner) {
35 return c.json({ error: "name and owner are required" }, 400);
51 if (!body.name) {
52 return c.json({ error: "name is required" }, 400);
53 }
54
55 // The caller may only create in their own namespace. `owner` is now
56 // optional; if supplied it must match, so an old client that sends its own
57 // username keeps working while a forged one is refused. Org placement still
58 // goes through orgSlug below, which checks the creator's admin rights.
59 if (body.owner && body.owner !== viewer.username) {
60 return c.json(
61 { error: "Cannot create a repository in another user's namespace" },
62 403
63 );
3664 }
65 body.owner = viewer.username;
3766
3867 // Validate repo name
3968 if (!/^[a-zA-Z0-9._-]+$/.test(body.name)) {
229258
230259// Quick-setup: create user + repo in one call (dev convenience)
231260api.post("/setup", async (c) => {
261 // First-run bootstrap ONLY.
262 //
263 // This endpoint was completely unauthenticated and upserts a user, seeding
264 // any username that does not yet exist with the fixed password "changeme".
265 // On a populated instance that is anonymous account creation plus username
266 // squatting — register the victim's preferred handle, or any name that looks
267 // official, with a password the attacker already knows.
268 //
269 // Its legitimate purpose is seeding the very first account on a fresh
270 // install, so gate it on the instance genuinely being empty. There are no
271 // production callers; the only references are tests.
272 let userCount: number;
273 try {
274 const [row] = await db.select({ n: sql<number>`count(*)::int` }).from(users);
275 userCount = Number(row?.n ?? 0);
276 } catch {
277 // Fail CLOSED. If we cannot prove the instance is empty we must not seed
278 // an account — an unreachable DB is not permission to bootstrap.
279 return c.json({ error: "Service unavailable" }, 503);
280 }
281 if (userCount > 0) {
282 // 404, not 403: on a live instance this endpoint simply does not exist.
283 return c.json({ error: "Not found" }, 404);
284 }
285
232286 let body: {
233287 username: string;
234288 email: string;
235289