Files
maenekoandClaude Opus 5 18bd05bcbb ma7engine: ядро, шаблон панели, скаффолдер и доктор
Первый коммит существующего состояния. Ядро кладётся под git ради того,
чтобы половины разъезжались в продуктовые репозитории через git subtree,
а не копированием: subtree делает настоящий merge, хранит историю и умеет
отдавать правки обратно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
2026-08-26 20:39:37 +03:00

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();
},
};
}