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,10 @@
// Валидация returnTo против open redirect: принимаем только относительные
// пути с одиночным '/' в начале. '//host' (protocol-relative) и '/\host'
// (браузеры трактуют '\' как '/') отклоняются. Невалидное значение → ''.
export function sanitizeReturnTo(value: string | null | undefined): string {
if (!value) return '';
if (!value.startsWith('/')) return '';
if (value.length > 1 && (value[1] === '/' || value[1] === '\\')) return '';
if (value.includes('://')) return '';
return value;
}

View File

@@ -3,11 +3,12 @@ import { useLocation } from 'wouter';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { CheckSquare, ArrowLeft, CheckCircle, Loader2 } from 'lucide-react';
import { sanitizeReturnTo } from '@/lib/return-to';
function getReturnTo(): string {
const params = new URLSearchParams(window.location.search);
const returnTo = params.get('returnTo');
if (returnTo && returnTo.startsWith('/') && !returnTo.startsWith('/login')) {
const returnTo = sanitizeReturnTo(params.get('returnTo'));
if (returnTo && !returnTo.startsWith('/login')) {
return returnTo;
}
return '';

View File

@@ -17,19 +17,14 @@ import {
FormMessage,
} from '@/components/ui/form';
import { CheckSquare, Eye, EyeOff, CloudOff } from 'lucide-react';
import { sanitizeReturnTo } from '@/lib/return-to';
function getReturnTo(): string {
const params = new URLSearchParams(window.location.search);
const returnTo = params.get('returnTo');
// Разрешаем только относительные пути внутри приложения.
// Блокируем open redirect: //evil.com, /login, javascript:, https:// и т.п.
if (
returnTo &&
returnTo.startsWith('/') &&
!returnTo.startsWith('//') &&
!returnTo.includes('://') &&
!returnTo.startsWith('/login')
) {
// Блокируем open redirect: //evil.com, /\evil.com, /login, javascript:, https:// и т.п.
const returnTo = sanitizeReturnTo(params.get('returnTo'));
if (returnTo && !returnTo.startsWith('/login')) {
return returnTo;
}
return '/home';

View File

@@ -3,13 +3,13 @@ import { useLocation } from 'wouter';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { CheckSquare, Eye, EyeOff, Loader2, ArrowLeft, CheckCircle } from 'lucide-react';
import { sanitizeReturnTo } from '@/lib/return-to';
function getParams(): { token: string; returnTo: string } {
const params = new URLSearchParams(window.location.search);
const token = params.get('token') || '';
const returnTo = params.get('returnTo') || '';
const safeReturnTo =
returnTo && returnTo.startsWith('/') && !returnTo.startsWith('/login') ? returnTo : '';
const returnTo = sanitizeReturnTo(params.get('returnTo'));
const safeReturnTo = returnTo && !returnTo.startsWith('/login') ? returnTo : '';
return { token, returnTo: safeReturnTo };
}