mirror of
https://github.com/devkitshq/notifkit.git
synced 2026-10-19 18:24:52 +02:00
fix(auth): add login rate limiting and eliminate timing side-channel
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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,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", () => {
|
||||
|
||||
Reference in New Issue
Block a user