/** * End-to-end security integration test. * * Requires the LOCAL Supabase stack to be running (user starts it with * `npx supabase start`). The backend is started in-process on an ephemeral * loopback port; every request uses real httpOnly cookies against the real * local Supabase Auth + Postgres. * * Proves, against live infra: * 1. Cross-user session access is impossible (read/list/delete/clear/chat). * 2. `/api/auth/me` derives identity from the verified JWT, not the unsigned * `padhle.user` cookie (forged cookie → 401). * 3. Unauthenticated requests are rejected. * 4. Chapter metadata cannot inject markup/control characters. * 5. Security headers are set on every response. * * Run: cd backend && node --test test/leak.test.js */ 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"; const PASSWORD = "test-password-123"; const RUN_ID = Date.now(); // ─── cookie jar (httpOnly cookies are invisible to JS; here we replay them) ─── function makeJar() { let cookies = {}; return { capture(res) { const setCookies = res.headers.getSetCookie ? res.headers.getSetCookie() : []; for (const c of setCookies) { 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("; "); }, clear() { cookies = {}; }, }; } let server; let base; before(async () => { server = app.listen(0, "127.0.0.1"); await new Promise((resolve) => server.once("listening", resolve)); const { port } = server.address(); base = `http://127.0.0.1:${port}`; }); after(async () => { if (server) { await new Promise((resolve) => server.close(resolve)); } }); async function api(path, { method = "GET", jar, body, rawCookie } = {}) { 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 = `leak-${tag}-${RUN_ID}@example.com`; const res = await api("/api/auth/signup", { method: "POST", jar, body: { email, password: PASSWORD }, }); if (res.status !== 200) { const text = await res.text(); assert.fail(`signup ${tag} should succeed, got ${res.status} ${text}`); } const data = await res.json(); return { email, uid: data.user.uid }; } async function cleanupUsers(users) { const key = process.env.SUPABASE_SERVICE_ROLE_KEY; if (!key || !process.env.SUPABASE_URL) return; // best-effort only try { const admin = createClient(process.env.SUPABASE_URL, key); for (const u of users) { await admin.auth.admin.deleteUser(u.uid); } } catch (err) { console.warn("Cleanup skipped:", err.message); } } test("no cross-user session leaks (end-to-end, real local Supabase)", async () => { const jarA = makeJar(); const jarB = makeJar(); const userA = await signup(jarA, "a"); const userB = await signup(jarB, "b"); try { // A creates a session (no AI call involved). const createRes = await api("/api/sessions", { method: "POST", jar: jarA, body: { grade: "Grade 10", subject: "math", chapter: "Chapter 1: Rational Numbers" }, }); assert.equal(createRes.status, 201, `A session create, got ${createRes.status}`); const session = await createRes.json(); const sessionId = session.id; assert.ok(sessionId, "session id present"); // B must NOT read A's session. const readRes = await api(`/api/chat/${sessionId}`, { jar: jarB }); assert.equal(readRes.status, 404, "B GET /api/chat/:id (A's) must be 404"); // B must NOT see A's session in either listing. for (const path of ["/api/chat/sessions", "/api/sessions"]) { const listRes = await api(path, { jar: jarB }); assert.equal(listRes.status, 200); const list = await listRes.json(); assert.ok(Array.isArray(list), `${path} returns an array`); assert.ok( !list.some((s) => s.id === sessionId), `B ${path} must not contain A's session` ); } // B must NOT delete or clear A's session. const delRes = await api(`/api/sessions/${sessionId}`, { method: "DELETE", jar: jarB }); assert.equal(delRes.status, 404, "B DELETE A's session must be 404"); const clearRes = await api(`/api/sessions/${sessionId}/clear`, { method: "PATCH", jar: jarB }); assert.equal(clearRes.status, 404, "B clear A's session must be 404"); // B must NOT be able to continue A's conversation (checked before any AI call). const chatRes = await api("/api/chat", { method: "POST", jar: jarB, body: { text: "hello", chatId: sessionId }, }); assert.equal(chatRes.status, 404, "B POST /api/chat with A's chatId must be 404"); // A can still read their own session (sanity: ownership didn't break). const ownRes = await api(`/api/chat/${sessionId}`, { jar: jarA }); assert.equal(ownRes.status, 200, "A GET own session must be 200"); const own = await ownRes.json(); assert.equal(own.session.id, sessionId); // /api/auth/me returns identity from the VERIFIED JWT, never the cookie. const meRes = await api("/api/auth/me", { jar: jarA }); assert.equal(meRes.status, 200); const me = await meRes.json(); assert.equal(me.user.uid, userA.uid, "/me uid must come from the JWT"); } finally { await cleanupUsers([userA, userB]); } }); test("forged padhle.user cookie is rejected by /api/auth/me", async () => { // No padhle.token at all → 401. const noToken = await api("/api/auth/me", { rawCookie: `padhle.user=${encodeURIComponent(JSON.stringify({ uid: "attacker", email: "a@b.c" }))}`, }); assert.equal(noToken.status, 401, "no token → 401"); // Forged user cookie + garbage token → 401 (token must verify). const badToken = await api("/api/auth/me", { rawCookie: `padhle.user=${encodeURIComponent(JSON.stringify({ uid: "attacker", email: "a@b.c" }))}; ` + `padhle.token=garbage.token.value`, }); assert.equal(badToken.status, 401, "forged token → 401"); }); test("unauthenticated requests to protected routes are rejected", async () => { for (const path of ["/api/sessions", "/api/chat/sessions"]) { const res = await api(path); assert.equal(res.status, 401, `${path} without cookie → 401`); } }); test("chapter metadata cannot inject markup or control characters", async () => { const jar = makeJar(); const user = await signup(jar, "inj"); try { const res = await api("/api/chat", { method: "POST", jar, body: { text: "hello", chapter: "" }, }); assert.equal(res.status, 400, "chapter with < > must be rejected"); const body = await res.json(); assert.ok(body.error, "error message present"); } finally { await cleanupUsers([user]); } }); test("security headers are set on every response", async () => { const res = await api("/health"); assert.equal(res.headers.get("x-content-type-options"), "nosniff"); assert.equal(res.headers.get("x-frame-options"), "DENY"); assert.equal(res.headers.get("referrer-policy"), "no-referrer"); assert.equal(res.headers.get("cross-origin-opener-policy"), "same-origin"); assert.equal(res.headers.get("x-powered-by"), null, "X-Powered-By removed"); });