This commit is contained in:
Debian
2026-03-30 14:32:24 +00:00
parent 87198824b6
commit 59310b0b9b
6 changed files with 161 additions and 21 deletions
+20 -8
View File
@@ -3,7 +3,9 @@ import { loginAdmin, logoutAdmin, requireAdminSession, getSessionFromRequest, se
import { cleanupDatabase } from "./lib/db";
import { errorResponse, jsonResponse, emptyResponse, optionsResponse, parseJson, assertAllowedOrigin } from "./lib/http";
import { config } from "./lib/config";
import { HttpError } from "./lib/errors";
import { serveFrontendBuild } from "./lib/frontend";
import { clearAccessKeyLoginRateLimit, enforceAggressiveLoginRateLimit, recordFailedAccessKeyLogin } from "./lib/security";
import {
createUserSchema,
loginSchema,
@@ -65,15 +67,25 @@ app.get(
app.post(
"/api/auth/login",
route(async ({ request }) => {
const body = await parseJson(request, loginSchema);
const result = await loginAdmin(body.accessKey);
enforceAggressiveLoginRateLimit(request);
return jsonResponse(
request,
200,
{ session: result.session },
{ "set-cookie": result.cookie }
);
try {
const body = await parseJson(request, loginSchema);
const result = await loginAdmin(body.accessKey);
clearAccessKeyLoginRateLimit(request);
return jsonResponse(
request,
200,
{ session: result.session },
{ "set-cookie": result.cookie }
);
} catch (error) {
if (error instanceof HttpError && error.status === 401) {
recordFailedAccessKeyLogin(request);
}
throw error;
}
})
);
+3 -1
View File
@@ -1,8 +1,10 @@
export class HttpError extends Error {
readonly status: number;
readonly headers?: HeadersInit;
constructor(status: number, message: string) {
constructor(status: number, message: string, headers?: HeadersInit) {
super(message);
this.status = status;
this.headers = headers;
}
}
+1 -2
View File
@@ -96,7 +96,7 @@ export const errorResponse = (request: Request, error: unknown): Response => {
error: {
message: error.message
}
});
}, error.headers);
}
if (error instanceof ZodError) {
@@ -115,4 +115,3 @@ export const errorResponse = (request: Request, error: unknown): Response => {
}
});
};
+126
View File
@@ -1,4 +1,102 @@
import { createHash, randomBytes } from "node:crypto";
import { HttpError } from "./errors";
type LoginRateLimitState = {
blockedUntil: number;
lastSeenAt: number;
recentAttempts: number[];
failedAttempts: number[];
};
const loginRateLimitStates = new Map<string, LoginRateLimitState>();
const LOGIN_BURST_WINDOW_MS = 15 * 60_000;
const LOGIN_BURST_LIMIT = 1;
const LOGIN_FAILURE_TRACK_MS = 365 * 24 * 60 * 60_000;
const LOGIN_STATE_TTL_MS = 400 * 24 * 60 * 60_000;
const getRetryAfterSeconds = (blockedUntil: number): number =>
Math.max(1, Math.ceil((blockedUntil - Date.now()) / 1000));
const getClientIp = (request: Request): string => {
const forwardedFor = request.headers.get("x-forwarded-for");
if (forwardedFor) {
const first = forwardedFor.split(",")[0]?.trim();
if (first) {
return first;
}
}
const realIp = request.headers.get("x-real-ip")?.trim();
if (realIp) {
return realIp;
}
return "unknown";
};
const getLoginRateLimitKey = (request: Request): string =>
getClientIp(request);
const pruneLoginRateLimitState = (state: LoginRateLimitState, now: number): void => {
state.recentAttempts = state.recentAttempts.filter((timestamp) => now - timestamp <= LOGIN_BURST_WINDOW_MS);
state.failedAttempts = state.failedAttempts.filter((timestamp) => now - timestamp <= LOGIN_FAILURE_TRACK_MS);
};
const getLoginRateLimitState = (key: string, now: number): LoginRateLimitState => {
for (const [stateKey, state] of loginRateLimitStates.entries()) {
if (now - state.lastSeenAt > LOGIN_STATE_TTL_MS && now >= state.blockedUntil) {
loginRateLimitStates.delete(stateKey);
}
}
const existing = loginRateLimitStates.get(key);
if (existing) {
existing.lastSeenAt = now;
pruneLoginRateLimitState(existing, now);
return existing;
}
const nextState: LoginRateLimitState = {
blockedUntil: 0,
lastSeenAt: now,
recentAttempts: [],
failedAttempts: []
};
loginRateLimitStates.set(key, nextState);
return nextState;
};
const createRateLimitError = (blockedUntil: number): HttpError =>
new HttpError(
429,
"Too many access key login attempts. Login is locked for this client.",
{
"Retry-After": String(getRetryAfterSeconds(blockedUntil))
}
);
const applyLoginFailurePenalty = (state: LoginRateLimitState, now: number): void => {
const failureCount = state.failedAttempts.length;
let blockMs = 0;
if (failureCount >= 5) {
blockMs = 365 * 24 * 60 * 60_000;
} else if (failureCount >= 4) {
blockMs = 180 * 24 * 60 * 60_000;
} else if (failureCount >= 3) {
blockMs = 30 * 24 * 60 * 60_000;
} else if (failureCount >= 2) {
blockMs = 7 * 24 * 60 * 60_000;
} else if (failureCount >= 1) {
blockMs = 24 * 60 * 60_000;
}
if (blockMs > 0) {
state.blockedUntil = Math.max(state.blockedUntil, now + blockMs);
}
};
export const hashToken = (value: string): string =>
createHash("sha256").update(value).digest("hex");
@@ -54,3 +152,31 @@ export const readCookie = (request: Request, name: string): string | null => {
export const sanitizeHeaderValue = (value: string): string =>
value.replace(/[\r\n]+/g, " ").trim();
export const enforceAggressiveLoginRateLimit = (request: Request): void => {
const now = Date.now();
const key = getLoginRateLimitKey(request);
const state = getLoginRateLimitState(key, now);
if (now < state.blockedUntil) {
throw createRateLimitError(state.blockedUntil);
}
state.recentAttempts.push(now);
if (state.recentAttempts.length > LOGIN_BURST_LIMIT) {
state.blockedUntil = now + 24 * 60 * 60_000;
throw createRateLimitError(state.blockedUntil);
}
};
export const recordFailedAccessKeyLogin = (request: Request): void => {
const now = Date.now();
const key = getLoginRateLimitKey(request);
const state = getLoginRateLimitState(key, now);
state.failedAttempts.push(now);
applyLoginFailurePenalty(state, now);
};
export const clearAccessKeyLoginRateLimit = (request: Request): void => {
loginRateLimitStates.delete(getLoginRateLimitKey(request));
};