import { afterEach, expect, test } from "vitest"; import { chmodSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { stringify } from "yaml"; import { buildApp } from "../src/app.js"; import { loadConfig } from "../src/config.js"; import { deriveCsrfToken } from "../src/auth/csrf.js"; const password = "correct horse battery staple"; const passwordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4"; const adminId = "6ba7b810-9dad-4ed1-80b4-00c04fd430c8"; const publicUrl = "http://127.0.0.1:8787"; const cleanups: Array<() => Promise> = []; afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); }); function sessionCookie(response: { headers: Record }): string { const setCookie = response.headers["set-cookie"]; const first = Array.isArray(setCookie) ? setCookie[0] : setCookie; return first?.split(";", 1)[0] ?? ""; } async function createApp() { const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-csrf-")); chmodSync(directory, 0o700); const authConfigFile = join(directory, "auth.yaml"); const usersFile = join(directory, "users.yaml"); writeFileSync(authConfigFile, stringify({ version: 1, mode: "local", publicUrl, local: { usersFile: "users.yaml" }, }), { encoding: "utf8", mode: 0o600 }); writeFileSync(usersFile, [ "version: 1", "users:", ` - id: ${adminId}`, " username: Admin", " displayName: Local administrator", ` passwordHash: ${passwordHash}`, " roles:", " - admin", " enabled: true", " authRevision: 1", "", ].join("\n"), { encoding: "utf8", mode: 0o600 }); chmodSync(authConfigFile, 0o600); chmodSync(usersFile, 0o600); const app = buildApp(loadConfig({ THT_AUTH_CONFIG_FILE: authConfigFile, THT_AUTH_STATE_ROOT: join(directory, "auth-state"), THT_HARNESS_DIR: "/tmp/h", })); cleanups.push(async () => { await app.close(); rmSync(directory, { recursive: true, force: true }); }); const signedIn = await app.inject({ method: "POST", url: "/auth/local/login", headers: { origin: publicUrl, "sec-fetch-site": "same-origin" }, payload: { username: "Admin", password }, }); const cookie = sessionCookie(signedIn); const me = await app.inject({ method: "GET", url: "/me", headers: { cookie } }); return { app, cookie, csrfToken: me.json().csrfToken as string }; } test("derived CSRF tokens are deterministic per opaque cookie and are never the cookie token", async () => { const { cookie, csrfToken } = await createApp(); const token = cookie.split("=", 2)[1] ?? ""; expect(deriveCsrfToken(token)).toBe(csrfToken); expect(csrfToken).toMatch(/^[A-Za-z0-9_-]{43}$/); expect(csrfToken).not.toBe(token); }); test("cookie-authenticated state changes require an exact Origin, Fetch Metadata when present, and CSRF token", async () => { const { app, cookie, csrfToken } = await createApp(); const cases = [ { headers: { cookie, origin: publicUrl, "sec-fetch-site": "same-origin" } }, { headers: { cookie, origin: "http://127.0.0.1:8788", "sec-fetch-site": "same-origin", "x-thothii-csrf": csrfToken } }, { headers: { cookie, origin: publicUrl, "sec-fetch-site": "cross-site", "x-thothii-csrf": csrfToken } }, { headers: { cookie, origin: publicUrl, "x-thothii-csrf": csrfToken.slice(0, -1) } }, ]; for (const request of cases) { const response = await app.inject({ method: "POST", url: "/auth/logout", ...request }); expect(response.statusCode).toBe(403); expect(response.json()).toEqual({ code: "csrf_failed", error: "Request origin validation failed" }); } }); test("duplicate or malformed CSRF and session-cookie headers fail closed", async () => { const { app, cookie, csrfToken } = await createApp(); const duplicateCsrf = await app.inject({ method: "POST", url: "/auth/logout", headers: { cookie, origin: publicUrl, "x-thothii-csrf": `${csrfToken}, ${csrfToken}` }, }); const duplicateCookie = await app.inject({ method: "POST", url: "/auth/logout", headers: { cookie: `${cookie}; ${cookie}`, origin: publicUrl, "x-thothii-csrf": csrfToken }, }); const malformedCookie = await app.inject({ method: "POST", url: "/auth/logout", headers: { cookie: "thothii_session=not-a-token", origin: publicUrl, "x-thothii-csrf": csrfToken }, }); expect(duplicateCsrf.statusCode).toBe(403); expect(duplicateCookie.statusCode).toBe(401); expect(malformedCookie.statusCode).toBe(401); }); test("an unauthenticated state-changing application route cannot bypass the central boundary", async () => { const { app } = await createApp(); const response = await app.inject({ method: "POST", url: "/sessions", headers: { origin: publicUrl, "x-thothii-csrf": "x".repeat(43) }, payload: { question: "must not reach a session handler" }, }); expect(response.statusCode).toBe(401); expect(response.json()).toEqual({ code: "authentication_required", error: "Authentication is required" }); });