// Ограничение перебора пароля на /login. Во всех трёх панелях этот код был // скопирован дословно (те же 5 попыток за 15 минут), но в одной из них — // с багом: IP брался как req.socket.remoteAddress без разбора X-Forwarded-For. // За обратным прокси это адрес самого прокси, все клиенты схлопываются в один // ключ, и пять неудачных попыток кем угодно блокируют вход всем. // // Счётчик — Map в памяти: он не переживает рестарт и не общий для нескольких // инстансов за балансировщиком. Для защиты от перебора этого достаточно // (атакующему всё равно приходится укладываться в лимит на каждом инстансе), // но знать об этом надо — распределённый лимит требует общего хранилища. import type { NextFunction, Request, Response } from 'express'; export interface RateLimitOptions { /** Сколько неудачных попыток разрешено в окне. По умолчанию 5. */ limit?: number; /** Длина окна в миллисекундах. По умолчанию 15 минут. */ windowMs?: number; /** * Доверять ли заголовку X-Forwarded-For. По умолчанию — только в проде: * в деве перед сервером прокси нет, и подставить чужой IP заголовком мог * бы кто угодно, кто достучался напрямую. */ trustProxy?: boolean; /** Текст ответа. Получает количество секунд до сброса. */ message?: (retryAfter: number) => string; } interface RateEntry { count: number; resetAt: number } type IpSource = Pick & { socket: { remoteAddress?: string } }; export function clientIp(req: IpSource, trustProxy: boolean): string { if (trustProxy) { const forwarded = req.headers['x-forwarded-for']; const first = Array.isArray(forwarded) ? forwarded[0] : forwarded; if (first) return first.split(',')[0].trim(); } return req.socket.remoteAddress ?? 'unknown'; } export interface LoginRateLimit { /** Мидлвар на маршрут логина. */ middleware: (req: Request, res: Response, next: NextFunction) => void; /** * Снять счётчик после успешного входа — иначе три опечатки подряд перед * удачным логином всё ещё считались бы попытками перебора. */ clear: (req: IpSource) => void; /** IP запроса по тем же правилам, что использует лимитер. */ ipOf: (req: IpSource) => string; } export function createLoginRateLimit(options: RateLimitOptions = {}): LoginRateLimit { const limit = options.limit ?? 5; const windowMs = options.windowMs ?? 15 * 60 * 1000; const trustProxy = options.trustProxy ?? process.env.NODE_ENV === 'production'; const message = options.message ?? ((retryAfter: number) => `Слишком много попыток. Повтори через ${retryAfter} сек.`); const attempts = new Map(); const ipOf = (req: IpSource) => clientIp(req, trustProxy); return { ipOf, clear: (req) => { attempts.delete(ipOf(req)); }, middleware(req, res, next) { const ip = ipOf(req); const now = Date.now(); const entry = attempts.get(ip); if (!entry || entry.resetAt < now) { attempts.set(ip, { count: 1, resetAt: now + windowMs }); } else if (entry.count >= limit) { const retryAfter = Math.ceil((entry.resetAt - now) / 1000); res.setHeader('Retry-After', String(retryAfter)); res.status(429).json({ error: message(retryAfter) }); return; } else { entry.count++; } next(); }, }; }