diff --git a/backend/src/auth/local-registry.ts b/backend/src/auth/local-registry.ts index 575889d8..af157c8d 100644 --- a/backend/src/auth/local-registry.ts +++ b/backend/src/auth/local-registry.ts @@ -246,10 +246,10 @@ export function createLocalUserRegistry(usersPath: string): LocalUserRegistry { }, async verify(user: LocalUserRecord | undefined, password: string): Promise { if (!user || !user.enabled) { - verifyWithDummy(password); + await verifyWithDummy(password); return false; } - return verifyPassword(password, user.passwordHash); + return await verifyPassword(password, user.passwordHash); }, }; } diff --git a/backend/src/auth/password.ts b/backend/src/auth/password.ts index d46fb050..ac283991 100644 --- a/backend/src/auth/password.ts +++ b/backend/src/auth/password.ts @@ -1,4 +1,4 @@ -import { argon2Sync, randomBytes, timingSafeEqual } from "node:crypto"; +import { argon2, timingSafeEqual } from "node:crypto"; const MAXIMUM_PHC_BYTES = 256; const MAXIMUM_MEMORY_KIB = 256 * 1024; @@ -28,8 +28,14 @@ function parseDecimal(value: string, maximum: number): number | undefined { function decodeRawBase64(value: string, minimum: number, maximum: number): Buffer | undefined { if (!/^[A-Za-z0-9+/]+$/.test(value)) return undefined; const decoded = Buffer.from(value, "base64"); - if (decoded.length < minimum || decoded.length > maximum) return undefined; - if (decoded.toString("base64").replace(/=+$/, "") !== value) return undefined; + if (decoded.length < minimum || decoded.length > maximum) { + decoded.fill(0); + return undefined; + } + if (decoded.toString("base64").replace(/=+$/, "") !== value) { + decoded.fill(0); + return undefined; + } return decoded; } @@ -47,58 +53,99 @@ function parsePHC(encoded: string): Argon2Parameters | undefined { const salt = decodeRawBase64(parts[4], MINIMUM_SALT_BYTES, MAXIMUM_SALT_BYTES); const digest = decodeRawBase64(parts[5], MINIMUM_KEY_BYTES, MAXIMUM_KEY_BYTES); - if (!salt || !digest) return undefined; + if (!salt || !digest) { + salt?.fill(0); + digest?.fill(0); + return undefined; + } return { memory, passes, parallelism, salt, digest }; } -function passwordBytes(password: string): Buffer | undefined { - if (typeof password !== "string") return undefined; +function hasValidPasswordBytes(password: string): boolean { + if (typeof password !== "string") return false; const typedPassword = password as string & { isWellFormed?: () => boolean }; if (typeof typedPassword.isWellFormed === "function") { - if (!typedPassword.isWellFormed()) return undefined; + if (!typedPassword.isWellFormed()) return false; } else if (/[\uD800-\uDFFF]/.test(password)) { - return undefined; + return false; } const byteLength = Buffer.byteLength(password, "utf8"); - if (byteLength < MINIMUM_PASSWORD_BYTES || byteLength > MAXIMUM_PASSWORD_BYTES) return undefined; - return Buffer.from(password, "utf8"); + return byteLength >= MINIMUM_PASSWORD_BYTES && byteLength <= MAXIMUM_PASSWORD_BYTES; +} + +function passwordBytes(password: string): Buffer | undefined { + return hasValidPasswordBytes(password) ? Buffer.from(password, "utf8") : undefined; +} + +function clearParameters(parameters: Argon2Parameters): void { + parameters.salt.fill(0); + parameters.digest.fill(0); +} + +function deriveArgon2(message: Buffer, parameters: Argon2Parameters): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const complete = (error: Error | null, derived?: Buffer): void => { + if (settled) { + derived?.fill(0); + return; + } + settled = true; + if (error || !derived) { + reject(error ?? new Error("argon2_failed")); + return; + } + resolve(derived); + }; + try { + argon2("argon2id", { + message, + nonce: parameters.salt, + memory: parameters.memory, + passes: parameters.passes, + parallelism: parameters.parallelism, + tagLength: parameters.digest.length, + }, complete); + } catch (error) { + if (!settled) { + settled = true; + reject(error); + } + } + }); } export function isValidPasswordHash(encoded: string): boolean { - return parsePHC(encoded) !== undefined; + const parameters = parsePHC(encoded); + if (!parameters) return false; + clearParameters(parameters); + return true; } -export function verifyPassword(password: string, encoded: string): boolean { +export async function verifyPassword(password: string, encoded: string): Promise { const parameters = parsePHC(encoded); const message = passwordBytes(password); - if (!message || !parameters) return false; + if (!message || !parameters) { + message?.fill(0); + if (parameters) clearParameters(parameters); + return false; + } + let derived: Buffer | undefined; try { - const derived = argon2Sync("argon2id", { - message, - nonce: parameters.salt, - memory: parameters.memory, - passes: parameters.passes, - parallelism: parameters.parallelism, - tagLength: parameters.digest.length, - }); + derived = await deriveArgon2(message, parameters); return derived.length === parameters.digest.length && timingSafeEqual(derived, parameters.digest); } catch { return false; + } finally { + message.fill(0); + derived?.fill(0); + clearParameters(parameters); } } const DUMMY_PASSWORD = "thothii-process-local-dummy-password"; -const DUMMY_SALT = randomBytes(MINIMUM_SALT_BYTES); -const DUMMY_DIGEST = argon2Sync("argon2id", { - message: Buffer.from(DUMMY_PASSWORD, "utf8"), - nonce: DUMMY_SALT, - memory: 65536, - passes: 3, - parallelism: 1, - tagLength: 32, -}); -const DUMMY_HASH = `$argon2id$v=19$m=65536,t=3,p=1$${DUMMY_SALT.toString("base64").replace(/=+$/, "")}$${DUMMY_DIGEST.toString("base64").replace(/=+$/, "")}`; +const DUMMY_HASH = "$argon2id$v=19$m=65536,t=3,p=1$ABEiM0RVZneImaq7zN3u/w$/YuK14HV5biXcVIYqaJs07meu0nRgo+d62KnM02MSoU"; -export function verifyWithDummy(password: string): void { - verifyPassword(passwordBytes(password) ? password : DUMMY_PASSWORD, DUMMY_HASH); +export async function verifyWithDummy(password: string): Promise { + await verifyPassword(hasValidPasswordBytes(password) ? password : DUMMY_PASSWORD, DUMMY_HASH); } diff --git a/backend/src/auth/routes.ts b/backend/src/auth/routes.ts index 46feb36e..7a4ea6c6 100644 --- a/backend/src/auth/routes.ts +++ b/backend/src/auth/routes.ts @@ -27,30 +27,52 @@ interface LoginPayload { remember: boolean; } -class LoginFailureLimiter { +export class LoginFailureLimiter { private readonly usernames = new Map(); private readonly addresses = new Map(); + private readonly maximumEntries: number; + + constructor(options: { maximumEntries?: number } = {}) { + this.maximumEntries = options.maximumEntries ?? MAX_LIMIT_ENTRIES; + } isLimited(username: string, address: string, now = Date.now()): boolean { - return this.active(this.usernames, username, now).length >= 10 - || this.active(this.addresses, address, now).length >= 20; + return this.countActive(this.usernames, username, now) >= 10 + || this.countActive(this.addresses, address, now) >= 20; } - recordFailure(username: string, address: string, now = Date.now()): void { - this.active(this.usernames, username, now).push(now); - this.active(this.addresses, address, now).push(now); + recordFailure(username: string, address: string, now = Date.now()): boolean { + this.pruneExpired(this.usernames, now); + this.pruneExpired(this.addresses, now); + const usernameAttempts = this.usernames.get(username) ?? []; + const addressAttempts = this.addresses.get(address) ?? []; + if (usernameAttempts.length >= 10 || addressAttempts.length >= 20) return false; + if ((!this.usernames.has(username) && this.usernames.size >= this.maximumEntries) + || (!this.addresses.has(address) && this.addresses.size >= this.maximumEntries)) return false; + + this.usernames.set(username, [...usernameAttempts, now]); + this.addresses.set(address, [...addressAttempts, now]); + return true; } - private active(bucket: Map, key: string, now: number): number[] { - const prior = bucket.get(key) ?? []; - const current = prior.filter((timestamp) => timestamp > now - TEN_MINUTES_MS); - if (current.length === 0) bucket.delete(key); else bucket.set(key, current); - if (!bucket.has(key) && bucket.size >= MAX_LIMIT_ENTRIES) { - const oldest = bucket.keys().next().value; - if (typeof oldest === "string") bucket.delete(oldest); + private countActive(bucket: ReadonlyMap, key: string, now: number): number { + const attempts = bucket.get(key); + if (!attempts) return 0; + const earliest = now - TEN_MINUTES_MS; + let count = 0; + for (const timestamp of attempts) { + if (timestamp > earliest) count += 1; + } + return count; + } + + private pruneExpired(bucket: Map, now: number): void { + const earliest = now - TEN_MINUTES_MS; + for (const [key, attempts] of bucket) { + const active = attempts.filter((timestamp) => timestamp > earliest); + if (active.length === 0) bucket.delete(key); + else if (active.length !== attempts.length) bucket.set(key, active); } - if (!bucket.has(key)) bucket.set(key, current); - return current; } } @@ -107,7 +129,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen } if (verified === undefined) return loginLimited(reply); if (!verified || !user || !user.enabled) { - limiter.recordFailure(normalizedUsername, sourceAddress); + if (!limiter.recordFailure(normalizedUsername, sourceAddress)) return loginLimited(reply); return invalidCredentials(reply); } diff --git a/backend/test/auth-csrf.test.ts b/backend/test/auth-csrf.test.ts index 0a5a047b..e926616e 100644 --- a/backend/test/auth-csrf.test.ts +++ b/backend/test/auth-csrf.test.ts @@ -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> = []; 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 }; + 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); diff --git a/backend/test/auth-password.test.ts b/backend/test/auth-password.test.ts index ab6e27e1..9307fa66 100644 --- a/backend/test/auth-password.test.ts +++ b/backend/test/auth-password.test.ts @@ -2,11 +2,11 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { describe, expect, test, vi } from "vitest"; -const { argon2SyncSpy } = vi.hoisted(() => ({ argon2SyncSpy: vi.fn() })); +const { argon2Spy } = vi.hoisted(() => ({ argon2Spy: vi.fn() })); vi.mock("node:crypto", async (importOriginal) => { const actual = await importOriginal(); - argon2SyncSpy.mockImplementation(actual.argon2Sync); - return { ...actual, argon2Sync: argon2SyncSpy }; + argon2Spy.mockImplementation(actual.argon2); + return { ...actual, argon2: argon2Spy }; }); import { verifyPassword } from "../src/auth/password.js"; @@ -22,51 +22,51 @@ const vectors = JSON.parse(readFileSync( )) as Argon2Vector[]; describe("local Argon2id password verification", () => { - test("accepts every committed Go-generated vector", () => { + test("accepts every committed Go-generated vector", async () => { expect(vectors.length).toBeGreaterThan(0); for (const vector of vectors) { - expect(verifyPassword(vector.password, vector.phc)).toBe(true); + await expect(verifyPassword(vector.password, vector.phc)).resolves.toBe(true); } }); - test("rejects a one-byte password change", () => { + test("rejects a one-byte password change", async () => { for (const vector of vectors) { - expect(verifyPassword(`${vector.password}!`, vector.phc)).toBe(false); + await expect(verifyPassword(`${vector.password}!`, vector.phc)).resolves.toBe(false); } }); - test("rejects ill-formed Unicode instead of authenticating as U+FFFD", () => { + test("rejects ill-formed Unicode instead of authenticating as U+FFFD", async () => { const replacementPassword = "correct horse battery stap\uFFFD"; const loneSurrogatePassword = "correct horse battery stap\uD800"; const replacementPasswordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$+tAXzgaQVnNaonNvgevyG6UKlaKcwyRMi1mESNk0BvQ"; - expect(verifyPassword(replacementPassword, replacementPasswordHash)).toBe(true); - expect(verifyPassword(loneSurrogatePassword, replacementPasswordHash)).toBe(false); + await expect(verifyPassword(replacementPassword, replacementPasswordHash)).resolves.toBe(true); + await expect(verifyPassword(loneSurrogatePassword, replacementPasswordHash)).resolves.toBe(false); }); - test("accepts a multibyte password at exactly the 1024-byte boundary", () => { + test("accepts a multibyte password at exactly the 1024-byte boundary", async () => { const password = "é".repeat(512); const passwordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$LfO3M3JBKeXf1knenCjQ6m9z4B7nedb0As2uc522I1I"; expect(Buffer.byteLength(password, "utf8")).toBe(1024); - expect(verifyPassword(password, passwordHash)).toBe(true); - expect(verifyPassword(`${password}é`, passwordHash)).toBe(false); + await expect(verifyPassword(password, passwordHash)).resolves.toBe(true); + await expect(verifyPassword(`${password}é`, passwordHash)).resolves.toBe(false); }); - test("rejects an over-limit password before converting it with Buffer.from", () => { + test("rejects an over-limit password before converting it with Buffer.from", async () => { const password = "é".repeat(513); const fromSpy = vi.spyOn(Buffer, "from"); try { expect(Buffer.byteLength(password, "utf8")).toBe(1026); - expect(verifyPassword(password, vectors[0].phc)).toBe(false); + await expect(verifyPassword(password, vectors[0].phc)).resolves.toBe(false); expect(fromSpy.mock.calls.some(([value, encoding]) => value === password && encoding === "utf8")).toBe(false); } finally { fromSpy.mockRestore(); } }); - test("rejects malformed and oversized PHC parameters before Argon2 allocation", () => { + test("rejects malformed and oversized PHC parameters before Argon2 allocation", async () => { const password = vectors[0].password; const digest = vectors[0].phc.split("$")[5]; const salt = vectors[0].phc.split("$")[4]; @@ -79,9 +79,9 @@ describe("local Argon2id password verification", () => { ]; for (const phc of cases) { - argon2SyncSpy.mockClear(); - expect(verifyPassword(password, phc)).toBe(false); - expect(argon2SyncSpy).not.toHaveBeenCalled(); + argon2Spy.mockClear(); + await expect(verifyPassword(password, phc)).resolves.toBe(false); + expect(argon2Spy).not.toHaveBeenCalled(); } }); }); diff --git a/backend/test/auth-production-csrf.test.ts b/backend/test/auth-production-csrf.test.ts new file mode 100644 index 00000000..fe8ac193 --- /dev/null +++ b/backend/test/auth-production-csrf.test.ts @@ -0,0 +1,80 @@ +import { afterAll, beforeAll, expect, test } from "vitest"; +import { createLocalAuthFixture, type LocalAuthFixture, localPublicUrl } from "./auth-test-fixtures.js"; + +interface StateChangingRoute { + family: string; + method: "POST" | "PUT" | "DELETE"; + url: string; +} + +const stateChangingRoutes: readonly StateChangingRoute[] = [ + { family: "auth logout", method: "POST", url: "/auth/logout" }, + { family: "runtime prewarm", method: "POST", url: "/runtime/prewarm" }, + { family: "session create", method: "POST", url: "/sessions" }, + { family: "session mutation", method: "POST", url: "/sessions/session-1/response" }, + { family: "session mutation", method: "POST", url: "/sessions/session-1/steer" }, + { family: "session mutation", method: "POST", url: "/sessions/session-1/resume" }, + { family: "session mutation", method: "POST", url: "/sessions/session-1/close" }, + { family: "session mutation", method: "POST", url: "/sessions/session-1/rename" }, + { family: "session mutation", method: "POST", url: "/sessions/session-1/group" }, + { family: "session archive", method: "POST", url: "/sessions/session-1/archive" }, + { family: "session archive", method: "POST", url: "/sessions/session-1/unarchive" }, + { family: "session delete", method: "DELETE", url: "/sessions/session-1" }, + { family: "SQL preview", method: "POST", url: "/sessions/session-1/sql/preview" }, + { family: "SQL export", method: "POST", url: "/sessions/session-1/sql/export" }, + { family: "workspace registry", method: "POST", url: "/workspace-registry/pull" }, + { family: "workspace validation", method: "POST", url: "/workspaces/validate" }, + { family: "workspace secrets", method: "PUT", url: "/workspaces/workspace-1/secrets" }, + { family: "workspace secrets", method: "DELETE", url: "/workspaces/workspace-1/secrets/secret-1" }, + { family: "workspace diagnostics", method: "POST", url: "/workspaces/workspace-1/test" }, + { family: "settings", method: "PUT", url: "/settings" }, + { family: "Pi management", method: "PUT", url: "/pi-management/config" }, + { family: "Pi management", method: "POST", url: "/pi-management/test" }, + { family: "maintenance", method: "POST", url: "/internal/maintenance/activate" }, + { family: "maintenance", method: "POST", url: "/internal/maintenance/deactivate" }, +]; + +let fixture: LocalAuthFixture; + +beforeAll(async () => { + fixture = await createLocalAuthFixture(); +}); + +afterAll(async () => { + await fixture.close(); +}); + +test.each(stateChangingRoutes)( + "$family $method $url rejects every production CSRF/session-boundary failure before downstream code", + async ({ method, url }) => { + const failures = [ + fixture.sessionHeaders({ "x-thothii-csrf": undefined }), + fixture.sessionHeaders({ "x-thothii-csrf": "malformed" }), + fixture.sessionHeaders({ origin: undefined }), + fixture.sessionHeaders({ origin: "http://wrong.example.test" }), + fixture.sessionHeaders({ "sec-fetch-site": "cross-site" }), + ]; + + for (const headers of failures) { + fixture.resetDownstreamHits(); + const response = await fixture.app.inject({ method, url, headers, payload: {} }); + expect(response.statusCode).toBe(403); + expect(response.json()).toEqual({ code: "csrf_failed", error: "Request origin validation failed" }); + expect(fixture.downstreamHits()).toBe(0); + } + }, +); + +test("a valid real local session cookie and derived CSRF token cross the same production boundary", async () => { + fixture.resetDownstreamHits(); + const response = await fixture.app.inject({ + method: "PUT", + url: "/settings", + headers: fixture.sessionHeaders(), + payload: {}, + }); + + expect(response.statusCode).toBe(200); + expect(fixture.downstreamHits()).toBe(1); + expect(localPublicUrl).toBe("http://127.0.0.1:8787"); +}); diff --git a/backend/test/auth-routes-local.test.ts b/backend/test/auth-routes-local.test.ts index 0c6ff8ee..bd8205ba 100644 --- a/backend/test/auth-routes-local.test.ts +++ b/backend/test/auth-routes-local.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { stringify } from "yaml"; import { buildApp } from "../src/app.js"; import { loadConfig } from "../src/config.js"; +import { LoginFailureLimiter } from "../src/auth/routes.js"; const password = "correct horse battery staple"; const passwordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4"; @@ -270,6 +271,66 @@ test("failed logins are limited by normalized username and source address", asyn expect((await login(addressLimited.app, { username: "A-new-username" })).statusCode).toBe(429); }); +test("failed-attempt limiter keeps exact bounded windows without check-time mutation or active-key eviction", () => { + const now = 1_000_000; + const limiter = new LoginFailureLimiter({ maximumEntries: 2 }); + + expect(limiter.isLimited("checked-only", "127.0.0.1", now)).toBe(false); + expect(limiter.recordFailure("victim", "127.0.0.1", now)).toBe(true); + expect(limiter.recordFailure("attacker", "127.0.0.1", now)).toBe(true); + expect(limiter.recordFailure("flood", "127.0.0.1", now)).toBe(false); + + for (let attempt = 1; attempt < 10; attempt += 1) { + expect(limiter.recordFailure("victim", "127.0.0.1", now)).toBe(true); + } + expect(limiter.isLimited("victim", "127.0.0.1", now)).toBe(true); + expect(limiter.recordFailure("victim", "127.0.0.1", now)).toBe(false); + expect(limiter.isLimited("victim", "127.0.0.1", now + 10 * 60 * 1000 - 1)).toBe(true); + expect(limiter.isLimited("victim", "127.0.0.1", now + 10 * 60 * 1000)).toBe(false); + expect(limiter.recordFailure("victim", "127.0.0.1", now + 10 * 60 * 1000)).toBe(true); +}); + +test("failed-attempt limiter allows twenty source-address failures, then rejects the twenty-first", () => { + const limiter = new LoginFailureLimiter(); + const now = 1_000_000; + + for (let attempt = 0; attempt < 20; attempt += 1) { + expect(limiter.recordFailure(`user-${attempt}`, "127.0.0.1", now)).toBe(true); + } + expect(limiter.isLimited("new-user", "127.0.0.1", now)).toBe(true); + expect(limiter.recordFailure("new-user", "127.0.0.1", now)).toBe(false); +}); + +test("successful and pre-authentication failures never reset or consume failed-login counters", async () => { + let calls = 0; + const user = { + id: adminId, username: "Admin", normalizedUsername: "admin", displayName: "Local administrator", + passwordHash, roles: ["admin"], enabled: true, authRevision: 1, + }; + const { app } = await createLocalApp({ + registry: { + findByUsername: async () => user, + findBySubject: async () => user, + verify: async () => { + calls += 1; + return calls === 10; + }, + }, + }); + + const originRejected = await app.inject({ + method: "POST", url: "/auth/local/login", headers: { origin: "http://wrong.example.test" }, + payload: { username: "Admin", password }, + }); + expect(originRejected.statusCode).toBe(403); + expect(calls).toBe(0); + for (let attempt = 0; attempt < 9; attempt += 1) expect((await login(app)).statusCode).toBe(401); + expect((await login(app)).statusCode).toBe(200); + expect((await login(app)).statusCode).toBe(401); + expect((await login(app)).statusCode).toBe(429); + expect((await login(app)).statusCode).toBe(429); +}); + test("only two Argon2 verifications run concurrently and excess login attempts fail immediately", async () => { let calls = 0; let release!: () => void; @@ -302,6 +363,19 @@ test("only two Argon2 verifications run concurrently and excess login attempts f expect((await second).statusCode).toBe(401); }); +test("the real native asynchronous Argon2 verifier holds two permits and releases them after completion", async () => { + const { app } = await createLocalApp(); + const first = login(app, { password: `${password}!` }); + const second = login(app, { password: `${password}!` }); + await new Promise((resolve) => setImmediate(resolve)); + + const excess = await login(app, { password: `${password}!` }); + expect(excess.statusCode).toBe(429); + await expect(first).resolves.toMatchObject({ statusCode: 401 }); + await expect(second).resolves.toMatchObject({ statusCode: 401 }); + await expect(login(app, { password: `${password}!` })).resolves.toMatchObject({ statusCode: 401 }); +}); + test("a verifier failure is sanitized and releases its concurrency permit", async () => { let attempts = 0; const user = { diff --git a/backend/test/auth-test-fixtures.ts b/backend/test/auth-test-fixtures.ts new file mode 100644 index 00000000..39506c97 --- /dev/null +++ b/backend/test/auth-test-fixtures.ts @@ -0,0 +1,112 @@ +import { chmodSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { FastifyInstance } from "fastify"; +import { stringify } from "yaml"; +import { buildApp, type BuildAppDeps } from "../src/app.js"; +import { loadConfig } from "../src/config.js"; + +export const localPassword = "correct horse battery staple"; +export const localPasswordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4"; +export const localAdminId = "6ba7b810-9dad-4ed1-80b4-00c04fd430c8"; +export const localPublicUrl = "http://127.0.0.1:8787"; + +export interface LocalAuthFixture { + app: FastifyInstance; + cookie: string; + csrfToken: string; + downstreamHits(): number; + resetDownstreamHits(): void; + sessionHeaders(overrides?: Record): Record; + close(): Promise; +} + +function firstSetCookie(response: { headers: Record }): string { + const header = response.headers["set-cookie"]; + return Array.isArray(header) ? header[0] ?? "" : header ?? ""; +} + +function cookiePair(setCookie: string): string { + return setCookie.split(";", 1)[0] ?? ""; +} + +/** Creates a production-local app and authenticates through the real login/session boundary. */ +export async function createLocalAuthFixture(deps?: BuildAppDeps): Promise { + const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-fixture-")); + chmodSync(directory, 0o700); + const authConfigFile = join(directory, "auth.yaml"); + const usersFile = join(directory, "users.yaml"); + writeFileSync(authConfigFile, stringify({ + version: 1, + mode: "local", + publicUrl: localPublicUrl, + local: { usersFile: "users.yaml" }, + }), { encoding: "utf8", mode: 0o600 }); + writeFileSync(usersFile, [ + "version: 1", + "users:", + ` - id: ${localAdminId}`, + " username: Admin", + " displayName: Local administrator", + ` passwordHash: ${localPasswordHash}`, + " 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", + }), deps); + let downstream = 0; + app.addHook("preHandler", async () => { downstream += 1; }); + + try { + const signedIn = await app.inject({ + method: "POST", + url: "/auth/local/login", + headers: { origin: localPublicUrl, "sec-fetch-site": "same-origin" }, + payload: { username: "Admin", password: localPassword }, + }); + if (signedIn.statusCode !== 200) throw new Error("local_auth_fixture_login_failed"); + const cookie = cookiePair(firstSetCookie(signedIn)); + const me = await app.inject({ method: "GET", url: "/me", headers: { cookie } }); + const csrfToken = (me.json() as { csrfToken?: unknown }).csrfToken; + if (me.statusCode !== 200 || typeof csrfToken !== "string") throw new Error("local_auth_fixture_session_failed"); + downstream = 0; + + return { + app, + cookie, + csrfToken, + downstreamHits: () => downstream, + resetDownstreamHits: () => { downstream = 0; }, + sessionHeaders(overrides = {}) { + const headers: Record = { + cookie, + origin: localPublicUrl, + "sec-fetch-site": "same-origin", + "x-thothii-csrf": csrfToken, + }; + for (const [key, value] of Object.entries(overrides)) { + if (value === undefined) delete headers[key]; + else headers[key] = value; + } + return headers; + }, + async close() { + await app.close(); + rmSync(directory, { recursive: true, force: true }); + }, + }; + } catch (error) { + await app.close(); + rmSync(directory, { recursive: true, force: true }); + throw error; + } +}