/** * Adversarial security tests — new findings beyond the existing leak suite. * * 1. RLS is enabled with correct "own sessions only" policies, BUT the backend * (db.js) runs every query through the `service_role` superuser client, * which has BYPASSRLS. So the DB backstop an app is supposed to rely on is * silently switched off for user data. This proves it: a user-scoped client * enforces RLS (denies another user's row), while the backend's admin path * reads it. * * 2. Token-cache revocation: verifyToken caches the user for a token for up to * 5 minutes but the cache is never purged on signout/revocation, so a * captured token keeps authenticating briefly after the session is revoked. * * Requires local Supabase (`npx supabase start`). */ import test, { before, after } from "node:test"; import assert from "node:assert/strict"; import { createClient } from "@supabase/supabase-js"; import app from "../src/app.js"; import { getSession } from "../src/services/db.js"; const PASSWORD = "adversarial-pass-123"; const RUN_ID = Date.now(); function makeJar() { let cookies = {}; return { capture(res) { for (const c of res.headers.getSetCookie ? res.headers.getSetCookie() : []) { const pair = c.split(";")[0]; const eq = pair.indexOf("="); if (eq === -1) continue; cookies[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim(); } }, header() { return Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join("; "); }, get(name) { return cookies[name]; }, set(name, value) { cookies[name] = value; }, clear() { cookies = {}; }, }; } let server; let base; before(async () => { server = app.listen(0, "127.0.0.1"); await new Promise((resolve) => server.once("listening", resolve)); base = `http://127.0.0.1:${server.address().port}`; }); after(async () => { if (server) await new Promise((resolve) => server.close(resolve)); }); async function api(path, { method = "GET", jar, rawCookie, body } = {}) { const headers = {}; if (jar) headers["Cookie"] = jar.header(); if (rawCookie) headers["Cookie"] = rawCookie; if (body !== undefined) headers["Content-Type"] = "application/json"; const res = await fetch(`${base}${path}`, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, }); if (jar) jar.capture(res); return res; } async function signup(jar, tag) { const email = `adv-${tag}-${RUN_ID}@example.com`; const res = await api("/api/auth/signup", { method: "POST", jar, body: { email, password: PASSWORD }, }); if (res.status !== 200) { assert.fail(`signup ${tag} -> ${res.status}: ${await res.text()}`); } const data = await res.json(); return { email, uid: data.user.uid, token: jar.get("padhle.token") }; } const usersToCleanup = []; after(async () => { const key = process.env.SUPABASE_SERVICE_ROLE_KEY; if (!key || !process.env.SUPABASE_URL) return; try { const admin = createClient(process.env.SUPABASE_URL, key); for (const u of usersToCleanup) { if (u?.uid) await admin.auth.admin.deleteUser(u.uid); } } catch { /* best-effort */ } }); test("RLS is the enforced data-layer backstop once the user token is threaded through", async () => { const jarA = makeJar(); const jarB = makeJar(); const userA = await signup(jarA, "a"); const userB = await signup(jarB, "b"); usersToCleanup.push(userA, userB); // A creates a session through the real API. const createRes = await api("/api/sessions", { method: "POST", jar: jarA, body: { grade: "Grade 10", subject: "math", chapter: "secret chapter" }, }); assert.equal(createRes.status, 201); const session = await createRes.json(); const sessionId = session.id; // 1) RLS (user-scoped client + B's token) denies B the row → null. const bRead = await getSession(sessionId, userB.token); assert.equal(bRead, null, "RLS denies B the row at the data layer (backstop active)"); // 2) The OWNER (A's token) reads it normally. const aRead = await getSession(sessionId, userA.token); assert.equal(aRead?.id, sessionId, "owner reads their own session via RLS"); assert.equal(aRead.user_id, userA.uid); // 3) Full API: B still gets 404 (both app-code check and RLS agree). const bApi = await api(`/api/sessions/${sessionId}`, { jar: jarB }); assert.equal(bApi.status, 404, "app-code check still rejects B"); }); test("a revoked token is rejected after signout (in-memory token cache is purged)", async () => { const jar = makeJar(); const user = await signup(jar, "cache"); usersToCleanup.push(user); assert.ok(user.token, "token captured from httpOnly cookie"); // Prime the cache. const me1 = await api("/api/auth/me", { jar }); assert.equal(me1.status, 200, "/me before signout"); assert.equal((await me1.json()).user.uid, user.uid); // Sign out: revokes the Supabase session and clears cookies. const so = await api("/api/auth/signout", { method: "POST", jar }); assert.equal(so.status, 200); // Replay the OLD token cookie (simulating a captured cookie). const stale = makeJar(); stale.set("padhle.token", user.token); const me2 = await api("/api/auth/me", { jar: stale }); // After the fix (invalidateToken on signout), the cache is purged and the // stale token must be rejected — NOT served from the cache. assert.equal(me2.status, 401, "revoked stale token must NOT authenticate after signout"); });