80 lines
3.5 KiB
TypeScript
80 lines
3.5 KiB
TypeScript
import { afterEach, expect, test } from "vitest";
|
|
import { deriveCsrfToken } from "../src/auth/csrf.js";
|
|
import { createLocalAuthFixture, localPublicUrl } from "./auth-test-fixtures.js";
|
|
|
|
const cleanups: Array<() => Promise<void>> = [];
|
|
|
|
afterEach(async () => {
|
|
for (const cleanup of cleanups.splice(0).reverse()) await cleanup();
|
|
});
|
|
|
|
async function createApp() {
|
|
const fixture = await createLocalAuthFixture();
|
|
cleanups.push(() => fixture.close());
|
|
return fixture;
|
|
}
|
|
|
|
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: localPublicUrl, "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: localPublicUrl, "sec-fetch-site": "cross-site", "x-thothii-csrf": csrfToken } },
|
|
{ headers: { cookie, origin: localPublicUrl, "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: localPublicUrl, "x-thothii-csrf": `${csrfToken}, ${csrfToken}` },
|
|
});
|
|
const duplicateCookie = await app.inject({
|
|
method: "POST", url: "/auth/logout",
|
|
headers: { cookie: `${cookie}; ${cookie}`, origin: localPublicUrl, "x-thothii-csrf": csrfToken },
|
|
});
|
|
const malformedCookie = await app.inject({
|
|
method: "POST", url: "/auth/logout",
|
|
headers: { cookie: "thothii_session=not-a-token", origin: localPublicUrl, "x-thothii-csrf": csrfToken },
|
|
});
|
|
const oversizedCsrf = await app.inject({
|
|
method: "POST", url: "/auth/logout",
|
|
headers: { cookie, origin: localPublicUrl, "x-thothii-csrf": "x".repeat(4097) },
|
|
});
|
|
const oversizedCookie = await app.inject({
|
|
method: "POST", url: "/auth/logout",
|
|
headers: { cookie: `${cookie}; padding=${"x".repeat(4097)}`, origin: localPublicUrl, "x-thothii-csrf": csrfToken },
|
|
});
|
|
|
|
expect(duplicateCsrf.statusCode).toBe(403);
|
|
expect(duplicateCookie.statusCode).toBe(401);
|
|
expect(malformedCookie.statusCode).toBe(401);
|
|
expect(oversizedCsrf.statusCode).toBe(403);
|
|
expect(oversizedCookie.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: localPublicUrl, "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" });
|
|
});
|