rateLimit.ts: 5 запросов / 10 минут на IP в памяти, фиксированное окно, чистка устаревших записей по ходу запросов, 429 с Retry-After. IP из X-Real-IP только от loopback-сокета (плюс TRUSTED_PROXIES для шлюза docker), иначе адрес сокета; в базу IP не пишем. Honeypot → 201 без записи и уведомления. isPaused() → 503 на время восстановления базы. Тесты на каждый код ответа из таблицы плана.
53 lines
2.1 KiB
TypeScript
53 lines
2.1 KiB
TypeScript
// Лимит запросов с IP в памяти процесса. Фиксированное окно: проще скользящего,
|
|
// а для защиты формы от перебора разницы нет. После рестарта счётчики обнуляются — это нормально.
|
|
|
|
export type RateDecision = { ok: true } | { ok: false; retryAfterSec: number };
|
|
|
|
export interface RateLimiter {
|
|
hit(key: string, now: Date): RateDecision;
|
|
}
|
|
|
|
export function createRateLimiter({ limit = 5, windowMs = 10 * 60 * 1000 } = {}) {
|
|
const buckets = new Map<string, { start: number; count: number }>();
|
|
let lastSweep = 0;
|
|
|
|
return {
|
|
hit(key: string, now: Date): RateDecision {
|
|
const t = now.getTime();
|
|
// Чистим не таймером, а по ходу запросов: без трафика нечему и расти.
|
|
if (t - lastSweep >= windowMs) {
|
|
for (const [k, b] of buckets) if (t - b.start >= windowMs) buckets.delete(k);
|
|
lastSweep = t;
|
|
}
|
|
|
|
const bucket = buckets.get(key);
|
|
if (!bucket || t - bucket.start >= windowMs) {
|
|
buckets.set(key, { start: t, count: 1 });
|
|
return { ok: true };
|
|
}
|
|
if (bucket.count < limit) {
|
|
bucket.count++;
|
|
return { ok: true };
|
|
}
|
|
return { ok: false, retryAfterSec: Math.max(1, Math.ceil((bucket.start + windowMs - t) / 1000)) };
|
|
},
|
|
size: () => buckets.size,
|
|
};
|
|
}
|
|
|
|
export const LOOPBACK = ['127.0.0.1', '::1', '::ffff:127.0.0.1'] as const;
|
|
|
|
/**
|
|
* IP посетителя. X-Real-IP ставит nginx на той же машине; верим заголовку только от доверенного
|
|
* адреса (loopback и явно перечисленные), иначе любой подставит чужой адрес и обойдёт лимит.
|
|
*/
|
|
export function clientIp(
|
|
socketAddress: string | undefined,
|
|
realIpHeader: string | undefined,
|
|
trustedProxies: ReadonlySet<string>,
|
|
): string {
|
|
const realIp = realIpHeader?.trim();
|
|
if (socketAddress && trustedProxies.has(socketAddress) && realIp) return realIp;
|
|
return socketAddress ?? 'unknown';
|
|
}
|