86 lines
3.1 KiB
TypeScript
86 lines
3.1 KiB
TypeScript
|
|
import { describe, it, expect } from "vitest";
|
||
|
|
import PocketBase from "pocketbase";
|
||
|
|
|
||
|
|
import { APP_URL, DEMO, requireServices, signIn } from "./helpers";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Regression guard for a live full-owner-impersonation hole.
|
||
|
|
*
|
||
|
|
* `getCurrentUser()` used to trust `pb.authStore.loadFromCookie()`'s decoded
|
||
|
|
* payload directly. That call only base64-decodes the cookie locally — the
|
||
|
|
* SDK holds no secret to check a signature against — so a completely forged
|
||
|
|
* cookie (garbage signature, `role: "owner"` claim, an email nobody owns)
|
||
|
|
* was accepted as a real owner session by every action and page in the app.
|
||
|
|
* Verified exploitable against this exact code path before the fix: a raw
|
||
|
|
* `Cookie` header, no browser, no password, full owner access.
|
||
|
|
*
|
||
|
|
* The fix makes `getCurrentUser()` call `authRefresh()`, which asks
|
||
|
|
* PocketBase itself to confirm the token. If this test ever passes again
|
||
|
|
* without the app being signed in as owner, that verification has been
|
||
|
|
* removed or bypassed.
|
||
|
|
*/
|
||
|
|
|
||
|
|
/** A cookie shaped exactly like a real one, with a token PocketBase never issued. */
|
||
|
|
function forgedOwnerCookie(): string {
|
||
|
|
const pb = new PocketBase("http://forged.invalid");
|
||
|
|
|
||
|
|
function b64url(obj: unknown): string {
|
||
|
|
return Buffer.from(JSON.stringify(obj)).toString("base64url");
|
||
|
|
}
|
||
|
|
const header = b64url({ alg: "HS256", typ: "JWT" });
|
||
|
|
const payload = b64url({
|
||
|
|
id: "forged-owner-id",
|
||
|
|
type: "auth",
|
||
|
|
collectionId: "_pb_users_auth_",
|
||
|
|
exp: Math.floor(Date.now() / 1000) + 3600,
|
||
|
|
});
|
||
|
|
const forgedToken = `${header}.${payload}.not_a_real_signature`;
|
||
|
|
|
||
|
|
// Load a fabricated record onto the auth store so exportToCookie() embeds
|
||
|
|
// it — this is exactly what a legitimate login also does, only here the
|
||
|
|
// token and record were never issued by PocketBase.
|
||
|
|
pb.authStore.save(forgedToken, {
|
||
|
|
id: "forged-owner-id",
|
||
|
|
email: "attacker@example.com",
|
||
|
|
role: "owner",
|
||
|
|
client: "",
|
||
|
|
active: true,
|
||
|
|
} as never);
|
||
|
|
|
||
|
|
return pb.authStore.exportToCookie({}, "pb_auth");
|
||
|
|
}
|
||
|
|
|
||
|
|
describe("a forged session cookie cannot impersonate the owner", () => {
|
||
|
|
it("refuses a request bearing a self-signed, PocketBase-never-issued cookie", async () => {
|
||
|
|
await requireServices();
|
||
|
|
|
||
|
|
const response = await fetch(`${APP_URL}/`, {
|
||
|
|
headers: { cookie: forgedOwnerCookie() },
|
||
|
|
redirect: "manual",
|
||
|
|
});
|
||
|
|
|
||
|
|
// A verified session check treats this as signed-out: redirected to
|
||
|
|
// /login, never a 200 rendering the owner's home page.
|
||
|
|
if (response.status >= 300 && response.status < 400) {
|
||
|
|
expect(response.headers.get("location")).toContain("/login");
|
||
|
|
} else {
|
||
|
|
// Some deployments render the redirect target directly rather than
|
||
|
|
// a 3xx — either is acceptable, but the OWNER'S home page must not
|
||
|
|
// have rendered.
|
||
|
|
const html = await response.text();
|
||
|
|
expect(html).not.toContain("Everything you are building");
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it("still accepts a real, freshly signed-in session", async () => {
|
||
|
|
await requireServices();
|
||
|
|
|
||
|
|
const cookie = await signIn(DEMO.ownerEmail, DEMO.ownerPassword);
|
||
|
|
const response = await fetch(`${APP_URL}/`, {
|
||
|
|
headers: { cookie },
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(response.status).toBe(200);
|
||
|
|
});
|
||
|
|
});
|