diff --git a/src/services/api/handlers.ts b/src/services/api/handlers.ts index 36ba050..6cd25e9 100644 --- a/src/services/api/handlers.ts +++ b/src/services/api/handlers.ts @@ -17,6 +17,7 @@ import { createAdminSession, revokeAdminSession, getAdminSession, + DUMMY_PASSWORD_HASH, } from "@/services/auth/index.js"; import type { Preferences } from "@/contracts/index.js"; import { @@ -1853,13 +1854,9 @@ code{background:#f4f4f5;padding:.1rem .35rem;border-radius:4px} const { identifier, password } = parsed.data; const user = await deps.adminUserRepo.findByEmailOrUsername(identifier); - if (!user) { - sendJson(res, 401, { error: "unauthorized", message: "Invalid credentials" }); - return; - } - - const isValid = await verifyPassword(password, user.passwordHash); - if (!isValid) { + const targetHash = user ? user.passwordHash : DUMMY_PASSWORD_HASH; + const isValid = await verifyPassword(password, targetHash); + if (!user || !isValid) { sendJson(res, 401, { error: "unauthorized", message: "Invalid credentials" }); return; } diff --git a/src/services/api/main.ts b/src/services/api/main.ts index 009a41b..9838862 100644 --- a/src/services/api/main.ts +++ b/src/services/api/main.ts @@ -499,6 +499,30 @@ export async function startApiServer() { } } + // The login route is unauthenticated and CPU-intensive due to password hashing (scrypt). + // Throttle login attempts per IP to prevent credential brute-forcing and CPU exhaustion. + if (isPublicAuth) { + const clientIp = + (req.headers["x-forwarded-for"] as string | undefined)?.split(",")[0]?.trim() || + req.socket.remoteAddress || + "unknown"; + try { + const key = `rate-limit:api:auth:${clientIp}`; + const count = await redis.native.incr(key); + if (count === 1) await redis.native.expire(key, 60); + if (count > 10) { + res.setHeader("Retry-After", "60"); + sendJson(res, 429, { + error: "too_many_requests", + message: "Too many login attempts. Please try again later.", + }); + return; + } + } catch (err) { + logger.warn({ err }, "auth rate limit unavailable — allowing request"); + } + } + const route = router.match(req.method ?? "GET", url.pathname); if (!route) { diff --git a/src/services/auth/index.ts b/src/services/auth/index.ts index 339b372..4a3b6f3 100644 --- a/src/services/auth/index.ts +++ b/src/services/auth/index.ts @@ -8,6 +8,14 @@ const scryptAsync = promisify(scrypt); const DEFAULT_SESSION_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days const SESSION_PREFIX = "notif:session:"; +/** + * Pre-computed dummy hash conforming to salt(16 bytes hex):hash(64 bytes hex). + * Used during authentication failure to equalize execution time and prevent + * user enumeration via scrypt timing side-channels. + */ +export const DUMMY_PASSWORD_HASH = + "00000000000000000000000000000000:00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; + export interface AdminSession { token: string; adminId: string; diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 5697046..b177f09 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -5,6 +5,7 @@ import { createAdminSession, getAdminSession, revokeAdminSession, + DUMMY_PASSWORD_HASH, } from "@/services/auth/index.js"; import { createHandlers } from "@/services/api/handlers.js"; @@ -55,6 +56,10 @@ describe("Password Hashing & Verification", () => { it("fails verification on malformed hash", async () => { expect(await verifyPassword("password", "invalid_hash_string")).toBe(false); }); + + it("fails verification on dummy hash without throwing", async () => { + expect(await verifyPassword("password", DUMMY_PASSWORD_HASH)).toBe(false); + }); }); describe("Admin Session Management", () => {