🔒 Implement comprehensive security and DDoS protection
### Security Features: - **Rate Limiting**: Redis-based IP tracking with configurable limits - General: 100 requests/minute (5min block) - Upload: 10 requests/5min (10min block) - Auth: 20 requests/15min (30min block) - **Request Validation**: Path length, header count, suspicious patterns - **Attack Detection**: Admin paths, script injections, bot patterns - **Enhanced JWT**: Format validation, length checks, character filtering - **IP Tracking**: X-Forwarded-For and X-Real-IP support ### Security Headers: - X-Content-Type-Options: nosniff - X-Frame-Options: DENY - X-XSS-Protection: 1; mode=block - Content-Security-Policy with strict rules - Strict-Transport-Security with includeSubDomains ### CORS Hardening: - Limited to specific domains: discours.io, new.discours.io - Restricted methods: GET, POST, OPTIONS only - Essential headers only ### Infrastructure: - Security middleware for all requests - Local cache + Redis for performance - Comprehensive logging and monitoring - Progressive blocking for repeat offenders ### Documentation: - Complete security guide (docs/security.md) - Configuration examples - Incident response procedures - Monitoring recommendations Version bump to 0.6.0 for major security enhancement.
This commit is contained in:
32
CHANGELOG.md
32
CHANGELOG.md
@@ -1,10 +1,40 @@
|
|||||||
|
## [0.6.0] - 2025-09-02
|
||||||
|
|
||||||
|
### 🔒 Безопасность и защита от DDoS
|
||||||
|
- **СОЗДАН**: Модуль `security.rs` с комплексной системой защиты
|
||||||
|
- **ДОБАВЛЕНО**: Rate limiting по IP с конфигурируемыми лимитами
|
||||||
|
- Общие запросы: 100/мин (блокировка 5 мин)
|
||||||
|
- Загрузка файлов: 10/5мин (блокировка 10 мин)
|
||||||
|
- Аутентификация: 20/15мин (блокировка 30 мин)
|
||||||
|
- **ДОБАВЛЕНО**: Redis-based хранение счетчиков с локальным кэшем
|
||||||
|
- **ДОБАВЛЕНО**: Детекция подозрительных паттернов (admin paths, script injections)
|
||||||
|
- **ДОБАВЛЕНО**: Валидация запросов (размер, заголовки, путь)
|
||||||
|
- **ДОБАВЛЕНО**: Строгие заголовки безопасности (CSP, HSTS, XSS Protection)
|
||||||
|
- **ОГРАНИЧЕНО**: CORS до конкретных доменов
|
||||||
|
- **УЛУЧШЕНО**: Валидация JWT токенов (формат, длина, символы)
|
||||||
|
- **ДОБАВЛЕНО**: IP tracking с поддержкой X-Forwarded-For
|
||||||
|
|
||||||
|
### 🧹 DRY Refactoring
|
||||||
|
- **СОЗДАН**: Общий модуль `handlers/common.rs` для устранения дублирования
|
||||||
|
- **ИЗВЛЕЧЕНО**: Общая логика валидации токенов в `extract_and_validate_token()`
|
||||||
|
- **ИЗВЛЕЧЕНО**: Общие HTTP response helpers (`create_cached_response`, `create_error_response`)
|
||||||
|
- **ИЗВЛЕЧЕНО**: Общая логика кэширования ETag в `check_etag_cache()`
|
||||||
|
- **УПРОЩЕНО**: Все handlers теперь используют общие утилиты
|
||||||
|
- **УДАЛЕНО**: Дублирующиеся функции и константы
|
||||||
|
|
||||||
|
### 📦 Изменения квот
|
||||||
|
- **УВЕЛИЧЕНО**: Лимит квоты пользователя с 5 ГБ до 12 ГБ
|
||||||
|
|
||||||
|
### 📚 Документация
|
||||||
|
- **ДОБАВЛЕНО**: Подробная документация по безопасности (`docs/security.md`)
|
||||||
|
- **ОПИСАНО**: Конфигурация защиты, мониторинг, реагирование на инциденты
|
||||||
|
|
||||||
## [0.5.3] - 2025-09-02
|
## [0.5.3] - 2025-09-02
|
||||||
|
|
||||||
### 🔄 Архитектурные изменения
|
### 🔄 Архитектурные изменения
|
||||||
- **УПРОЩЕНО**: Убран сложный роутинг Actix-web в пользу универсального обработчика
|
- **УПРОЩЕНО**: Убран сложный роутинг Actix-web в пользу универсального обработчика
|
||||||
- **ДОБАВЛЕНО**: Прямое определение HTTP методов (GET/POST) в единой точке
|
- **ДОБАВЛЕНО**: Прямое определение HTTP методов (GET/POST) в единой точке
|
||||||
- **УБРАНО**: HTTP API для управления квотами (quota endpoints)
|
- **УБРАНО**: HTTP API для управления квотами (quota endpoints)
|
||||||
- **СОХРАНЕНО**: ACME challenge поддержка для SSL сертификатов
|
|
||||||
|
|
||||||
### 📋 API Структура
|
### 📋 API Структура
|
||||||
- `GET /` - авторизованная информация о персональном хранилище
|
- `GET /` - авторизованная информация о персональном хранилище
|
||||||
|
|||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -2644,7 +2644,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quoter"
|
name = "quoter"
|
||||||
version = "0.5.3"
|
version = "0.5.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"actix",
|
"actix",
|
||||||
"actix-cors",
|
"actix-cors",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "quoter"
|
name = "quoter"
|
||||||
version = "0.5.3"
|
version = "0.6.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
176
docs/security.md
Normal file
176
docs/security.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
# 🔒 Безопасность и защита от DDoS
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
Система quoter включает многоуровневую защиту от различных типов атак, включая DDoS, брутфорс и эксплуатацию уязвимостей.
|
||||||
|
|
||||||
|
## 🛡️ Уровни защиты
|
||||||
|
|
||||||
|
### 1. Сетевой уровень (HTTP Server)
|
||||||
|
|
||||||
|
#### Ограничения размера запросов
|
||||||
|
- **Максимальный размер payload**: 500 МБ
|
||||||
|
- **Максимальный размер JSON**: 1 МБ
|
||||||
|
- **Таймаут соединения**: настраивается через Actix-web
|
||||||
|
|
||||||
|
#### Заголовки безопасности
|
||||||
|
```http
|
||||||
|
X-Content-Type-Options: nosniff
|
||||||
|
X-Frame-Options: DENY
|
||||||
|
X-XSS-Protection: 1; mode=block
|
||||||
|
Referrer-Policy: strict-origin-when-cross-origin
|
||||||
|
Content-Security-Policy: default-src 'self'; img-src 'self' data: https:; object-src 'none';
|
||||||
|
Strict-Transport-Security: max-age=31536000; includeSubDomains
|
||||||
|
```
|
||||||
|
|
||||||
|
#### CORS Policy
|
||||||
|
- **Разрешенные домены**: `discours.io`, `new.discours.io`, `localhost:3000`
|
||||||
|
- **Разрешенные методы**: GET, POST, OPTIONS
|
||||||
|
- **Ограниченные заголовки**: Content-Type, Authorization, If-None-Match, Cache-Control
|
||||||
|
|
||||||
|
### 2. Rate Limiting (Лимиты запросов)
|
||||||
|
|
||||||
|
#### Конфигурация по умолчанию
|
||||||
|
|
||||||
|
| Тип endpoint | Макс. запросов | Окно времени | Блокировка |
|
||||||
|
|--------------|----------------|--------------|------------|
|
||||||
|
| Общие запросы | 100 | 60 сек | 5 мин |
|
||||||
|
| Загрузка файлов | 10 | 300 сек | 10 мин |
|
||||||
|
| Аутентификация | 20 | 900 сек | 30 мин |
|
||||||
|
|
||||||
|
#### Механизм работы
|
||||||
|
1. **IP-based tracking**: Отслеживание по IP (учитывает X-Forwarded-For, X-Real-IP)
|
||||||
|
2. **Redis storage**: Хранение счетчиков в Redis с TTL
|
||||||
|
3. **Local cache**: Быстрый локальный кэш для частых проверок
|
||||||
|
4. **Progressive blocking**: Увеличение времени блокировки при повторных нарушениях
|
||||||
|
|
||||||
|
### 3. Валидация запросов
|
||||||
|
|
||||||
|
#### Проверки безопасности
|
||||||
|
- **Длина пути**: максимум 1000 символов
|
||||||
|
- **Количество заголовков**: максимум 50
|
||||||
|
- **Длина значений заголовков**: максимум 8192 символа
|
||||||
|
- **Подозрительные символы**: фильтрация `..`, `\0`, `\r`, `\n`
|
||||||
|
|
||||||
|
#### Детекция атак
|
||||||
|
```rust
|
||||||
|
// Подозрительные паттерны
|
||||||
|
let suspicious_patterns = [
|
||||||
|
"/admin", "/wp-admin", "/phpmyadmin", "/.env", "/config",
|
||||||
|
"/.git", "/backup", "/db", "/sql",
|
||||||
|
"script>", "<iframe", "javascript:", "data:",
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Аутентификация и авторизация
|
||||||
|
|
||||||
|
#### JWT Token Validation
|
||||||
|
- **Формат**: 3 части разделенные точками
|
||||||
|
- **Символы**: только alphanumeric, `.`, `-`, `_`
|
||||||
|
- **Длина**: 100-2048 символов
|
||||||
|
- **Валидация**: проверка подписи и времени жизни
|
||||||
|
|
||||||
|
#### Защита от брутфорса
|
||||||
|
- Задержки при неудачных попытках аутентификации
|
||||||
|
- Временная блокировка IP после множественных неудач
|
||||||
|
- Логирование подозрительной активности
|
||||||
|
|
||||||
|
## 🚀 Производительность
|
||||||
|
|
||||||
|
### Оптимизации
|
||||||
|
- **Локальный кэш**: быстрые проверки без обращения к Redis
|
||||||
|
- **Асинхронная обработка**: неблокирующие операции
|
||||||
|
- **ETag caching**: сокращение нагрузки на статические файлы
|
||||||
|
- **HTTP/2 support**: через reverse proxy
|
||||||
|
|
||||||
|
### Мониторинг
|
||||||
|
- Детальное логирование атак и блокировок
|
||||||
|
- Метрики производительности Redis
|
||||||
|
- Статистика rate limiting по IP и endpoint
|
||||||
|
|
||||||
|
## 🔧 Конфигурация
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
```bash
|
||||||
|
# Redis (для rate limiting)
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
|
# Security настройки
|
||||||
|
MAX_PAYLOAD_SIZE=524288000 # 500MB
|
||||||
|
MAX_PATH_LENGTH=1000
|
||||||
|
MAX_HEADERS_COUNT=50
|
||||||
|
```
|
||||||
|
|
||||||
|
### Настройка Rate Limits
|
||||||
|
```rust
|
||||||
|
let security_config = SecurityConfig {
|
||||||
|
general_rate_limit: RateLimitConfig {
|
||||||
|
max_requests: 100,
|
||||||
|
window_seconds: 60,
|
||||||
|
block_duration_seconds: 300,
|
||||||
|
},
|
||||||
|
upload_rate_limit: RateLimitConfig {
|
||||||
|
max_requests: 5, // Более строгие лимиты
|
||||||
|
window_seconds: 300,
|
||||||
|
block_duration_seconds: 1800,
|
||||||
|
},
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 Обнаружение угроз
|
||||||
|
|
||||||
|
### Автоматическая блокировка
|
||||||
|
- **Bot detection**: по User-Agent заголовкам
|
||||||
|
- **Массовые запросы**: временная блокировка агрессивных IP
|
||||||
|
- **Подозрительные пути**: немедленная блокировка известных attack vectors
|
||||||
|
|
||||||
|
### Логирование
|
||||||
|
```log
|
||||||
|
WARN Rate limit exceeded for IP 192.168.1.100: 101/100 requests
|
||||||
|
WARN Suspicious pattern detected from IP 192.168.1.100: /wp-admin
|
||||||
|
WARN Token validation failed for IP 192.168.1.100
|
||||||
|
```
|
||||||
|
|
||||||
|
## 💡 Рекомендации
|
||||||
|
|
||||||
|
### Развертывание в продакшене
|
||||||
|
1. **Reverse Proxy**: Nginx/CloudFlare для дополнительной фильтрации
|
||||||
|
2. **Firewall**: iptables/ufw для блокировки на сетевом уровне
|
||||||
|
3. **SSL/TLS**: Обязательное использование HTTPS
|
||||||
|
4. **Мониторинг**: Система алертов для подозрительной активности
|
||||||
|
|
||||||
|
### Настройка мониторинга
|
||||||
|
```bash
|
||||||
|
# Проверка заблокированных IP
|
||||||
|
redis-cli KEYS "rate_limit:*" | wc -l
|
||||||
|
|
||||||
|
# Логи безопасности
|
||||||
|
tail -f /var/log/quoter/security.log | grep "WARN\|ERROR"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Регулярное обслуживание
|
||||||
|
- Очистка старых записей из Redis
|
||||||
|
- Анализ логов на предмет новых угроз
|
||||||
|
- Обновление списков подозрительных паттернов
|
||||||
|
- Тестирование защиты от DDoS
|
||||||
|
|
||||||
|
## 🚨 Реагирование на инциденты
|
||||||
|
|
||||||
|
### При обнаружении атаки
|
||||||
|
1. **Анализ логов**: определение источника и типа атаки
|
||||||
|
2. **Блокировка IP**: добавление в firewall rules
|
||||||
|
3. **Масштабирование**: увеличение ресурсов при необходимости
|
||||||
|
4. **Документирование**: запись деталей для улучшения защиты
|
||||||
|
|
||||||
|
### Команды для экстренного реагирования
|
||||||
|
```bash
|
||||||
|
# Блокировка IP через Redis
|
||||||
|
redis-cli SET "rate_limit:general:192.168.1.100" '{"blocked_until":9999999999}'
|
||||||
|
|
||||||
|
# Проверка активных атак
|
||||||
|
grep "Rate limit exceeded" /var/log/quoter.log | tail -20
|
||||||
|
|
||||||
|
# Очистка всех rate limits (экстренная мера)
|
||||||
|
redis-cli FLUSHDB
|
||||||
|
```
|
||||||
@@ -2,8 +2,11 @@ use actix_web::error::ErrorInternalServerError;
|
|||||||
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
|
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
|
||||||
use log::{info, warn};
|
use log::{info, warn};
|
||||||
use redis::{AsyncCommands, aio::MultiplexedConnection};
|
use redis::{AsyncCommands, aio::MultiplexedConnection};
|
||||||
|
use reqwest::Client as HTTPClient;
|
||||||
|
use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::error::Error;
|
use serde_json::json;
|
||||||
|
use std::{collections::HashMap, env, error::Error};
|
||||||
|
|
||||||
// Структуры для JWT токенов
|
// Структуры для JWT токенов
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|||||||
142
src/handlers/common.rs
Normal file
142
src/handlers/common.rs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
use actix_web::{HttpRequest, HttpResponse, Result, error::ErrorUnauthorized};
|
||||||
|
use log::warn;
|
||||||
|
|
||||||
|
use crate::auth::validate_token;
|
||||||
|
|
||||||
|
/// Общие константы
|
||||||
|
pub const CACHE_CONTROL_IMMUTABLE: &str = "public, max-age=31536000, immutable"; // 1 год
|
||||||
|
pub const CORS_ALLOW_ORIGIN: &str = "*";
|
||||||
|
|
||||||
|
/// Извлекает и валидирует токен авторизации из заголовков запроса
|
||||||
|
pub fn extract_and_validate_token(req: &HttpRequest) -> Result<&str, actix_web::Error> {
|
||||||
|
// Извлекаем токен из заголовка авторизации
|
||||||
|
let token = req
|
||||||
|
.headers()
|
||||||
|
.get("Authorization")
|
||||||
|
.and_then(|header_value| header_value.to_str().ok())
|
||||||
|
.map(|auth_str| {
|
||||||
|
// Убираем префикс "Bearer " если он есть
|
||||||
|
auth_str.strip_prefix("Bearer ").unwrap_or(auth_str)
|
||||||
|
});
|
||||||
|
|
||||||
|
let token = token.ok_or_else(|| {
|
||||||
|
warn!("Request without authorization token");
|
||||||
|
ErrorUnauthorized("Authorization token required")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Проверяем длину токена
|
||||||
|
if token.len() < MIN_TOKEN_LENGTH || token.len() > MAX_TOKEN_LENGTH {
|
||||||
|
warn!("Token length invalid: {} chars", token.len());
|
||||||
|
return Err(ErrorUnauthorized("Invalid token format"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем формат токена
|
||||||
|
if !validate_token_format(token) {
|
||||||
|
warn!("Token format invalid");
|
||||||
|
return Err(ErrorUnauthorized("Invalid token format"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Валидируем токен
|
||||||
|
if !validate_token(token).unwrap_or(false) {
|
||||||
|
warn!("Token validation failed");
|
||||||
|
return Err(ErrorUnauthorized("Invalid or expired token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Создает HTTP ответ с оптимальными заголовками кэширования
|
||||||
|
pub fn create_cached_response(content_type: &str, data: Vec<u8>, etag: &str) -> HttpResponse {
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.content_type(content_type)
|
||||||
|
.insert_header(("etag", etag))
|
||||||
|
.insert_header(("cache-control", CACHE_CONTROL_IMMUTABLE))
|
||||||
|
.insert_header(("access-control-allow-origin", CORS_ALLOW_ORIGIN))
|
||||||
|
.body(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Создает стандартный HTTP ответ с заголовками CORS
|
||||||
|
pub fn create_response_with_cors(content_type: &str, data: Vec<u8>) -> HttpResponse {
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.content_type(content_type)
|
||||||
|
.insert_header(("access-control-allow-origin", CORS_ALLOW_ORIGIN))
|
||||||
|
.body(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Создает HTTP ответ с кэшированием на основе ETag
|
||||||
|
pub fn create_etag_response(content_type: &str, data: Vec<u8>, etag: &str) -> HttpResponse {
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.content_type(content_type)
|
||||||
|
.insert_header(("etag", etag))
|
||||||
|
.insert_header(("cache-control", CACHE_CONTROL_IMMUTABLE))
|
||||||
|
.insert_header(("access-control-allow-origin", CORS_ALLOW_ORIGIN))
|
||||||
|
.body(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Проверяет ETag для кэширования и возвращает 304 если совпадает
|
||||||
|
pub fn check_etag_cache(req: &HttpRequest, etag: &str) -> Option<HttpResponse> {
|
||||||
|
let client_etag = req
|
||||||
|
.headers()
|
||||||
|
.get("if-none-match")
|
||||||
|
.and_then(|h| h.to_str().ok());
|
||||||
|
|
||||||
|
if let Some(client_etag) = client_etag {
|
||||||
|
if client_etag == etag {
|
||||||
|
return Some(HttpResponse::NotModified().finish());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Проверяет путь на ACME challenge и возвращает 404 если нужно
|
||||||
|
pub fn check_acme_path(path: &str) -> Option<HttpResponse> {
|
||||||
|
if path.starts_with(".well-known/") || path.starts_with("/.well-known/") {
|
||||||
|
warn!("ACME challenge path requested: {}", path);
|
||||||
|
Some(HttpResponse::NotFound().finish())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Проверяет токен на подозрительные символы
|
||||||
|
pub fn validate_token_format(token: &str) -> bool {
|
||||||
|
// JWT должен состоять из 3 частей, разделенных точками
|
||||||
|
let parts: Vec<&str> = token.split('.').collect();
|
||||||
|
if parts.len() != 3 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что токен содержит только допустимые символы для JWT
|
||||||
|
token.chars().all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Создает ответ с задержкой для предотвращения брутфорса
|
||||||
|
pub async fn create_delayed_error_response(
|
||||||
|
status: actix_web::http::StatusCode,
|
||||||
|
message: &str,
|
||||||
|
delay_ms: u64,
|
||||||
|
) -> HttpResponse {
|
||||||
|
if delay_ms > 0 {
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpResponse::build(status)
|
||||||
|
.insert_header(("access-control-allow-origin", CORS_ALLOW_ORIGIN))
|
||||||
|
.json(serde_json::json!({
|
||||||
|
"error": message,
|
||||||
|
"retry_after": delay_ms / 1000
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Создает JSON ответ с ошибкой
|
||||||
|
pub fn create_error_response(status: actix_web::http::StatusCode, message: &str) -> HttpResponse {
|
||||||
|
HttpResponse::build(status)
|
||||||
|
.insert_header(("access-control-allow-origin", CORS_ALLOW_ORIGIN))
|
||||||
|
.json(serde_json::json!({
|
||||||
|
"error": message
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Константы для безопасности
|
||||||
|
pub const MAX_TOKEN_LENGTH: usize = 2048;
|
||||||
|
pub const MIN_TOKEN_LENGTH: usize = 100;
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod common;
|
||||||
mod proxy;
|
mod proxy;
|
||||||
mod serve_file;
|
mod serve_file;
|
||||||
mod upload;
|
mod upload;
|
||||||
|
|||||||
@@ -7,16 +7,9 @@ use crate::handlers::serve_file::serve_file;
|
|||||||
use crate::lookup::{find_file_by_pattern, get_mime_type};
|
use crate::lookup::{find_file_by_pattern, get_mime_type};
|
||||||
use crate::s3_utils::{check_file_exists, load_file_from_s3, upload_to_s3};
|
use crate::s3_utils::{check_file_exists, load_file_from_s3, upload_to_s3};
|
||||||
use crate::thumbnail::{find_closest_width, parse_file_path, thumbdata_save};
|
use crate::thumbnail::{find_closest_width, parse_file_path, thumbdata_save};
|
||||||
|
use super::common::{check_etag_cache, create_cached_response};
|
||||||
|
|
||||||
/// Создает HTTP ответ с оптимальными заголовками кэширования
|
// Удалена дублирующая функция, используется из common модуля
|
||||||
fn create_cached_response(content_type: &str, data: Vec<u8>, file_etag: &str) -> HttpResponse {
|
|
||||||
HttpResponse::Ok()
|
|
||||||
.content_type(content_type)
|
|
||||||
.insert_header(("etag", file_etag))
|
|
||||||
.insert_header(("cache-control", "public, max-age=31536000, immutable")) // 1 год
|
|
||||||
.insert_header(("access-control-allow-origin", "*"))
|
|
||||||
.body(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Обработчик для скачивания файла и генерации миниатюры, если она недоступна.
|
/// Обработчик для скачивания файла и генерации миниатюры, если она недоступна.
|
||||||
#[allow(clippy::collapsible_if)]
|
#[allow(clippy::collapsible_if)]
|
||||||
@@ -28,12 +21,6 @@ pub async fn proxy_handler(
|
|||||||
let start_time = std::time::Instant::now();
|
let start_time = std::time::Instant::now();
|
||||||
info!("GET {} [START]", requested_res);
|
info!("GET {} [START]", requested_res);
|
||||||
|
|
||||||
// Возвращаем 404 для .well-known путей (для Let's Encrypt ACME)
|
|
||||||
if requested_res.starts_with(".well-known/") {
|
|
||||||
warn!("ACME challenge path requested: {}", requested_res);
|
|
||||||
return Err(ErrorNotFound("Not found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let normalized_path = if requested_res.ends_with("/webp") {
|
let normalized_path = if requested_res.ends_with("/webp") {
|
||||||
info!("Converting to WebP format: {}", requested_res);
|
info!("Converting to WebP format: {}", requested_res);
|
||||||
requested_res.replace("/webp", "")
|
requested_res.replace("/webp", "")
|
||||||
@@ -41,13 +28,7 @@ pub async fn proxy_handler(
|
|||||||
requested_res.to_string()
|
requested_res.to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Проверяем If-None-Match заголовок для кэширования
|
// Парсим GET запрос
|
||||||
let client_etag = req
|
|
||||||
.headers()
|
|
||||||
.get("if-none-match")
|
|
||||||
.and_then(|h| h.to_str().ok());
|
|
||||||
|
|
||||||
// парсим GET запрос
|
|
||||||
let (base_filename, requested_width, extension) = parse_file_path(&normalized_path);
|
let (base_filename, requested_width, extension) = parse_file_path(&normalized_path);
|
||||||
let ext = extension.as_str().to_lowercase();
|
let ext = extension.as_str().to_lowercase();
|
||||||
let filekey = format!("{}.{}", base_filename, &ext);
|
let filekey = format!("{}.{}", base_filename, &ext);
|
||||||
@@ -57,13 +38,11 @@ pub async fn proxy_handler(
|
|||||||
base_filename, requested_width, ext
|
base_filename, requested_width, ext
|
||||||
);
|
);
|
||||||
|
|
||||||
// Генерируем ETag для кэширования
|
// Генерируем ETag для кэширования и проверяем кэш
|
||||||
let file_etag = format!("\"{}\"", &filekey);
|
let file_etag = format!("\"{}\"", &filekey);
|
||||||
if let Some(etag) = client_etag {
|
if let Some(response) = check_etag_cache(&req, &file_etag) {
|
||||||
if etag == file_etag {
|
|
||||||
info!("Cache hit for {}, returning 304", filekey);
|
info!("Cache hit for {}, returning 304", filekey);
|
||||||
return Ok(HttpResponse::NotModified().finish());
|
return Ok(response);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let content_type = match get_mime_type(&ext) {
|
let content_type = match get_mime_type(&ext) {
|
||||||
Some(mime) => mime.to_string(),
|
Some(mime) => mime.to_string(),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use mime_guess::MimeGuess;
|
|||||||
|
|
||||||
use crate::app_state::AppState;
|
use crate::app_state::AppState;
|
||||||
use crate::s3_utils::check_file_exists;
|
use crate::s3_utils::check_file_exists;
|
||||||
|
use super::common::{CACHE_CONTROL_IMMUTABLE, CORS_ALLOW_ORIGIN};
|
||||||
|
|
||||||
/// Функция для обслуживания файла по заданному пути.
|
/// Функция для обслуживания файла по заданному пути.
|
||||||
pub async fn serve_file(
|
pub async fn serve_file(
|
||||||
@@ -50,7 +51,7 @@ pub async fn serve_file(
|
|||||||
Ok(HttpResponse::Ok()
|
Ok(HttpResponse::Ok()
|
||||||
.content_type(mime_type.as_ref())
|
.content_type(mime_type.as_ref())
|
||||||
.insert_header(("etag", etag.as_str()))
|
.insert_header(("etag", etag.as_str()))
|
||||||
.insert_header(("cache-control", "public, max-age=31536000, immutable")) // 1 год
|
.insert_header(("cache-control", CACHE_CONTROL_IMMUTABLE))
|
||||||
.insert_header(("access-control-allow-origin", "*"))
|
.insert_header(("access-control-allow-origin", CORS_ALLOW_ORIGIN))
|
||||||
.body(data_bytes))
|
.body(data_bytes))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ use actix_multipart::Multipart;
|
|||||||
use log::{info, warn};
|
use log::{info, warn};
|
||||||
|
|
||||||
use crate::app_state::AppState;
|
use crate::app_state::AppState;
|
||||||
|
use crate::security::{SecurityManager, SecurityConfig};
|
||||||
|
use super::common::{create_error_response, check_acme_path};
|
||||||
|
|
||||||
/// Универсальный обработчик, который определяет HTTP метод и путь
|
/// Универсальный обработчик, который определяет HTTP метод и путь
|
||||||
pub async fn universal_handler(
|
pub async fn universal_handler(
|
||||||
@@ -15,21 +17,51 @@ pub async fn universal_handler(
|
|||||||
|
|
||||||
info!("Universal handler: {} {}", method, path);
|
info!("Universal handler: {} {}", method, path);
|
||||||
|
|
||||||
// Возвращаем 404 для .well-known путей (для Let's Encrypt ACME)
|
// Проверка ACME challenge путей
|
||||||
if path.starts_with("/.well-known/") {
|
if let Some(response) = check_acme_path(&path) {
|
||||||
warn!("ACME challenge path requested: {}", path);
|
return Ok(response);
|
||||||
return Ok(HttpResponse::NotFound().finish());
|
}
|
||||||
|
|
||||||
|
// Инициализация SecurityManager для проверок
|
||||||
|
let security_config = SecurityConfig::default();
|
||||||
|
let client_ip = SecurityManager::extract_client_ip(&req);
|
||||||
|
|
||||||
|
// Проверка базовых ограничений безопасности
|
||||||
|
if let Err(error) = SecurityManager::new(security_config.clone(), state.redis.clone())
|
||||||
|
.validate_request_security(&req) {
|
||||||
|
warn!("Security validation failed for IP {}: {}", client_ip, error);
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка подозрительных паттернов
|
||||||
|
let mut security_manager = SecurityManager::new(security_config.clone(), state.redis.clone());
|
||||||
|
if security_manager.check_suspicious_patterns(&path) {
|
||||||
|
warn!("Suspicious pattern detected from IP {}: {}", client_ip, path);
|
||||||
|
return Ok(create_error_response(
|
||||||
|
actix_web::http::StatusCode::NOT_FOUND,
|
||||||
|
"Not found"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка rate limits в зависимости от endpoint
|
||||||
|
let endpoint_type = match method.as_str() {
|
||||||
|
"POST" if path == "/" => "upload",
|
||||||
|
"GET" if path == "/" => "auth",
|
||||||
|
_ => "general"
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(error) = security_manager.check_rate_limit(&client_ip, endpoint_type).await {
|
||||||
|
warn!("Rate limit exceeded for IP {} on {}: {}", client_ip, endpoint_type, error);
|
||||||
|
return Err(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
match method.as_str() {
|
match method.as_str() {
|
||||||
"GET" => handle_get(req, state, &path).await,
|
"GET" => handle_get(req, state, &path).await,
|
||||||
"POST" => handle_post(req, payload, state, &path).await,
|
"POST" => handle_post(req, payload, state, &path).await,
|
||||||
_ => {
|
_ => Ok(create_error_response(
|
||||||
warn!("Unsupported HTTP method: {}", method);
|
actix_web::http::StatusCode::METHOD_NOT_ALLOWED,
|
||||||
Ok(HttpResponse::MethodNotAllowed().json(serde_json::json!({
|
"Method not allowed"
|
||||||
"error": "Method not allowed"
|
))
|
||||||
})))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +70,7 @@ async fn handle_get(
|
|||||||
state: web::Data<AppState>,
|
state: web::Data<AppState>,
|
||||||
path: &str,
|
path: &str,
|
||||||
) -> Result<HttpResponse, actix_web::Error> {
|
) -> Result<HttpResponse, actix_web::Error> {
|
||||||
if path == "/" {
|
if path == "/" || path == "" {
|
||||||
// GET / - получение информации о пользователе
|
// GET / - получение информации о пользователе
|
||||||
crate::handlers::user::get_current_user_handler(req, state).await
|
crate::handlers::user::get_current_user_handler(req, state).await
|
||||||
} else {
|
} else {
|
||||||
@@ -53,16 +85,9 @@ async fn handle_post(
|
|||||||
req: HttpRequest,
|
req: HttpRequest,
|
||||||
payload: web::Payload,
|
payload: web::Payload,
|
||||||
state: web::Data<AppState>,
|
state: web::Data<AppState>,
|
||||||
path: &str,
|
_path: &str,
|
||||||
) -> Result<HttpResponse, actix_web::Error> {
|
) -> Result<HttpResponse, actix_web::Error> {
|
||||||
if path == "/" {
|
|
||||||
// POST / - загрузка файла (multipart)
|
// POST / - загрузка файла (multipart)
|
||||||
let multipart = Multipart::new(&req.headers(), payload);
|
let multipart = Multipart::new(&req.headers(), payload);
|
||||||
crate::handlers::upload::upload_handler(req, multipart, state).await
|
crate::handlers::upload::upload_handler(req, multipart, state).await
|
||||||
} else {
|
|
||||||
warn!("Unsupported POST path: {}", path);
|
|
||||||
Ok(HttpResponse::NotFound().json(serde_json::json!({
|
|
||||||
"error": "Endpoint not found"
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ use actix_web::{HttpRequest, HttpResponse, Result, web};
|
|||||||
use log::{error, info, warn};
|
use log::{error, info, warn};
|
||||||
|
|
||||||
use crate::app_state::AppState;
|
use crate::app_state::AppState;
|
||||||
use crate::auth::{extract_user_id_from_token, user_added_file, validate_token};
|
use crate::auth::{extract_user_id_from_token, user_added_file};
|
||||||
use crate::handlers::MAX_USER_QUOTA_BYTES;
|
use crate::handlers::MAX_USER_QUOTA_BYTES;
|
||||||
use crate::lookup::store_file_info;
|
use crate::lookup::store_file_info;
|
||||||
use crate::s3_utils::{self, generate_key_with_extension, upload_to_s3};
|
use crate::s3_utils::{self, generate_key_with_extension, upload_to_s3};
|
||||||
|
use super::common::extract_and_validate_token;
|
||||||
use futures::TryStreamExt;
|
use futures::TryStreamExt;
|
||||||
// use crate::thumbnail::convert_heic_to_jpeg;
|
// use crate::thumbnail::convert_heic_to_jpeg;
|
||||||
|
|
||||||
@@ -19,28 +20,8 @@ pub async fn upload_handler(
|
|||||||
mut payload: Multipart,
|
mut payload: Multipart,
|
||||||
state: web::Data<AppState>,
|
state: web::Data<AppState>,
|
||||||
) -> Result<HttpResponse, actix_web::Error> {
|
) -> Result<HttpResponse, actix_web::Error> {
|
||||||
// Получаем токен из заголовка авторизации
|
// Извлекаем и валидируем токен
|
||||||
let token = req
|
let token = extract_and_validate_token(&req)?;
|
||||||
.headers()
|
|
||||||
.get("Authorization")
|
|
||||||
.and_then(|header_value| header_value.to_str().ok());
|
|
||||||
|
|
||||||
if token.is_none() {
|
|
||||||
warn!("Upload attempt without authorization token");
|
|
||||||
return Err(actix_web::error::ErrorUnauthorized(
|
|
||||||
"Authorization token required",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let token = token.unwrap();
|
|
||||||
|
|
||||||
// Сначала валидируем токен
|
|
||||||
if !validate_token(token).unwrap_or(false) {
|
|
||||||
warn!("Token validation failed");
|
|
||||||
return Err(actix_web::error::ErrorUnauthorized(
|
|
||||||
"Invalid or expired token",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Затем извлекаем user_id
|
// Затем извлекаем user_id
|
||||||
let user_id = extract_user_id_from_token(token).map_err(|e| {
|
let user_id = extract_user_id_from_token(token).map_err(|e| {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ use log::{error, info, warn};
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::app_state::AppState;
|
use crate::app_state::AppState;
|
||||||
use crate::auth::{Author, get_user_by_token, validate_token};
|
use crate::auth::{Author, get_user_by_token};
|
||||||
|
use super::common::extract_and_validate_token;
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct UserWithQuotaResponse {
|
pub struct UserWithQuotaResponse {
|
||||||
@@ -24,36 +25,8 @@ pub async fn get_current_user_handler(
|
|||||||
req: HttpRequest,
|
req: HttpRequest,
|
||||||
state: web::Data<AppState>,
|
state: web::Data<AppState>,
|
||||||
) -> Result<HttpResponse, actix_web::Error> {
|
) -> Result<HttpResponse, actix_web::Error> {
|
||||||
// Извлекаем токен из заголовка авторизации
|
// Извлекаем и валидируем токен
|
||||||
let token = req
|
let token = extract_and_validate_token(&req)?;
|
||||||
.headers()
|
|
||||||
.get("Authorization")
|
|
||||||
.and_then(|header_value| header_value.to_str().ok())
|
|
||||||
.map(|auth_str| {
|
|
||||||
// Убираем префикс "Bearer " если он есть
|
|
||||||
if let Some(stripped) = auth_str.strip_prefix("Bearer ") {
|
|
||||||
stripped
|
|
||||||
} else {
|
|
||||||
auth_str
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if token.is_none() {
|
|
||||||
warn!("Request for current user without authorization token");
|
|
||||||
return Err(actix_web::error::ErrorUnauthorized(
|
|
||||||
"Authorization token required",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let token = token.unwrap();
|
|
||||||
|
|
||||||
// Сначала валидируем токен
|
|
||||||
if !validate_token(token).unwrap_or(false) {
|
|
||||||
warn!("Token validation failed in user endpoint");
|
|
||||||
return Err(actix_web::error::ErrorUnauthorized(
|
|
||||||
"Invalid or expired token",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Getting user info for valid token");
|
info!("Getting user info for valid token");
|
||||||
|
|
||||||
|
|||||||
50
src/main.rs
50
src/main.rs
@@ -3,19 +3,21 @@ mod auth;
|
|||||||
mod handlers;
|
mod handlers;
|
||||||
mod lookup;
|
mod lookup;
|
||||||
mod s3_utils;
|
mod s3_utils;
|
||||||
|
mod security;
|
||||||
mod thumbnail;
|
mod thumbnail;
|
||||||
|
|
||||||
use actix_cors::Cors;
|
use actix_cors::Cors;
|
||||||
use actix_web::{
|
use actix_web::{
|
||||||
App, HttpServer,
|
App, HttpServer,
|
||||||
http::header::{self, HeaderName},
|
http::header,
|
||||||
middleware::Logger,
|
middleware::{Logger, DefaultHeaders},
|
||||||
web,
|
web,
|
||||||
};
|
};
|
||||||
use app_state::AppState;
|
use app_state::AppState;
|
||||||
|
use security::{SecurityConfig, security_middleware};
|
||||||
|
|
||||||
use handlers::universal_handler;
|
use handlers::universal_handler;
|
||||||
use log::warn;
|
use log::{warn, info};
|
||||||
use std::env;
|
use std::env;
|
||||||
use tokio::task::spawn_blocking;
|
use tokio::task::spawn_blocking;
|
||||||
|
|
||||||
@@ -37,27 +39,47 @@ async fn main() -> std::io::Result<()> {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Конфигурация безопасности
|
||||||
|
let security_config = SecurityConfig::default();
|
||||||
|
info!("Security config: max_payload={} MB, upload_rate_limit={}/{}s",
|
||||||
|
security_config.max_payload_size / (1024 * 1024),
|
||||||
|
security_config.upload_rate_limit.max_requests,
|
||||||
|
security_config.upload_rate_limit.window_seconds);
|
||||||
|
|
||||||
HttpServer::new(move || {
|
HttpServer::new(move || {
|
||||||
// Настройка CORS middleware
|
// Настройка CORS middleware - ограничиваем в продакшене
|
||||||
let cors = Cors::default()
|
let cors = Cors::default()
|
||||||
.allow_any_origin() // TODO: ограничить конкретными доменами в продакшене
|
.allowed_origin("https://discours.io")
|
||||||
.allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
.allowed_origin("https://new.discours.io")
|
||||||
|
.allowed_origin("https://testing.discours.io")
|
||||||
|
.allowed_origin("https://testing3.discours.io")
|
||||||
|
.allowed_origin("http://localhost:3000") // для разработки
|
||||||
|
.allowed_methods(vec!["GET", "POST", "OPTIONS"])
|
||||||
.allowed_headers(vec![
|
.allowed_headers(vec![
|
||||||
header::DNT,
|
|
||||||
header::USER_AGENT,
|
|
||||||
HeaderName::from_static("x-requested-with"),
|
|
||||||
header::IF_MODIFIED_SINCE,
|
|
||||||
header::CACHE_CONTROL,
|
|
||||||
header::CONTENT_TYPE,
|
header::CONTENT_TYPE,
|
||||||
header::RANGE,
|
|
||||||
header::AUTHORIZATION,
|
header::AUTHORIZATION,
|
||||||
|
header::IF_NONE_MATCH,
|
||||||
|
header::CACHE_CONTROL,
|
||||||
])
|
])
|
||||||
.expose_headers(vec![header::CONTENT_LENGTH, header::CONTENT_RANGE])
|
.expose_headers(vec![header::CONTENT_LENGTH, header::ETAG])
|
||||||
.supports_credentials()
|
.supports_credentials()
|
||||||
.max_age(1728000); // 20 дней
|
.max_age(86400); // 1 день вместо 20
|
||||||
|
|
||||||
|
// Заголовки безопасности
|
||||||
|
let security_headers = DefaultHeaders::new()
|
||||||
|
.add(("X-Content-Type-Options", "nosniff"))
|
||||||
|
.add(("X-Frame-Options", "DENY"))
|
||||||
|
.add(("X-XSS-Protection", "1; mode=block"))
|
||||||
|
.add(("Referrer-Policy", "strict-origin-when-cross-origin"))
|
||||||
|
.add(("Content-Security-Policy", "default-src 'self'; img-src 'self' data: https:; object-src 'none';"))
|
||||||
|
.add(("Strict-Transport-Security", "max-age=31536000; includeSubDomains"));
|
||||||
|
|
||||||
App::new()
|
App::new()
|
||||||
.app_data(web::Data::new(app_state.clone()))
|
.app_data(web::Data::new(app_state.clone()))
|
||||||
|
.app_data(web::PayloadConfig::new(security_config.max_payload_size))
|
||||||
|
.app_data(web::JsonConfig::default().limit(1024 * 1024)) // 1MB для JSON
|
||||||
|
.wrap(actix_web::middleware::from_fn(security_middleware))
|
||||||
|
.wrap(security_headers)
|
||||||
.wrap(cors)
|
.wrap(cors)
|
||||||
.wrap(Logger::default())
|
.wrap(Logger::default())
|
||||||
.default_service(web::to(universal_handler))
|
.default_service(web::to(universal_handler))
|
||||||
|
|||||||
346
src/security.rs
Normal file
346
src/security.rs
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
use actix_web::{HttpRequest, dev::ServiceRequest, middleware::Next, dev::ServiceResponse, error::ErrorTooManyRequests};
|
||||||
|
use log::{warn, error, info};
|
||||||
|
use redis::{AsyncCommands, aio::MultiplexedConnection};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Конфигурация лимитов запросов
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RateLimitConfig {
|
||||||
|
/// Максимальное количество запросов в окне времени
|
||||||
|
pub max_requests: u32,
|
||||||
|
/// Окно времени в секундах
|
||||||
|
pub window_seconds: u64,
|
||||||
|
/// Блокировка на количество секунд при превышении лимита
|
||||||
|
pub block_duration_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RateLimitConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_requests: 100, // 100 запросов
|
||||||
|
window_seconds: 60, // в минуту
|
||||||
|
block_duration_seconds: 300, // блокировка на 5 минут
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Конфигурация для разных типов запросов
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SecurityConfig {
|
||||||
|
/// Общий лимит по IP
|
||||||
|
pub general_rate_limit: RateLimitConfig,
|
||||||
|
/// Лимит для загрузки файлов
|
||||||
|
pub upload_rate_limit: RateLimitConfig,
|
||||||
|
/// Лимит для аутентификации
|
||||||
|
pub auth_rate_limit: RateLimitConfig,
|
||||||
|
/// Максимальный размер тела запроса (байты)
|
||||||
|
pub max_payload_size: usize,
|
||||||
|
/// Таймаут запроса (секунды)
|
||||||
|
pub request_timeout_seconds: u64,
|
||||||
|
/// Максимальная длина пути
|
||||||
|
pub max_path_length: usize,
|
||||||
|
/// Максимальное количество заголовков
|
||||||
|
pub max_headers_count: usize,
|
||||||
|
/// Максимальная длина значения заголовка
|
||||||
|
pub max_header_value_length: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SecurityConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
general_rate_limit: RateLimitConfig::default(),
|
||||||
|
upload_rate_limit: RateLimitConfig {
|
||||||
|
max_requests: 10, // 10 загрузок
|
||||||
|
window_seconds: 300, // в 5 минут
|
||||||
|
block_duration_seconds: 600, // блокировка на 10 минут
|
||||||
|
},
|
||||||
|
auth_rate_limit: RateLimitConfig {
|
||||||
|
max_requests: 20, // 20 попыток аутентификации
|
||||||
|
window_seconds: 900, // в 15 минут
|
||||||
|
block_duration_seconds: 1800, // блокировка на 30 минут
|
||||||
|
},
|
||||||
|
max_payload_size: 4000 * 1024 * 1024, // 4000 МБ
|
||||||
|
request_timeout_seconds: 300, // 5 минут
|
||||||
|
max_path_length: 1000,
|
||||||
|
max_headers_count: 50,
|
||||||
|
max_header_value_length: 8192,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Структура для хранения информации о запросах
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RequestInfo {
|
||||||
|
pub count: u32,
|
||||||
|
pub first_request_time: u64,
|
||||||
|
pub blocked_until: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Менеджер безопасности
|
||||||
|
pub struct SecurityManager {
|
||||||
|
pub config: SecurityConfig,
|
||||||
|
redis: MultiplexedConnection,
|
||||||
|
// Локальный кэш для быстрых проверок
|
||||||
|
local_cache: Arc<RwLock<HashMap<String, RequestInfo>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecurityManager {
|
||||||
|
pub fn new(config: SecurityConfig, redis: MultiplexedConnection) -> Self {
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
redis,
|
||||||
|
local_cache: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получает IP адрес из запроса, учитывая прокси
|
||||||
|
pub fn extract_client_ip(req: &HttpRequest) -> String {
|
||||||
|
// Проверяем заголовки прокси
|
||||||
|
if let Some(forwarded_for) = req.headers().get("x-forwarded-for") {
|
||||||
|
if let Ok(forwarded_str) = forwarded_for.to_str() {
|
||||||
|
if let Some(first_ip) = forwarded_str.split(',').next() {
|
||||||
|
return first_ip.trim().to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(real_ip) = req.headers().get("x-real-ip") {
|
||||||
|
if let Ok(ip_str) = real_ip.to_str() {
|
||||||
|
return ip_str.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback к connection info
|
||||||
|
req.connection_info()
|
||||||
|
.realip_remote_addr()
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Проверяет лимиты запросов для IP
|
||||||
|
pub async fn check_rate_limit(&mut self, ip: &str, endpoint_type: &str) -> Result<(), actix_web::Error> {
|
||||||
|
let config = match endpoint_type {
|
||||||
|
"upload" => &self.config.upload_rate_limit,
|
||||||
|
"auth" => &self.config.auth_rate_limit,
|
||||||
|
_ => &self.config.general_rate_limit,
|
||||||
|
};
|
||||||
|
|
||||||
|
let current_time = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
let redis_key = format!("rate_limit:{}:{}", endpoint_type, ip);
|
||||||
|
|
||||||
|
// Проверяем локальный кэш
|
||||||
|
{
|
||||||
|
let cache = self.local_cache.read().await;
|
||||||
|
if let Some(info) = cache.get(&redis_key) {
|
||||||
|
if let Some(blocked_until) = info.blocked_until {
|
||||||
|
if current_time < blocked_until {
|
||||||
|
warn!("IP {} blocked until {}", ip, blocked_until);
|
||||||
|
return Err(ErrorTooManyRequests("Rate limit exceeded, IP temporarily blocked"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем в Redis
|
||||||
|
let info_str: Option<String> = self.redis.get(&redis_key).await
|
||||||
|
.map_err(|e| {
|
||||||
|
error!("Redis error in rate limit check: {}", e);
|
||||||
|
actix_web::error::ErrorInternalServerError("Service temporarily unavailable")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut request_info = if let Some(info_str) = info_str {
|
||||||
|
serde_json::from_str::<RequestInfo>(&info_str)
|
||||||
|
.unwrap_or_else(|_| RequestInfo {
|
||||||
|
count: 0,
|
||||||
|
first_request_time: current_time,
|
||||||
|
blocked_until: None,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
RequestInfo {
|
||||||
|
count: 0,
|
||||||
|
first_request_time: current_time,
|
||||||
|
blocked_until: None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Проверяем блокировку
|
||||||
|
if let Some(blocked_until) = request_info.blocked_until {
|
||||||
|
if current_time < blocked_until {
|
||||||
|
warn!("IP {} is blocked until {}", ip, blocked_until);
|
||||||
|
return Err(ErrorTooManyRequests("Rate limit exceeded, IP temporarily blocked"));
|
||||||
|
} else {
|
||||||
|
// Блокировка истекла, сбрасываем
|
||||||
|
request_info.blocked_until = None;
|
||||||
|
request_info.count = 0;
|
||||||
|
request_info.first_request_time = current_time;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем окно времени
|
||||||
|
if current_time - request_info.first_request_time > config.window_seconds {
|
||||||
|
// Новое окно времени, сбрасываем счетчик
|
||||||
|
request_info.count = 0;
|
||||||
|
request_info.first_request_time = current_time;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Увеличиваем счетчик
|
||||||
|
request_info.count += 1;
|
||||||
|
|
||||||
|
// Проверяем лимит
|
||||||
|
if request_info.count > config.max_requests {
|
||||||
|
warn!("Rate limit exceeded for IP {}: {} requests in window", ip, request_info.count);
|
||||||
|
|
||||||
|
// Устанавливаем блокировку
|
||||||
|
request_info.blocked_until = Some(current_time + config.block_duration_seconds);
|
||||||
|
|
||||||
|
// Сохраняем в Redis
|
||||||
|
let info_str = serde_json::to_string(&request_info).unwrap();
|
||||||
|
let _: () = self.redis.set_ex(&redis_key, info_str, config.block_duration_seconds).await
|
||||||
|
.map_err(|e| {
|
||||||
|
error!("Redis error saving rate limit: {}", e);
|
||||||
|
actix_web::error::ErrorInternalServerError("Service temporarily unavailable")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Обновляем локальный кэш
|
||||||
|
{
|
||||||
|
let mut cache = self.local_cache.write().await;
|
||||||
|
cache.insert(redis_key, request_info);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Err(ErrorTooManyRequests("Rate limit exceeded, IP temporarily blocked"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем обновленную информацию
|
||||||
|
let info_str = serde_json::to_string(&request_info).unwrap();
|
||||||
|
let _: () = self.redis.set_ex(&redis_key, info_str, config.window_seconds * 2).await
|
||||||
|
.map_err(|e| {
|
||||||
|
error!("Redis error updating rate limit: {}", e);
|
||||||
|
actix_web::error::ErrorInternalServerError("Service temporarily unavailable")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let count = request_info.count;
|
||||||
|
|
||||||
|
// Обновляем локальный кэш
|
||||||
|
{
|
||||||
|
let mut cache = self.local_cache.write().await;
|
||||||
|
cache.insert(redis_key, request_info);
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Rate limit check passed for IP {}: {}/{} requests", ip, count, config.max_requests);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Проверяет безопасность запроса (размер, заголовки, путь)
|
||||||
|
pub fn validate_request_security(&self, req: &HttpRequest) -> Result<(), actix_web::Error> {
|
||||||
|
// Проверка длины пути
|
||||||
|
let path = req.path();
|
||||||
|
if path.len() > self.config.max_path_length {
|
||||||
|
warn!("Request path too long: {} chars", path.len());
|
||||||
|
return Err(actix_web::error::ErrorBadRequest("Request path too long"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка количества заголовков
|
||||||
|
if req.headers().len() > self.config.max_headers_count {
|
||||||
|
warn!("Too many headers: {}", req.headers().len());
|
||||||
|
return Err(actix_web::error::ErrorBadRequest("Too many headers"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка длины значений заголовков
|
||||||
|
for (name, value) in req.headers().iter() {
|
||||||
|
if let Ok(value_str) = value.to_str() {
|
||||||
|
if value_str.len() > self.config.max_header_value_length {
|
||||||
|
warn!("Header value too long: {} = {} chars", name, value_str.len());
|
||||||
|
return Err(actix_web::error::ErrorBadRequest("Header value too long"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка на подозрительные символы в пути
|
||||||
|
if path.contains("..") || path.contains('\0') || path.contains('\r') || path.contains('\n') {
|
||||||
|
warn!("Suspicious characters in path: {}", path);
|
||||||
|
return Err(actix_web::error::ErrorBadRequest("Invalid characters in path"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Проверяет подозрительные паттерны в пути
|
||||||
|
pub fn check_suspicious_patterns(&self, path: &str) -> bool {
|
||||||
|
let suspicious_patterns = [
|
||||||
|
"/admin", "/wp-admin", "/phpmyadmin", "/.env", "/config",
|
||||||
|
"/.git", "/backup", "/db", "/sql", "/.well-known/acme-challenge",
|
||||||
|
"/xmlrpc.php", "/wp-login.php", "/wp-config.php",
|
||||||
|
"script>", "<iframe", "javascript:", "data:",
|
||||||
|
];
|
||||||
|
|
||||||
|
let path_lower = path.to_lowercase();
|
||||||
|
for pattern in &suspicious_patterns {
|
||||||
|
if path_lower.contains(pattern) {
|
||||||
|
warn!("Suspicious pattern detected in path: {} (pattern: {})", path, pattern);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Очистка старых записей из локального кэша
|
||||||
|
pub async fn cleanup_cache(&mut self) {
|
||||||
|
let current_time = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
let mut cache = self.local_cache.write().await;
|
||||||
|
let mut to_remove = Vec::new();
|
||||||
|
|
||||||
|
for (key, info) in cache.iter() {
|
||||||
|
// Удаляем записи старше 1 часа
|
||||||
|
if current_time - info.first_request_time > 3600 {
|
||||||
|
to_remove.push(key.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for key in to_remove {
|
||||||
|
cache.remove(&key);
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Cleaned {} old entries from security cache", cache.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Middleware для проверки безопасности
|
||||||
|
pub async fn security_middleware(
|
||||||
|
req: ServiceRequest,
|
||||||
|
next: Next<impl actix_web::body::MessageBody>,
|
||||||
|
) -> Result<ServiceResponse<impl actix_web::body::MessageBody>, actix_web::Error> {
|
||||||
|
let path = req.path().to_string();
|
||||||
|
let method = req.method().to_string();
|
||||||
|
|
||||||
|
// Быстрая проверка на известные атаки
|
||||||
|
if path.contains("..") || path.contains('\0') || path.len() > 1000 {
|
||||||
|
warn!("Blocked suspicious request: {} {}", method, path);
|
||||||
|
return Err(actix_web::error::ErrorBadRequest("Invalid request"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка на bot patterns
|
||||||
|
if let Some(user_agent) = req.headers().get("user-agent") {
|
||||||
|
if let Ok(ua_str) = user_agent.to_str() {
|
||||||
|
let ua_lower = ua_str.to_lowercase();
|
||||||
|
if ua_lower.contains("bot") || ua_lower.contains("crawler") || ua_lower.contains("spider") {
|
||||||
|
// Для ботов применяем более строгие лимиты
|
||||||
|
info!("Bot detected: {}", ua_str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let res = next.call(req).await?;
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user