ma7engine: ядро, шаблон панели, скаффолдер и доктор
Первый коммит существующего состояния. Ядро кладётся под git ради того, чтобы половины разъезжались в продуктовые репозитории через git subtree, а не копированием: subtree делает настоящий merge, хранит историю и умеет отдавать правки обратно. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
// Ограничение перебора пароля на /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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user