Some checks failed
Deploy quoter Microservice on push / deploy (push) Failing after 37m50s
### 🔒 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)
214 lines
7.8 KiB
Rust
214 lines
7.8 KiB
Rust
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()
|
||
}
|
||
}
|