kanban-dev-webapp/tests/integration/session-verification.test.ts

86 lines
3.1 KiB
TypeScript
Raw Permalink Normal View History

fix(auth): verify sessions against PocketBase instead of trusting the cookie getCurrentUser() only decoded the pb_auth cookie locally and trusted its embedded role/client/active fields directly — the PocketBase JS SDK holds no secret to check a JWT signature against, so a completely forged cookie (garbage signature, role: "owner", any email) was accepted as a real session. Verified exploitable end-to-end: a raw Cookie header against the live app, no browser and no password, rendered the owner's home page. This mattered most for actions writing through the superuser connection (log-work.ts, summarise.ts, ingest.ts, and critically users.ts for account creation) — those never route the caller's token through PocketBase's own rule engine at all, so the app-level role check was the ONLY gate, and it was reading an unverified claim. Actions using the caller's own token (cards.ts, projects.ts) still made the wrong authorization DECISION from the forged role, even though PocketBase's rule engine would have rejected the actual data write. Fixed by having getCurrentUser() call authRefresh(), which asks PocketBase to confirm the token is genuine; a forged or tampered token is refused there and treated as signed-out. Wrapped in React's cache() so the extra round-trip happens once per request, not once per call site (several pages call this more than once). Re-fetching the live record also means a deactivated account (`active: false`) stops working on its very next request instead of only being blocked at a new login. Regression test builds the exact forged cookie used to verify this, confirmed red against the vulnerable code and green against the fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 10:47:18 +00:00
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);
});
});