diff options
| author | salmonik <salmonik@tuta.io> | 2026-07-29 02:24:41 +0200 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-07-29 02:24:41 +0200 |
| commit | c654a6d17eb58adbb15b9c47ab32bcc3a1666307 (patch) | |
| tree | 545544bf7d805254415da787fad1cdc711767833 /src/mods.rs | |
| parent | Add per-instance mod management with client-side detection (diff) | |
| download | minecraft-ui-c654a6d17eb58adbb15b9c47ab32bcc3a1666307.tar.gz minecraft-ui-c654a6d17eb58adbb15b9c47ab32bcc3a1666307.zip | |
Security audit fixes: patch 16 vulnerabilities across 7 source files
CRITICAL:
- Instance name path traversal (AP-001): sanitize::sanitize_instance_name()
strips .., /, \, \0, spaces, special chars at all 12 join call sites
- Zip-slip protection in world import: starts_with() guard after enclosed_name()
HIGH:
- RCON command injection: validate_username() rejects non-MC usernames
across all 6 player action macros (whitelist, op, ban)
- Level-seed properties injection: sanitize_seed() strips \n \r = : \0
- CSRF protection: SameSite=Lax -> SameSite=Strict on session cookie
MEDIUM:
- Session TTL: server-side 24h expiry via strftime check in require_auth
- Rate limiting: in-memory 10-ip/5min sliding window on login
- Background task stale is_active: all queries use concrete WHERE id=?1
- Mod validation DoS: 500MB cap, 10k class scan limit, 10k CP entry cap
LOW:
- Logout auth guard: moved into protected route group + CurrentUser param
- Mod orphan race: DB insert before file write
- Backup orphan race: DB insert before ZIP creation
- Template fix: broken Askama == comparisons in edit.html
All fixes compile clean with zero warnings. 10 unit tests pass.
Diffstat (limited to 'src/mods.rs')
| -rw-r--r-- | src/mods.rs | 31 |
1 files changed, 30 insertions, 1 deletions
diff --git a/src/mods.rs b/src/mods.rs index a3f782b..10799f0 100644 --- a/src/mods.rs +++ b/src/mods.rs | |||
| @@ -43,6 +43,9 @@ pub struct ValidationResult { | |||
| 43 | pub side: ModSide, | 43 | pub side: ModSide, |
| 44 | } | 44 | } |
| 45 | 45 | ||
| 46 | /// Maximum size for a single mod JAR file (500 MB). | ||
| 47 | const MAX_MOD_SIZE_BYTES: usize = 500 * 1024 * 1024; | ||
| 48 | |||
| 46 | /// Validate a mod JAR (raw bytes, filename for error messages). | 49 | /// Validate a mod JAR (raw bytes, filename for error messages). |
| 47 | /// | 50 | /// |
| 48 | /// Returns a `ValidationResult` indicating whether the mod is safe to install. | 51 | /// Returns a `ValidationResult` indicating whether the mod is safe to install. |
| @@ -56,6 +59,19 @@ pub struct ValidationResult { | |||
| 56 | /// (`net/minecraft/block/Block`, `net/minecraft/item/Item`, …) without ever | 59 | /// (`net/minecraft/block/Block`, `net/minecraft/item/Item`, …) without ever |
| 57 | /// referencing `net/minecraft/server/…` directly. | 60 | /// referencing `net/minecraft/server/…` directly. |
| 58 | pub fn validate_mod(data: &[u8], filename: &str) -> ValidationResult { | 61 | pub fn validate_mod(data: &[u8], filename: &str) -> ValidationResult { |
| 62 | // Reject oversized files early to prevent DoS | ||
| 63 | if data.len() > MAX_MOD_SIZE_BYTES { | ||
| 64 | return ValidationResult { | ||
| 65 | accepted: false, | ||
| 66 | rejection_reason: Some(format!( | ||
| 67 | "File too large: {} bytes (maximum is {} MB)", | ||
| 68 | data.len(), | ||
| 69 | MAX_MOD_SIZE_BYTES / (1024 * 1024) | ||
| 70 | )), | ||
| 71 | display_name: None, | ||
| 72 | side: ModSide::Unknown, | ||
| 73 | }; | ||
| 74 | } | ||
| 59 | // Phase 1: metadata check | 75 | // Phase 1: metadata check |
| 60 | let (meta_side, meta_display_name) = read_metadata(data); | 76 | let (meta_side, meta_display_name) = read_metadata(data); |
| 61 | 77 | ||
| @@ -201,6 +217,9 @@ enum ScanResult { | |||
| 201 | NoClearSide, | 217 | NoClearSide, |
| 202 | } | 218 | } |
| 203 | 219 | ||
| 220 | /// Maximum number of class files to scan inside a single JAR (prevents DoS). | ||
| 221 | const MAX_CLASSES_TO_SCAN: usize = 10_000; | ||
| 222 | |||
| 204 | /// Walk all `.class` entries in the JAR and look for client vs server references. | 223 | /// Walk all `.class` entries in the JAR and look for client vs server references. |
| 205 | /// | 224 | /// |
| 206 | /// Two independent signal layers: | 225 | /// Two independent signal layers: |
| @@ -222,8 +241,13 @@ fn scan_jar_for_side(data: &[u8]) -> ScanResult { | |||
| 222 | 241 | ||
| 223 | let mut has_server_content = false; | 242 | let mut has_server_content = false; |
| 224 | let mut has_client_api = false; | 243 | let mut has_client_api = false; |
| 244 | let mut scanned_count = 0_usize; | ||
| 225 | 245 | ||
| 226 | for i in 0..archive.len() { | 246 | for i in 0..archive.len() { |
| 247 | if scanned_count >= MAX_CLASSES_TO_SCAN { | ||
| 248 | tracing::warn!("Reached max class scan limit ({MAX_CLASSES_TO_SCAN}), stopping"); | ||
| 249 | break; | ||
| 250 | } | ||
| 227 | let mut file = match archive.by_index(i) { | 251 | let mut file = match archive.by_index(i) { |
| 228 | Ok(f) => f, | 252 | Ok(f) => f, |
| 229 | Err(_) => continue, | 253 | Err(_) => continue, |
| @@ -239,6 +263,8 @@ fn scan_jar_for_side(data: &[u8]) -> ScanResult { | |||
| 239 | continue; | 263 | continue; |
| 240 | } | 264 | } |
| 241 | 265 | ||
| 266 | scanned_count += 1; | ||
| 267 | |||
| 242 | let result = scan_single_class(&bytes); | 268 | let result = scan_single_class(&bytes); |
| 243 | match result { | 269 | match result { |
| 244 | ClassScan::HasServerContent => has_server_content = true, | 270 | ClassScan::HasServerContent => has_server_content = true, |
| @@ -318,6 +344,9 @@ const CLIENT_API_PREFIXES: &[&[u8]] = &[ | |||
| 318 | /// We parse the constant pool minimally to find `CONSTANT_Utf8` entries, | 344 | /// We parse the constant pool minimally to find `CONSTANT_Utf8` entries, |
| 319 | /// which store the actual string literals (class names, method signatures, etc.) | 345 | /// which store the actual string literals (class names, method signatures, etc.) |
| 320 | /// used by the bytecode. This avoids false positives from raw byte scanning. | 346 | /// used by the bytecode. This avoids false positives from raw byte scanning. |
| 347 | /// Maximum constant pool entries to scan in a single class file (prevents crafted-file DoS). | ||
| 348 | const MAX_CP_ENTRIES: usize = 10_000; | ||
| 349 | |||
| 321 | fn scan_single_class(data: &[u8]) -> ClassScan { | 350 | fn scan_single_class(data: &[u8]) -> ClassScan { |
| 322 | if data.len() < 10 { | 351 | if data.len() < 10 { |
| 323 | return ClassScan::Neither; | 352 | return ClassScan::Neither; |
| @@ -327,7 +356,7 @@ fn scan_single_class(data: &[u8]) -> ClassScan { | |||
| 327 | } | 356 | } |
| 328 | 357 | ||
| 329 | let cp_count = read_u16_be(&data[8..10]) as usize; | 358 | let cp_count = read_u16_be(&data[8..10]) as usize; |
| 330 | if cp_count < 1 { | 359 | if !(1..=MAX_CP_ENTRIES).contains(&cp_count) { |
| 331 | return ClassScan::Neither; | 360 | return ClassScan::Neither; |
| 332 | } | 361 | } |
| 333 | 362 | ||
