From c654a6d17eb58adbb15b9c47ab32bcc3a1666307 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 29 Jul 2026 02:24:41 +0200 Subject: 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. --- src/mods.rs | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) (limited to 'src/mods.rs') 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 { pub side: ModSide, } +/// Maximum size for a single mod JAR file (500 MB). +const MAX_MOD_SIZE_BYTES: usize = 500 * 1024 * 1024; + /// Validate a mod JAR (raw bytes, filename for error messages). /// /// Returns a `ValidationResult` indicating whether the mod is safe to install. @@ -56,6 +59,19 @@ pub struct ValidationResult { /// (`net/minecraft/block/Block`, `net/minecraft/item/Item`, …) without ever /// referencing `net/minecraft/server/…` directly. pub fn validate_mod(data: &[u8], filename: &str) -> ValidationResult { + // Reject oversized files early to prevent DoS + if data.len() > MAX_MOD_SIZE_BYTES { + return ValidationResult { + accepted: false, + rejection_reason: Some(format!( + "File too large: {} bytes (maximum is {} MB)", + data.len(), + MAX_MOD_SIZE_BYTES / (1024 * 1024) + )), + display_name: None, + side: ModSide::Unknown, + }; + } // Phase 1: metadata check let (meta_side, meta_display_name) = read_metadata(data); @@ -201,6 +217,9 @@ enum ScanResult { NoClearSide, } +/// Maximum number of class files to scan inside a single JAR (prevents DoS). +const MAX_CLASSES_TO_SCAN: usize = 10_000; + /// Walk all `.class` entries in the JAR and look for client vs server references. /// /// Two independent signal layers: @@ -222,8 +241,13 @@ fn scan_jar_for_side(data: &[u8]) -> ScanResult { let mut has_server_content = false; let mut has_client_api = false; + let mut scanned_count = 0_usize; for i in 0..archive.len() { + if scanned_count >= MAX_CLASSES_TO_SCAN { + tracing::warn!("Reached max class scan limit ({MAX_CLASSES_TO_SCAN}), stopping"); + break; + } let mut file = match archive.by_index(i) { Ok(f) => f, Err(_) => continue, @@ -239,6 +263,8 @@ fn scan_jar_for_side(data: &[u8]) -> ScanResult { continue; } + scanned_count += 1; + let result = scan_single_class(&bytes); match result { ClassScan::HasServerContent => has_server_content = true, @@ -318,6 +344,9 @@ const CLIENT_API_PREFIXES: &[&[u8]] = &[ /// We parse the constant pool minimally to find `CONSTANT_Utf8` entries, /// which store the actual string literals (class names, method signatures, etc.) /// used by the bytecode. This avoids false positives from raw byte scanning. +/// Maximum constant pool entries to scan in a single class file (prevents crafted-file DoS). +const MAX_CP_ENTRIES: usize = 10_000; + fn scan_single_class(data: &[u8]) -> ClassScan { if data.len() < 10 { return ClassScan::Neither; @@ -327,7 +356,7 @@ fn scan_single_class(data: &[u8]) -> ClassScan { } let cp_count = read_u16_be(&data[8..10]) as usize; - if cp_count < 1 { + if !(1..=MAX_CP_ENTRIES).contains(&cp_count) { return ClassScan::Neither; } -- cgit v1.2.3