feat(api): rate-limit, honeypot, коды ошибок

rateLimit.ts: 5 запросов / 10 минут на IP в памяти, фиксированное окно,
чистка устаревших записей по ходу запросов, 429 с Retry-After. IP из
X-Real-IP только от loopback-сокета (плюс TRUSTED_PROXIES для шлюза
docker), иначе адрес сокета; в базу IP не пишем. Honeypot → 201 без
записи и уведомления. isPaused() → 503 на время восстановления базы.
Тесты на каждый код ответа из таблицы плана.
This commit is contained in:
2026-10-17 15:41:04 +03:00
parent c20e40c333
commit ea0535001c
6 changed files with 203 additions and 9 deletions
+52
View File
@@ -0,0 +1,52 @@
// Лимит запросов с 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';
}