184 lines · 8.5 KB · TypeScript
RawDownload
import { mkdtempSync } from "node:fs";import { tmpdir } from "node:os";import { join } from "node:path"; import { afterAll, expect, test } from "@effect/vitest"; import { mintInviteCode } from "../testing/mint-invite"; // ---------------------------------------------------------------------------// THE ESCALATION, performed rather than asserted about.//// `admin-users.node.test.ts` already asserts a plain member gets a 403, and it// PASSED while the instance was fully bypassable — because it only ever asked// the question one way. A member who simply calls the admin plane is refused;// a member who first arranges to be an owner OF SOMETHING is a different// caller, and the old gate could not tell the difference: it read the role from// `session.activeOrganizationId`, which the caller controls, while the reads// underneath stayed scoped to the instance's boot-seeded org.//// So this file does not test the label ("members are refused"). It runs the// actual attack an invited member can run with nothing but their own session// and the auth routes Better Auth mounts:////   1. POST /api/auth/organization/create  → become `owner` of a new org//   2. POST /api/auth/organization/set-active → point the session at it//   3. call the admin planes                → must STILL be refused//// and asserts the refusal survives every step, on BOTH planes. The users plane// leaks the instance's whole user directory and connection inventory; the// invite plane is worse, because a `role: "admin"` invite code is durable// escalation that outlives the session. Step 1 is now refused outright// (`allowUserToCreateOrganization: false`), so the test asserts what it can at// each step and never lets a blocked earlier step silently excuse a later one —// steps 2 and 3 run regardless of whether step 1 succeeded.//// MUTATION-CHECKED, both directions://   - gate reverted to a session-scoped role lookup + org creation re-opened://     FAILS with "/api/admin/users refuses the escalated member: expected 200//     to be 403". The escalation really is reachable, and this test really does//     catch it.//   - gate fixed, org creation re-opened: every admin-plane assertion above//     PASSES and only the final defense-in-depth line fails. That is the//     evidence that the gate — not the flag — is what closes the hole.// --------------------------------------------------------------------------- process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-admin-escalation-"));process.env.BETTER_AUTH_SECRET = "admin-escalation-secret-0123456789-abcdefg";process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@escalation.test";process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-pass-123456"; const { makeSelfHostApiHandler } = await import("../app");const { handler, dispose } = await makeSelfHostApiHandler();afterAll(() => dispose()); const BASE = "http://localhost:4788"; const asMember = (token: string) => ({ authorization: `Bearer ${token}` }); const post = (path: string, token: string, body: unknown) =>  handler(    new Request(`${BASE}${path}`, {      method: "POST",      headers: { "content-type": "application/json", ...asMember(token) },      body: JSON.stringify(body),    }),  ); const get = (path: string, token: string) =>  handler(new Request(`${BASE}${path}`, { headers: asMember(token) })); /** Join the instance for real: mint an invite as the bootstrap admin, redeem it. *  This is a legitimate, fully authenticated member of the instance org — the *  weakest principal that has any standing at all, which is precisely the *  attacker this gate has to hold against. */const joinAsMember = async (email: string): Promise<string> => {  const inviteCode = await mintInviteCode(handler);  const signUp = await handler(    new Request(`${BASE}/api/auth/sign-up/email`, {      method: "POST",      headers: { "content-type": "application/json" },      body: JSON.stringify({ email, password: "password-12345678", name: "Member", inviteCode }),    }),  );  expect(signUp.status, "the member joined through the real invite flow").toBe(200);  const token = signUp.headers.get("set-auth-token") ?? "";  expect(token).not.toBe("");  return token;}; test("a member cannot escalate by owning an organization of their own", async () => {  const memberToken = await joinAsMember("climber@escalation.test");   // Baseline: the ordinary refusal the old test already covered. If this were  // ever a 200 the rest of the file would be meaningless, so it is asserted  // first rather than assumed.  expect((await get("/api/admin/users", memberToken)).status, "a plain member is refused").toBe(    403,  );   // -- Step 1: become an owner. ---------------------------------------------  // With `allowUserToCreateOrganization: false` this is refused outright. Its  // status is RECORDED, not asserted here: the security property this test  // exists for is "the admin plane stays shut", and asserting the block at step  // 1 would abort the run before step 3 ever asserts it. So the attack proceeds  // as far as it can either way, the planes are checked, and only then is the  // defense-in-depth flag asserted at the bottom.  const created = await post("/api/auth/organization/create", memberToken, {    name: "Climber Org",    slug: "climber-org",  });  const createdOrgId = created.ok ? ((await created.json()) as { id?: string }).id : undefined;   // -- Step 2: point the session at an org the caller owns. ------------------  // The cheaper repeat path. Attempted against the created org when step 1  // somehow succeeded, and unconditionally against the slug either way.  if (createdOrgId) {    await post("/api/auth/organization/set-active", memberToken, {      organizationId: createdOrgId,    });  }  await post("/api/auth/organization/set-active", memberToken, {    organizationSlug: "climber-org",  });   // -- Step 3: the planes must still refuse. ---------------------------------  // Every admin users route, not just the list: a gate applied at four call  // sites can be fixed at three.  for (const path of [    "/api/admin/users",    "/api/admin/users/with-connections",    "/api/admin/users/user_anyone/connections",    "/api/admin/users/user_anyone",    `/api/admin/users/${encodeURIComponent("admin@escalation.test")}`,  ]) {    const response = await get(path, memberToken);    expect(response.status, `${path} refuses the escalated member`).toBe(403);    // A refusal that leaked the answer in its body would be no refusal at all.    expect(await response.text(), `${path} discloses nothing`).not.toContain(      "admin@escalation.test",    );  }   // The invite plane shares the gate, and a bypass there is DURABLE: a  // `role: "admin"` code redeems into a real admin membership that outlives  // this session. Read and write are both checked — listing invite codes is  // itself a disclosure (a live code is a credential).  expect((await get("/api/admin/invites", memberToken)).status, "invite list refused").toBe(403);   const minted = await post("/api/admin/invites", memberToken, { role: "admin" });  expect(minted.status, "an admin invite cannot be minted by a member").toBe(403);  expect(await minted.text(), "and no code came back").not.toContain("code");   const revoked = await handler(    new Request(`${BASE}/api/admin/invites/some-invite-id`, {      method: "DELETE",      headers: asMember(memberToken),    }),  );  expect(revoked.status, "revoking someone else's invite is refused too").toBe(403);   // The instance's real admin is unaffected — the gate closed the hole, it did  // not close the door. Without this, every assertion above would also pass if  // the admin plane were simply broken for everyone.  const adminList = await get("/api/admin/users", await signInAdmin());  expect(adminList.status, "the real instance admin still gets in").toBe(200);   // Defense in depth, asserted LAST so it can never mask the assertions above:  // step 1 of the attack is itself closed on a single-org instance.  expect(created.status, "org creation is closed on a single-org instance").not.toBe(200);}); const signInAdmin = async (): Promise<string> => {  const response = await handler(    new Request(`${BASE}/api/auth/sign-in/email`, {      method: "POST",      headers: { "content-type": "application/json" },      body: JSON.stringify({        email: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL,        password: process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD,      }),    }),  );  return response.headers.get("set-auth-token") ?? "";};