1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 | /**
* GraphQL must not disclose other users' email addresses.
*
* `user(username: "...")` selected users.email and returned it verbatim. The
* resolver is reachable anonymously (POST /api/graphql, 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 2026-07-28 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"}}}
*
* The `me` resolver remains the supported way to read your own address; it is
* already scoped to ctx.user.id.
*/
import { describe, expect, it } from "bun:test";
import { readFileSync } from "fs";
const SRC = readFileSync("src/lib/graphql.ts", "utf8");
const userResolver = SRC.slice(
SRC.indexOf("user: async (args, sel, _ctx)"),
SRC.indexOf("repository: async (args, sel, _ctx)")
);
describe("user(username:) email exposure", () => {
it("only returns email to the account holder", () => {
expect(userResolver).toContain("_ctx?.user?.id === row.id");
expect(userResolver).toContain("email: isSelf ? row.email : null");
});
it("does not pass the raw row through to selection resolution", () => {
// The bug shape: `resolveSelections(u, sel)` where u came straight from
// the DB with email attached.
const idx = userResolver.indexOf("const u = {");
expect(idx).toBeGreaterThan(-1);
// The redacted object must be built before anything consumes it.
const consume = userResolver.indexOf("resolveSelections(u");
if (consume > -1) expect(idx).toBeLessThan(consume);
});
it("nulls the field rather than dropping it", () => {
// Clients that request `email` should still get a valid response shape.
expect(userResolver).toContain(": null");
});
});
describe("the viewer resolver is unaffected", () => {
it("still scopes to the authenticated user", () => {
// `viewer`, not `me` — it is the self-lookup and is already gated on
// ctx.user, so reading your own address keeps working.
const viewer = SRC.slice(
SRC.indexOf("viewer: async"),
SRC.indexOf("user: async")
);
expect(viewer).toContain("ctx.user.id");
expect(viewer).toContain("email: users.email");
expect(viewer).toContain("if (!ctx.user) return null");
});
});
describe("no other resolver selects email", () => {
it("only viewer and user touch users.email", () => {
const hits = SRC.match(/email: users\.email/g) ?? [];
expect(hits.length).toBe(2); // viewer (scoped) + user (now redacted)
});
});
|