Files
padhle/backend/test/adversarial.test.js
2026-09-15 04:08:55 -04:00

160 lines
5.4 KiB
JavaScript

/**
* 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");
});