From 82668768d01b17d45d977e350ca570df0347f207 Mon Sep 17 00:00:00 2001 From: Untone Date: Tue, 2 Sep 2025 11:40:43 +0300 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=92=20Implement=20comprehensive=20secu?= =?UTF-8?q?rity=20and=20DDoS=20protection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 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. --- CHANGELOG.md | 32 +++- Cargo.lock | 2 +- Cargo.toml | 2 +- docs/security.md | 176 +++++++++++++++++++ src/auth.rs | 5 +- src/handlers/common.rs | 142 +++++++++++++++ src/handlers/mod.rs | 1 + src/handlers/proxy.rs | 35 +--- src/handlers/serve_file.rs | 5 +- src/handlers/universal.rs | 69 +++++--- src/handlers/upload.rs | 27 +-- src/handlers/user.rs | 35 +--- src/main.rs | 50 ++++-- src/security.rs | 346 +++++++++++++++++++++++++++++++++++++ 14 files changed, 803 insertions(+), 124 deletions(-) create mode 100644 docs/security.md create mode 100644 src/handlers/common.rs create mode 100644 src/security.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index ace35a3..98b909e 100644 --- a/CHANGELOG.md +++ b/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 ### πŸ”„ АрхитСктурныС измСнСния - **Π£ΠŸΠ ΠžΠ©Π•ΠΠž**: Π£Π±Ρ€Π°Π½ слоТный Ρ€ΠΎΡƒΡ‚ΠΈΠ½Π³ Actix-web Π² ΠΏΠΎΠ»ΡŒΠ·Ρƒ ΡƒΠ½ΠΈΠ²Π΅Ρ€ΡΠ°Π»ΡŒΠ½ΠΎΠ³ΠΎ ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊΠ° - **Π”ΠžΠ‘ΠΠ’Π›Π•ΠΠž**: ΠŸΡ€ΡΠΌΠΎΠ΅ ΠΎΠΏΡ€Π΅Π΄Π΅Π»Π΅Π½ΠΈΠ΅ HTTP ΠΌΠ΅Ρ‚ΠΎΠ΄ΠΎΠ² (GET/POST) Π² Π΅Π΄ΠΈΠ½ΠΎΠΉ Ρ‚ΠΎΡ‡ΠΊΠ΅ - **Π£Π‘Π ΠΠΠž**: HTTP API для управлСния ΠΊΠ²ΠΎΡ‚Π°ΠΌΠΈ (quota endpoints) -- **БОΠ₯Π ΠΠΠ•ΠΠž**: ACME challenge ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΠ° для SSL сСртификатов ### πŸ“‹ API Π‘Ρ‚Ρ€ΡƒΠΊΡ‚ΡƒΡ€Π° - `GET /` - авторизованная информация ΠΎ ΠΏΠ΅Ρ€ΡΠΎΠ½Π°Π»ΡŒΠ½ΠΎΠΌ Ρ…Ρ€Π°Π½ΠΈΠ»ΠΈΡ‰Π΅ diff --git a/Cargo.lock b/Cargo.lock index 4e9fef9..72a91d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2644,7 +2644,7 @@ dependencies = [ [[package]] name = "quoter" -version = "0.5.3" +version = "0.5.4" dependencies = [ "actix", "actix-cors", diff --git a/Cargo.toml b/Cargo.toml index dde7b8e..0ac12ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "quoter" -version = "0.5.3" +version = "0.6.0" edition = "2024" [dependencies] diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..28e446f --- /dev/null +++ b/docs/security.md @@ -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>", " 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, 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) -> 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, 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 { + 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 { + 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; diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 75ff23d..4d2ad12 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,3 +1,4 @@ +mod common; mod proxy; mod serve_file; mod upload; diff --git a/src/handlers/proxy.rs b/src/handlers/proxy.rs index 8289c66..d965874 100644 --- a/src/handlers/proxy.rs +++ b/src/handlers/proxy.rs @@ -7,16 +7,9 @@ use crate::handlers::serve_file::serve_file; 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::thumbnail::{find_closest_width, parse_file_path, thumbdata_save}; +use super::common::{check_etag_cache, create_cached_response}; -/// Π‘ΠΎΠ·Π΄Π°Π΅Ρ‚ HTTP ΠΎΡ‚Π²Π΅Ρ‚ с ΠΎΠΏΡ‚ΠΈΠΌΠ°Π»ΡŒΠ½Ρ‹ΠΌΠΈ Π·Π°Π³ΠΎΠ»ΠΎΠ²ΠΊΠ°ΠΌΠΈ ΠΊΡΡˆΠΈΡ€ΠΎΠ²Π°Π½ΠΈΡ -fn create_cached_response(content_type: &str, data: Vec, 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) -} +// Π£Π΄Π°Π»Π΅Π½Π° Π΄ΡƒΠ±Π»ΠΈΡ€ΡƒΡŽΡ‰Π°Ρ функция, ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ΡΡ ΠΈΠ· common модуля /// ΠžΠ±Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊ для скачивания Ρ„Π°ΠΉΠ»Π° ΠΈ Π³Π΅Π½Π΅Ρ€Π°Ρ†ΠΈΠΈ ΠΌΠΈΠ½ΠΈΠ°Ρ‚ΡŽΡ€Ρ‹, Ссли ΠΎΠ½Π° нСдоступна. #[allow(clippy::collapsible_if)] @@ -28,12 +21,6 @@ pub async fn proxy_handler( let start_time = std::time::Instant::now(); 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") { info!("Converting to WebP format: {}", requested_res); requested_res.replace("/webp", "") @@ -41,13 +28,7 @@ pub async fn proxy_handler( requested_res.to_string() }; - // ΠŸΡ€ΠΎΠ²Π΅Ρ€ΡΠ΅ΠΌ If-None-Match Π·Π°Π³ΠΎΠ»ΠΎΠ²ΠΎΠΊ для ΠΊΡΡˆΠΈΡ€ΠΎΠ²Π°Π½ΠΈΡ - let client_etag = req - .headers() - .get("if-none-match") - .and_then(|h| h.to_str().ok()); - - // парсим GET запрос + // ΠŸΠ°Ρ€ΡΠΈΠΌ GET запрос let (base_filename, requested_width, extension) = parse_file_path(&normalized_path); let ext = extension.as_str().to_lowercase(); let filekey = format!("{}.{}", base_filename, &ext); @@ -57,13 +38,11 @@ pub async fn proxy_handler( base_filename, requested_width, ext ); - // Π“Π΅Π½Π΅Ρ€ΠΈΡ€ΡƒΠ΅ΠΌ ETag для ΠΊΡΡˆΠΈΡ€ΠΎΠ²Π°Π½ΠΈΡ + // Π“Π΅Π½Π΅Ρ€ΠΈΡ€ΡƒΠ΅ΠΌ ETag для ΠΊΡΡˆΠΈΡ€ΠΎΠ²Π°Π½ΠΈΡ ΠΈ провСряСм кэш let file_etag = format!("\"{}\"", &filekey); - if let Some(etag) = client_etag { - if etag == file_etag { - info!("Cache hit for {}, returning 304", filekey); - return Ok(HttpResponse::NotModified().finish()); - } + if let Some(response) = check_etag_cache(&req, &file_etag) { + info!("Cache hit for {}, returning 304", filekey); + return Ok(response); } let content_type = match get_mime_type(&ext) { Some(mime) => mime.to_string(), diff --git a/src/handlers/serve_file.rs b/src/handlers/serve_file.rs index 48fdaca..106ecce 100644 --- a/src/handlers/serve_file.rs +++ b/src/handlers/serve_file.rs @@ -3,6 +3,7 @@ use mime_guess::MimeGuess; use crate::app_state::AppState; use crate::s3_utils::check_file_exists; +use super::common::{CACHE_CONTROL_IMMUTABLE, CORS_ALLOW_ORIGIN}; /// Ѐункция для обслуТивания Ρ„Π°ΠΉΠ»Π° ΠΏΠΎ Π·Π°Π΄Π°Π½Π½ΠΎΠΌΡƒ ΠΏΡƒΡ‚ΠΈ. pub async fn serve_file( @@ -50,7 +51,7 @@ pub async fn serve_file( Ok(HttpResponse::Ok() .content_type(mime_type.as_ref()) .insert_header(("etag", etag.as_str())) - .insert_header(("cache-control", "public, max-age=31536000, immutable")) // 1 Π³ΠΎΠ΄ - .insert_header(("access-control-allow-origin", "*")) + .insert_header(("cache-control", CACHE_CONTROL_IMMUTABLE)) + .insert_header(("access-control-allow-origin", CORS_ALLOW_ORIGIN)) .body(data_bytes)) } diff --git a/src/handlers/universal.rs b/src/handlers/universal.rs index aeaaa64..5e8c007 100644 --- a/src/handlers/universal.rs +++ b/src/handlers/universal.rs @@ -3,6 +3,8 @@ use actix_multipart::Multipart; use log::{info, warn}; use crate::app_state::AppState; +use crate::security::{SecurityManager, SecurityConfig}; +use super::common::{create_error_response, check_acme_path}; /// Π£Π½ΠΈΠ²Π΅Ρ€ΡΠ°Π»ΡŒΠ½Ρ‹ΠΉ ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊ, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΉ опрСдСляСт HTTP ΠΌΠ΅Ρ‚ΠΎΠ΄ ΠΈ ΠΏΡƒΡ‚ΡŒ pub async fn universal_handler( @@ -15,21 +17,51 @@ pub async fn universal_handler( info!("Universal handler: {} {}", method, path); - // Π’ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅ΠΌ 404 для .well-known ΠΏΡƒΡ‚Π΅ΠΉ (для Let's Encrypt ACME) - if path.starts_with("/.well-known/") { - warn!("ACME challenge path requested: {}", path); - return Ok(HttpResponse::NotFound().finish()); + // ΠŸΡ€ΠΎΠ²Π΅Ρ€ΠΊΠ° ACME challenge ΠΏΡƒΡ‚Π΅ΠΉ + if let Some(response) = check_acme_path(&path) { + return Ok(response); + } + + // Π˜Π½ΠΈΡ†ΠΈΠ°Π»ΠΈΠ·Π°Ρ†ΠΈΡ 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() { "GET" => handle_get(req, state, &path).await, "POST" => handle_post(req, payload, state, &path).await, - _ => { - warn!("Unsupported HTTP method: {}", method); - Ok(HttpResponse::MethodNotAllowed().json(serde_json::json!({ - "error": "Method not allowed" - }))) - } + _ => Ok(create_error_response( + actix_web::http::StatusCode::METHOD_NOT_ALLOWED, + "Method not allowed" + )) } } @@ -38,7 +70,7 @@ async fn handle_get( state: web::Data, path: &str, ) -> Result { - if path == "/" { + if path == "/" || path == "" { // GET / - ΠΏΠΎΠ»ΡƒΡ‡Π΅Π½ΠΈΠ΅ ΠΈΠ½Ρ„ΠΎΡ€ΠΌΠ°Ρ†ΠΈΠΈ ΠΎ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ crate::handlers::user::get_current_user_handler(req, state).await } else { @@ -53,16 +85,9 @@ async fn handle_post( req: HttpRequest, payload: web::Payload, state: web::Data, - path: &str, + _path: &str, ) -> Result { - if path == "/" { - // POST / - Π·Π°Π³Ρ€ΡƒΠ·ΠΊΠ° Ρ„Π°ΠΉΠ»Π° (multipart) - let multipart = Multipart::new(&req.headers(), payload); - 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" - }))) - } + // POST / - Π·Π°Π³Ρ€ΡƒΠ·ΠΊΠ° Ρ„Π°ΠΉΠ»Π° (multipart) + let multipart = Multipart::new(&req.headers(), payload); + crate::handlers::upload::upload_handler(req, multipart, state).await } diff --git a/src/handlers/upload.rs b/src/handlers/upload.rs index f00eb49..2834e87 100644 --- a/src/handlers/upload.rs +++ b/src/handlers/upload.rs @@ -3,10 +3,11 @@ use actix_web::{HttpRequest, HttpResponse, Result, web}; use log::{error, info, warn}; 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::lookup::store_file_info; use crate::s3_utils::{self, generate_key_with_extension, upload_to_s3}; +use super::common::extract_and_validate_token; use futures::TryStreamExt; // use crate::thumbnail::convert_heic_to_jpeg; @@ -19,28 +20,8 @@ pub async fn upload_handler( mut payload: Multipart, state: web::Data, ) -> Result { - // ΠŸΠΎΠ»ΡƒΡ‡Π°Π΅ΠΌ Ρ‚ΠΎΠΊΠ΅Π½ ΠΈΠ· Π·Π°Π³ΠΎΠ»ΠΎΠ²ΠΊΠ° Π°Π²Ρ‚ΠΎΡ€ΠΈΠ·Π°Ρ†ΠΈΠΈ - let 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", - )); - } + // ИзвлСкаСм ΠΈ Π²Π°Π»ΠΈΠ΄ΠΈΡ€ΡƒΠ΅ΠΌ Ρ‚ΠΎΠΊΠ΅Π½ + let token = extract_and_validate_token(&req)?; // Π—Π°Ρ‚Π΅ΠΌ ΠΈΠ·Π²Π»Π΅ΠΊΠ°Π΅ΠΌ user_id let user_id = extract_user_id_from_token(token).map_err(|e| { diff --git a/src/handlers/user.rs b/src/handlers/user.rs index 6eb0a6a..23a758e 100644 --- a/src/handlers/user.rs +++ b/src/handlers/user.rs @@ -3,7 +3,8 @@ use log::{error, info, warn}; use serde::Serialize; 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)] pub struct UserWithQuotaResponse { @@ -24,36 +25,8 @@ pub async fn get_current_user_handler( req: HttpRequest, state: web::Data, ) -> Result { - // ИзвлСкаСм Ρ‚ΠΎΠΊΠ΅Π½ ΠΈΠ· Π·Π°Π³ΠΎΠ»ΠΎΠ²ΠΊΠ° Π°Π²Ρ‚ΠΎΡ€ΠΈΠ·Π°Ρ†ΠΈΠΈ - let 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", - )); - } + // ИзвлСкаСм ΠΈ Π²Π°Π»ΠΈΠ΄ΠΈΡ€ΡƒΠ΅ΠΌ Ρ‚ΠΎΠΊΠ΅Π½ + let token = extract_and_validate_token(&req)?; info!("Getting user info for valid token"); diff --git a/src/main.rs b/src/main.rs index f9c7bf6..1d05079 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,19 +3,21 @@ mod auth; mod handlers; mod lookup; mod s3_utils; +mod security; mod thumbnail; use actix_cors::Cors; use actix_web::{ App, HttpServer, - http::header::{self, HeaderName}, - middleware::Logger, + http::header, + middleware::{Logger, DefaultHeaders}, web, }; use app_state::AppState; +use security::{SecurityConfig, security_middleware}; use handlers::universal_handler; -use log::warn; +use log::{warn, info}; use std::env; 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 || { - // Настройка CORS middleware + // Настройка CORS middleware - ΠΎΠ³Ρ€Π°Π½ΠΈΡ‡ΠΈΠ²Π°Π΅ΠΌ Π² ΠΏΡ€ΠΎΠ΄Π°ΠΊΡˆΠ΅Π½Π΅ let cors = Cors::default() - .allow_any_origin() // TODO: ΠΎΠ³Ρ€Π°Π½ΠΈΡ‡ΠΈΡ‚ΡŒ ΠΊΠΎΠ½ΠΊΡ€Π΅Ρ‚Π½Ρ‹ΠΌΠΈ Π΄ΠΎΠΌΠ΅Π½Π°ΠΌΠΈ Π² ΠΏΡ€ΠΎΠ΄Π°ΠΊΡˆΠ΅Π½Π΅ - .allowed_methods(vec!["GET", "POST", "PUT", "DELETE", "OPTIONS"]) + .allowed_origin("https://discours.io") + .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![ - header::DNT, - header::USER_AGENT, - HeaderName::from_static("x-requested-with"), - header::IF_MODIFIED_SINCE, - header::CACHE_CONTROL, header::CONTENT_TYPE, - header::RANGE, 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() - .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_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(Logger::default()) .default_service(web::to(universal_handler)) diff --git a/src/security.rs b/src/security.rs new file mode 100644 index 0000000..3296c9d --- /dev/null +++ b/src/security.rs @@ -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, +} + +/// ΠœΠ΅Π½Π΅Π΄ΠΆΠ΅Ρ€ бСзопасности +pub struct SecurityManager { + pub config: SecurityConfig, + redis: MultiplexedConnection, + // Π›ΠΎΠΊΠ°Π»ΡŒΠ½Ρ‹ΠΉ кэш для быстрых ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΎΠΊ + local_cache: Arc>>, +} + +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 = 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::(&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>", " 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, +) -> Result, 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) +}