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>
78 lines
2.8 KiB
TypeScript
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);
|
|
});
|
|
});
|