padhle - post migration

This commit is contained in:
2026-09-15 04:08:55 -04:00
parent 534b51b41b
commit b2d8109019
509 changed files with 147378 additions and 303 deletions
+220
View File
@@ -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");
});