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

fix(security): GraphQL disclosed any account's email to anonymous callers

fix(security): GraphQL disclosed any account's email to anonymous callers

`user(username: "...")` selected users.email and returned it verbatim. The
resolver is reachable anonymously (POST /api/graphql is behind softAuth only),
so an unauthenticated caller could read the address of any account on the
platform — bulk harvesting with one cheap query per username.

Verified live before the fix:
  $ curl -s -X POST https://gluecron.com/api/graphql \
      -d '{"query":"{ user(username:\"ccantynz\"){ username email } }"}'
  {"data":{"user":{"username":"ccantynz","email":"ccantynz@gmail.com"}}}

Return the address only to the account holder; everyone else gets null. Null
rather than omitting the field so clients that select `email` keep a valid
response shape. The `viewer` resolver is untouched and remains the supported
way to read your own address — it already gates on ctx.user.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ccantynz-alt committed on July 27, 2026Parent: f0cd0be
2 files changed+79243755fad9b15d6959d6572899a415174144509e3
2 changed files+79−2
Addedsrc/__tests__/graphql-email-privacy.test.ts+68−0View fileUnifiedSplit
1/**
2 * GraphQL must not disclose other users' email addresses.
3 *
4 * `user(username: "...")` selected users.email and returned it verbatim. The
5 * resolver is reachable anonymously (POST /api/graphql, softAuth only), so an
6 * unauthenticated caller could read the address of any account on the
7 * platform — bulk harvesting with one cheap query per username.
8 *
9 * Verified live 2026-07-28 before the fix:
10 * $ curl -s -X POST https://gluecron.com/api/graphql \
11 * -d '{"query":"{ user(username:\"ccantynz\"){ username email } }"}'
12 * {"data":{"user":{"username":"ccantynz","email":"ccantynz@gmail.com"}}}
13 *
14 * The `me` resolver remains the supported way to read your own address; it is
15 * already scoped to ctx.user.id.
16 */
17
18import { describe, expect, it } from "bun:test";
19import { readFileSync } from "fs";
20
21const SRC = readFileSync("src/lib/graphql.ts", "utf8");
22const userResolver = SRC.slice(
23 SRC.indexOf("user: async (args, sel, _ctx)"),
24 SRC.indexOf("repository: async (args, sel, _ctx)")
25);
26
27describe("user(username:) email exposure", () => {
28 it("only returns email to the account holder", () => {
29 expect(userResolver).toContain("_ctx?.user?.id === row.id");
30 expect(userResolver).toContain("email: isSelf ? row.email : null");
31 });
32
33 it("does not pass the raw row through to selection resolution", () => {
34 // The bug shape: `resolveSelections(u, sel)` where u came straight from
35 // the DB with email attached.
36 const idx = userResolver.indexOf("const u = {");
37 expect(idx).toBeGreaterThan(-1);
38 // The redacted object must be built before anything consumes it.
39 const consume = userResolver.indexOf("resolveSelections(u");
40 if (consume > -1) expect(idx).toBeLessThan(consume);
41 });
42
43 it("nulls the field rather than dropping it", () => {
44 // Clients that request `email` should still get a valid response shape.
45 expect(userResolver).toContain(": null");
46 });
47});
48
49describe("the viewer resolver is unaffected", () => {
50 it("still scopes to the authenticated user", () => {
51 // `viewer`, not `me` — it is the self-lookup and is already gated on
52 // ctx.user, so reading your own address keeps working.
53 const viewer = SRC.slice(
54 SRC.indexOf("viewer: async"),
55 SRC.indexOf("user: async")
56 );
57 expect(viewer).toContain("ctx.user.id");
58 expect(viewer).toContain("email: users.email");
59 expect(viewer).toContain("if (!ctx.user) return null");
60 });
61});
62
63describe("no other resolver selects email", () => {
64 it("only viewer and user touch users.email", () => {
65 const hits = SRC.match(/email: users\.email/g) ?? [];
66 expect(hits.length).toBe(2); // viewer (scoped) + user (now redacted)
67 });
68});
Modifiedsrc/lib/graphql.ts+11−2View fileUnifiedSplit
292292 user: async (args, sel, _ctx) => {
293293 const username = String(args.username || "");
294294 if (!username) return null;
295 const [u] = await db
295 const [row] = await db
296296 .select({
297297 id: users.id,
298298 username: users.username,
302302 .from(users)
303303 .where(eq(users.username, username))
304304 .limit(1);
305 if (!u) return null;
305 if (!row) return null;
306
307 // `email` is PII and this resolver is reachable anonymously, so it was
308 // handing out the address of every account on the platform to anyone who
309 // asked — bulk harvesting with one query per username. Only the account
310 // holder sees their own address here; the `viewer` resolver above is the
311 // supported way to read it. Null rather than omitted so the field stays
312 // valid for clients that request it.
313 const isSelf = _ctx?.user?.id === row.id;
314 const u = { ...row, email: isSelf ? row.email : null };
306315 const needsRepos = sel.some((s) => s.name === "repos");
307316 let repos: any[] = [];
308317 if (needsRepos) {
309318