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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user