fix(auth): harden async login boundaries
This commit is contained in:
@@ -1,60 +1,17 @@
|
||||
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";
|
||||
import { createLocalAuthFixture, localPublicUrl } from "./auth-test-fixtures.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<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const cleanup of cleanups.splice(0).reverse()) await cleanup();
|
||||
});
|
||||
|
||||
function sessionCookie(response: { headers: Record<string, string | string[] | undefined> }): 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 };
|
||||
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 () => {
|
||||
@@ -68,10 +25,10 @@ test("derived CSRF tokens are deterministic per opaque cookie and are never the
|
||||
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: 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: publicUrl, "sec-fetch-site": "cross-site", "x-thothii-csrf": csrfToken } },
|
||||
{ headers: { cookie, origin: publicUrl, "x-thothii-csrf": csrfToken.slice(0, -1) } },
|
||||
{ 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) {
|
||||
@@ -85,26 +42,36 @@ 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}` },
|
||||
headers: { cookie, origin: localPublicUrl, "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 },
|
||||
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: publicUrl, "x-thothii-csrf": csrfToken },
|
||||
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: publicUrl, "x-thothii-csrf": "x".repeat(43) },
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user