Первый коммит существующего состояния. Ядро кладётся под git ради того, чтобы половины разъезжались в продуктовые репозитории через git subtree, а не копированием: subtree делает настоящий merge, хранит историю и умеет отдавать правки обратно. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
84 lines
4.4 KiB
TypeScript
84 lines
4.4 KiB
TypeScript
// Ограничение перебора пароля на /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<Request, 'headers'> & { 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<string, RateEntry>();
|
|
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();
|
|
},
|
|
};
|
|
}
|