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