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:
2026-09-08 00:46:57 +03:00
parent 0828003141
commit 5750a3106f
15 changed files with 525 additions and 45 deletions

View 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
View 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;
}