Blame · Line-by-line history
graphql-email-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.
| 43755fa | 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 | ||
| 18 | import { describe, expect, it } from "bun:test"; | |
| 19 | import { readFileSync } from "fs"; | |
| 20 | ||
| 21 | const SRC = readFileSync("src/lib/graphql.ts", "utf8"); | |
| 22 | const userResolver = SRC.slice( | |
| 23 | SRC.indexOf("user: async (args, sel, _ctx)"), | |
| 24 | SRC.indexOf("repository: async (args, sel, _ctx)") | |
| 25 | ); | |
| 26 | ||
| 27 | describe("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 | ||
| 49 | describe("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 | ||
| 63 | describe("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 | }); |