fix(auth): harden async login boundaries
This commit is contained in:
@@ -246,10 +246,10 @@ export function createLocalUserRegistry(usersPath: string): LocalUserRegistry {
|
||||
},
|
||||
async verify(user: LocalUserRecord | undefined, password: string): Promise<boolean> {
|
||||
if (!user || !user.enabled) {
|
||||
verifyWithDummy(password);
|
||||
await verifyWithDummy(password);
|
||||
return false;
|
||||
}
|
||||
return verifyPassword(password, user.passwordHash);
|
||||
return await verifyPassword(password, user.passwordHash);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<Buffer> {
|
||||
return new Promise<Buffer>((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<boolean> {
|
||||
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<void> {
|
||||
await verifyPassword(hasValidPasswordBytes(password) ? password : DUMMY_PASSWORD, DUMMY_HASH);
|
||||
}
|
||||
|
||||
+38
-16
@@ -27,30 +27,52 @@ interface LoginPayload {
|
||||
remember: boolean;
|
||||
}
|
||||
|
||||
class LoginFailureLimiter {
|
||||
export class LoginFailureLimiter {
|
||||
private readonly usernames = new Map<string, number[]>();
|
||||
private readonly addresses = new Map<string, number[]>();
|
||||
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<string, number[]>, 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<string, readonly number[]>, 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<string, number[]>, 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<typeof import("node:crypto")>();
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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<void>((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 = {
|
||||
|
||||
@@ -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<string, string | undefined>): Record<string, string>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
function firstSetCookie(response: { headers: Record<string, string | string[] | undefined> }): 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<LocalAuthFixture> {
|
||||
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<string, string> = {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user