Files
quoter/src/security.rs
Untone 9d68c0c078
Some checks failed
Deploy quoter Microservice on push / deploy (push) Failing after 37m50s
[0.6.8] - 2025-10-03
### 🔒 Security: Early Scan Rejection
- ** Ранний reject**: Проверка suspicious patterns ДО вызова proxy_handler (минимум логов)
- **🎯 Расширенные паттерны**: Добавлены `wp-includes`, `wlwmanifest` (без слешей для любых подпапок)
- **📦 CMS защита**: Joomla, Drupal, Magento paths в blacklist
- **🔕 Zero-log policy**: Silent 404 для всех сканов - нулевое логирование

### Changed
- **security.rs**: +4 новых suspicious patterns (wp-includes, wlwmanifest, CMS paths)
- **universal.rs**: Двойная проверка - ранний reject в handle_get ДО proxy
- **auth.rs**:
  - Added `Clone` derive для `TokenClaims` (требование jsonwebtoken v10)
- **Tests**:  Все тесты проходят (3/3 passed)
2025-10-03 19:58:43 +03:00

214 lines
7.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use actix_web::HttpRequest;
use log::warn;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::RwLock;
/// Простая защита от злоупотреблений для upload endpoint
#[derive(Debug, Clone)]
pub struct UploadProtection {
/// Максимальное количество загрузок в минуту с одного IP
pub max_uploads_per_minute: u32,
/// Локальный кэш для подсчета загрузок
pub upload_counts: Arc<RwLock<HashMap<String, (u32, u64)>>>,
}
/// Конфигурация безопасности для простого storage proxy
#[derive(Debug, Clone)]
pub struct SecurityConfig {
/// Максимальный размер тела запроса (байты)
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,
/// Защита от злоупотреблений upload
pub upload_protection: UploadProtection,
}
impl Default for SecurityConfig {
fn default() -> Self {
Self {
max_payload_size: 500 * 1024 * 1024, // 500MB
request_timeout_seconds: 300, // 5 минут
max_path_length: 1000,
max_headers_count: 50,
max_header_value_length: 8192,
upload_protection: UploadProtection {
max_uploads_per_minute: 10, // 10 загрузок в минуту
upload_counts: Arc::new(RwLock::new(HashMap::new())),
},
}
}
}
impl SecurityConfig {
/// Валидирует запрос на базовые параметры безопасности
pub fn validate_request(&self, req: &HttpRequest) -> Result<(), actix_web::Error> {
let path = req.path();
// Проверка длины пути
if path.len() > self.max_path_length {
warn!("Path too long: {} chars", path.len());
return Err(actix_web::error::ErrorBadRequest("Path too long"));
}
// Проверка количества заголовков
if req.headers().len() > self.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() {
if let Ok(value_str) = value.to_str() {
if value_str.len() > self.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",
));
}
// Проверка на подозрительные паттерны
if self.check_suspicious_patterns(path) {
return Err(actix_web::error::ErrorBadRequest("Suspicious path pattern"));
}
Ok(())
}
/// Проверяет путь на подозрительные паттерны (молча отклоняет сканы)
pub fn check_suspicious_patterns(&self, path: &str) -> bool {
let suspicious_patterns = [
// WordPress scanning patterns
"/wp-admin",
"/wp-includes/",
"/wp-content/",
"/wp-login.php",
"/wp-config.php",
"/xmlrpc.php",
"/wlwmanifest.xml",
"/wp-json/",
"/wordpress/",
"wp-includes", // Добавлено для любых подпапок
"wlwmanifest", // Добавлено без слеша
// Admin panels
"/admin",
"/phpmyadmin",
"/cpanel",
"/plesk",
// Config & sensitive files
"/.env",
"/config",
"/.git",
"/backup",
"/db",
"/sql",
"/.htaccess",
"/web.config",
// XSS & injection patterns
"script>",
"<iframe",
"javascript:",
"data:",
"eval(",
// Common CMS paths
"/joomla",
"/drupal",
"/magento",
"/.well-known/security.txt",
];
let path_lower = path.to_lowercase();
for pattern in &suspicious_patterns {
if path_lower.contains(pattern) {
// Silent reject - no logging for scan attempts
return true;
}
}
false
}
/// Проверяет лимит загрузок для IP (только для upload endpoint)
pub async fn check_upload_limit(&self, ip: &str) -> Result<(), actix_web::Error> {
let current_time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let mut counts = self.upload_protection.upload_counts.write().await;
// Очищаем старые записи (старше минуты)
counts.retain(|_, (_, timestamp)| current_time - *timestamp < 60);
// Проверяем текущий IP
let current_count = counts.get(ip).map(|(count, _)| *count).unwrap_or(0);
let first_upload_time = counts
.get(ip)
.map(|(_, time)| *time)
.unwrap_or(current_time);
if current_time - first_upload_time < 60 {
// В пределах минуты
if current_count >= self.upload_protection.max_uploads_per_minute {
warn!(
"Upload limit exceeded for IP {}: {} uploads in minute",
ip, current_count
);
return Err(actix_web::error::ErrorTooManyRequests(
"Upload limit exceeded",
));
}
counts.insert(ip.to_string(), (current_count + 1, first_upload_time));
} else {
// Новая минута, сбрасываем счетчик
counts.insert(ip.to_string(), (1, current_time));
}
Ok(())
}
/// Извлекает IP адрес клиента
pub fn extract_client_ip(req: &HttpRequest) -> String {
// Проверяем X-Forwarded-For (для прокси)
if let Some(forwarded) = req.headers().get("x-forwarded-for") {
if let Ok(forwarded_str) = forwarded.to_str() {
if let Some(first_ip) = forwarded_str.split(',').next() {
return first_ip.trim().to_string();
}
}
}
// Проверяем X-Real-IP
if let Some(real_ip) = req.headers().get("x-real-ip") {
if let Ok(real_ip_str) = real_ip.to_str() {
return real_ip_str.to_string();
}
}
// Fallback на connection info
req.connection_info()
.peer_addr()
.unwrap_or("unknown")
.to_string()
}
}