security(auth): anti-enumeration, returnTo-валидация, лимит попыток логина
Шаг 2.1 плана production-готовности: - единый 401 при любом отказе логина + constant-time bcrypt - forgot-password: идентичный ответ независимо от существования email - sanitizeReturnTo (open redirect fix в /api/documents/generate-link) - 10 неудачных попыток → блок 15 мин (in-memory, аудит) - DATABASE_SSL_REJECT_UNAUTHORIZED env (дефолт true) - доделка 0.7: статичные тексты в llm-providers/rag/finance-di2 - 8 новых тестов (104/104)
This commit is contained in:
92
server/utils/login-attempts.ts
Normal file
92
server/utils/login-attempts.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
// In-memory счётчик неудачных попыток входа по email (anti brute-force
|
||||
// per-account). После MAX_FAILED_ATTEMPTS подряд — временная блокировка
|
||||
// попыток на LOCK_DURATION_MS. При успешном входе счётчик сбрасывается.
|
||||
// Блокировка по email (а не IP), чтобы коллеги за одним офисным NAT
|
||||
// не блокировали друг друга; перечисление аккаунтов не раскрывается,
|
||||
// т.к. лимит применяется к любому email независимо от его существования.
|
||||
// ОГОВОРКА: при multi-instance деплое счётчики не общие между процессами
|
||||
// (как и остальные in-memory кэши) — для общего лимита нужен Redis/БД.
|
||||
|
||||
export const MAX_FAILED_ATTEMPTS = 10;
|
||||
export const LOCK_DURATION_MS = 15 * 60 * 1000;
|
||||
|
||||
const MAX_ENTRIES = 10_000;
|
||||
|
||||
interface AttemptEntry {
|
||||
count: number;
|
||||
lockedUntil: number;
|
||||
/** Момент, после которого запись можно удалить (для очистки Map). */
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const attempts = new Map<string, AttemptEntry>();
|
||||
|
||||
function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function getFreshEntry(email: string): AttemptEntry | undefined {
|
||||
const entry = attempts.get(email);
|
||||
if (!entry) return undefined;
|
||||
const now = Date.now();
|
||||
if (now > entry.expiresAt) {
|
||||
attempts.delete(email);
|
||||
return undefined;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** Оставшееся время блокировки в мс (0 — не заблокирован). */
|
||||
export function getLoginLockRemainingMs(email: string): number {
|
||||
const entry = getFreshEntry(normalizeEmail(email));
|
||||
if (!entry) return 0;
|
||||
return Math.max(0, entry.lockedUntil - Date.now());
|
||||
}
|
||||
|
||||
export interface FailedAttemptResult {
|
||||
/** Аккаунт заблокирован (либо уже был, либо блокировка сработала на этой попытке). */
|
||||
locked: boolean;
|
||||
/** Блокировка сработала именно на этой попытке — повод записать аудит. */
|
||||
justLocked: boolean;
|
||||
remainingMs: number;
|
||||
}
|
||||
|
||||
export function recordFailedLoginAttempt(email: string): FailedAttemptResult {
|
||||
const key = normalizeEmail(email);
|
||||
const now = Date.now();
|
||||
const existing = getFreshEntry(key);
|
||||
|
||||
if (existing && existing.lockedUntil > now) {
|
||||
// Продлеваем жизнь записи, чтобы блокировка не «протухла» раньше времени.
|
||||
existing.expiresAt = existing.lockedUntil;
|
||||
return { locked: true, justLocked: false, remainingMs: existing.lockedUntil - now };
|
||||
}
|
||||
|
||||
const count = (existing?.count ?? 0) + 1;
|
||||
const locked = count >= MAX_FAILED_ATTEMPTS;
|
||||
const entry: AttemptEntry = {
|
||||
count: locked ? 0 : count,
|
||||
lockedUntil: locked ? now + LOCK_DURATION_MS : 0,
|
||||
expiresAt: now + LOCK_DURATION_MS,
|
||||
};
|
||||
|
||||
if (!attempts.has(key) && attempts.size >= MAX_ENTRIES) {
|
||||
// Вытесняем самую старую запись (Map хранит порядок вставки).
|
||||
const oldestKey = attempts.keys().next().value;
|
||||
if (oldestKey !== undefined) attempts.delete(oldestKey);
|
||||
}
|
||||
attempts.delete(key);
|
||||
attempts.set(key, entry);
|
||||
|
||||
return { locked, justLocked: locked, remainingMs: locked ? LOCK_DURATION_MS : 0 };
|
||||
}
|
||||
|
||||
/** Сброс счётчика при успешном входе. */
|
||||
export function resetLoginAttempts(email: string): void {
|
||||
attempts.delete(normalizeEmail(email));
|
||||
}
|
||||
|
||||
/** Полная очистка (для тестов). */
|
||||
export function clearLoginAttempts(): void {
|
||||
attempts.clear();
|
||||
}
|
||||
11
server/utils/return-to.ts
Normal file
11
server/utils/return-to.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// Валидация returnTo/redirect-параметров против open redirect:
|
||||
// принимаем только относительные пути, начинающиеся с одиночного '/'.
|
||||
// '//evil.com' (protocol-relative), '/\evil.com' (backslash-trick,
|
||||
// браузеры трактуют '\' как '/') и управляющие символы отклоняются.
|
||||
export function sanitizeReturnTo(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string' || value.length === 0) return undefined;
|
||||
if (!value.startsWith('/')) return undefined;
|
||||
if (value.length > 1 && (value[1] === '/' || value[1] === '\\')) return undefined;
|
||||
if (/[\r\n\0]/.test(value)) return undefined;
|
||||
return value;
|
||||
}
|
||||
Reference in New Issue
Block a user