fix(auth): add login rate limiting and eliminate timing side-channel

This commit is contained in:
coo1estguy
2026-09-19 14:18:35 +05:30
parent 6f64922151
commit 151db527e1
4 changed files with 41 additions and 7 deletions
+4 -7
View File
@@ -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}</style>
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;
}
+24
View File
@@ -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) {
+8
View File
@@ -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;
+5
View File
@@ -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", () => {