kanban-dev-webapp/tests/integration/user-rules.test.ts
Dave 2a8d6f2505 fix(security): lock the users collection against privilege escalation
The users collection kept PocketBase's defaults because the Plan 1a rules
migration covered eight collections and missed this one:

  createRule ""                      -> anyone could sign up
  updateRule "id = @request.auth.id"  -> anyone could edit their own record

`role` is an ordinary field here, so both were escalations. Verified
exploitable against the live instance: an anonymous request created an
account with role "owner", and a client manager promoted themselves to
owner by patching their own record. Either route yields read and write
access to every client's board.

Create, update and delete are now superuser-only; list and view are
owner-or-self. Adds four integration tests so a regression fails loudly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 11:18:13 +02:00

78 lines
2.8 KiB
TypeScript

import { describe, it, expect, beforeAll } from "vitest";
import PocketBase from "pocketbase";
import { DEMO, PB_URL, adminPb, requireServices, userPb } from "./helpers";
/**
* Regression guard for a live privilege escalation.
*
* The built-in `users` collection ships with createRule "" (anyone may sign
* up) and updateRule "id = @request.auth.id" (anyone may edit themselves).
* Because `role` is an ordinary field on that collection, both were
* exploitable: an anonymous request could create an owner account, and a
* client manager could promote themselves to owner and then read every other
* client's board. Both were verified working against the live instance before
* the rules were locked.
*
* If either test here fails, someone has reverted the users rules and every
* client's data is readable by anyone who can reach the PocketBase URL.
*/
let admin: PocketBase;
beforeAll(async () => {
await requireServices();
admin = await adminPb();
});
describe("the users collection cannot be used to escalate privileges", () => {
it("refuses anonymous account creation", async () => {
const anon = new PocketBase(PB_URL);
anon.autoCancellation(false);
await expect(
anon.collection("users").create({
email: `anon-${Date.now()}@example.com`,
password: "probe-password-123",
passwordConfirm: "probe-password-123",
name: "Should not exist",
role: "owner",
})
).rejects.toThrow();
});
it("refuses a client manager promoting themselves to owner", async () => {
const pb = await userPb(DEMO.managerEmail, DEMO.managerPassword);
const selfId = pb.authStore.record!.id;
await expect(
pb.collection("users").update(selfId, { role: "owner" })
).rejects.toThrow();
const after = await admin.collection("users").getOne(selfId);
expect(after.role).toBe("client_manager");
});
it("refuses a client manager reassigning themselves to another client", async () => {
// The other half of the same hole: switching `client` would hand them a
// different customer's entire board through the normal read rules.
const pb = await userPb(DEMO.managerEmail, DEMO.managerPassword);
const selfId = pb.authStore.record!.id;
const before = await admin.collection("users").getOne(selfId);
await expect(
pb.collection("users").update(selfId, { client: "" })
).rejects.toThrow();
const after = await admin.collection("users").getOne(selfId);
expect(after.client).toBe(before.client);
});
it("does not let a client manager list other accounts", async () => {
const pb = await userPb(DEMO.managerEmail, DEMO.managerPassword);
const visible = await pb.collection("users").getFullList();
expect(visible).toHaveLength(1);
expect(visible[0].id).toBe(pb.authStore.record!.id);
});
});