padhle - post migration
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
Reference in New Issue
Block a user