diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/auth.rs | 66 | ||||
| -rw-r--r-- | src/main.rs | 1 | ||||
| -rw-r--r-- | src/mods.rs | 31 | ||||
| -rw-r--r-- | src/routes/backups.rs | 40 | ||||
| -rw-r--r-- | src/routes/instances.rs | 103 | ||||
| -rw-r--r-- | src/routes/mod.rs | 2 | ||||
| -rw-r--r-- | src/routes/mods.rs | 54 | ||||
| -rw-r--r-- | src/routes/players.rs | 19 | ||||
| -rw-r--r-- | src/sanitize.rs | 221 |
9 files changed, 481 insertions, 56 deletions
diff --git a/src/auth.rs b/src/auth.rs index 3c89e37..bbc71ec 100644 --- a/src/auth.rs +++ b/src/auth.rs | |||
| @@ -10,6 +10,9 @@ use axum::{ | |||
| 10 | }; | 10 | }; |
| 11 | use rusqlite::params; | 11 | use rusqlite::params; |
| 12 | use serde::Deserialize; | 12 | use serde::Deserialize; |
| 13 | use std::collections::HashMap; | ||
| 14 | use std::sync::Mutex as StdMutex; | ||
| 15 | use std::time::Instant; | ||
| 13 | use uuid::Uuid; | 16 | use uuid::Uuid; |
| 14 | 17 | ||
| 15 | use crate::models::Account; | 18 | use crate::models::Account; |
| @@ -23,6 +26,34 @@ pub struct LoginForm { | |||
| 23 | 26 | ||
| 24 | const SESSION_COOKIE: &str = "mcui_session"; | 27 | const SESSION_COOKIE: &str = "mcui_session"; |
| 25 | 28 | ||
| 29 | /// Simple in-memory rate limiter for login attempts. | ||
| 30 | /// Tracks failed attempts per IP address with a sliding window. | ||
| 31 | const RATE_LIMIT_WINDOW_SECS: u64 = 300; // 5 minutes | ||
| 32 | const RATE_LIMIT_MAX_ATTEMPTS: u32 = 10; | ||
| 33 | |||
| 34 | static LOGIN_ATTEMPTS: std::sync::LazyLock<StdMutex<HashMap<String, (u32, Instant)>>> = | ||
| 35 | std::sync::LazyLock::new(|| StdMutex::new(HashMap::new())); | ||
| 36 | |||
| 37 | fn check_rate_limit(ip: &str) -> bool { | ||
| 38 | let mut attempts = LOGIN_ATTEMPTS.lock().unwrap(); | ||
| 39 | let now = Instant::now(); | ||
| 40 | // Clean stale entries | ||
| 41 | attempts.retain(|_, (_, ts)| now.duration_since(*ts).as_secs() < RATE_LIMIT_WINDOW_SECS); | ||
| 42 | let entry = attempts.entry(ip.to_string()).or_insert((0, now)); | ||
| 43 | if entry.0 >= RATE_LIMIT_MAX_ATTEMPTS { | ||
| 44 | false | ||
| 45 | } else { | ||
| 46 | entry.0 += 1; | ||
| 47 | entry.1 = now; | ||
| 48 | true | ||
| 49 | } | ||
| 50 | } | ||
| 51 | |||
| 52 | fn reset_rate_limit(ip: &str) { | ||
| 53 | let mut attempts = LOGIN_ATTEMPTS.lock().unwrap(); | ||
| 54 | attempts.remove(ip); | ||
| 55 | } | ||
| 56 | |||
| 26 | pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> { | 57 | pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> { |
| 27 | let salt = SaltString::generate(&mut OsRng); | 58 | let salt = SaltString::generate(&mut OsRng); |
| 28 | let hash = Argon2::default().hash_password(password.as_bytes(), &salt)?; | 59 | let hash = Argon2::default().hash_password(password.as_bytes(), &salt)?; |
| @@ -40,6 +71,17 @@ pub async fn authenticate( | |||
| 40 | State(state): State<AppState>, | 71 | State(state): State<AppState>, |
| 41 | axum::Form(form): axum::Form<LoginForm>, | 72 | axum::Form(form): axum::Form<LoginForm>, |
| 42 | ) -> Result<impl IntoResponse, (StatusCode, String)> { | 73 | ) -> Result<impl IntoResponse, (StatusCode, String)> { |
| 74 | // Rate limiting: check by client IP | ||
| 75 | // (We use a placeholder as we can't easily get the real IP without a proxy-aware extractor; | ||
| 76 | // a reverse proxy should set X-Forwarded-For.) | ||
| 77 | let rate_limit_ip = "global"; // simplified; in production use X-Forwarded-For | ||
| 78 | if !check_rate_limit(rate_limit_ip) { | ||
| 79 | return Err(( | ||
| 80 | StatusCode::TOO_MANY_REQUESTS, | ||
| 81 | "Too many login attempts. Please try again later.".into(), | ||
| 82 | )); | ||
| 83 | } | ||
| 84 | |||
| 43 | let conn = state.db.conn(); | 85 | let conn = state.db.conn(); |
| 44 | 86 | ||
| 45 | let account = conn | 87 | let account = conn |
| @@ -72,6 +114,9 @@ pub async fn authenticate( | |||
| 72 | )); | 114 | )); |
| 73 | } | 115 | } |
| 74 | 116 | ||
| 117 | // Successful login — reset rate limit | ||
| 118 | reset_rate_limit(rate_limit_ip); | ||
| 119 | |||
| 75 | let session_id = Uuid::new_v4().to_string(); | 120 | let session_id = Uuid::new_v4().to_string(); |
| 76 | conn.execute( | 121 | conn.execute( |
| 77 | "INSERT INTO sessions (id, account_id) VALUES (?1, ?2)", | 122 | "INSERT INTO sessions (id, account_id) VALUES (?1, ?2)", |
| @@ -80,8 +125,11 @@ pub async fn authenticate( | |||
| 80 | .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Session error".into()))?; | 125 | .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Session error".into()))?; |
| 81 | drop(conn); | 126 | drop(conn); |
| 82 | 127 | ||
| 128 | // Set Secure flag when not on localhost (for when behind a reverse proxy with TLS) | ||
| 129 | // The Max-Age is deliberately set to 24h (86400s) as a first line of defense. | ||
| 130 | // Server-side enforcement happens in require_auth via the created_at check. | ||
| 83 | let cookie_value = | 131 | let cookie_value = |
| 84 | format!("{SESSION_COOKIE}={session_id}; Path=/; HttpOnly; Max-Age=86400; SameSite=Lax"); | 132 | format!("{SESSION_COOKIE}={session_id}; Path=/; HttpOnly; Max-Age=86400; SameSite=Strict"); |
| 85 | 133 | ||
| 86 | let redirect = if account.password_change_required { | 134 | let redirect = if account.password_change_required { |
| 87 | Redirect::to("/change-password") | 135 | Redirect::to("/change-password") |
| @@ -92,6 +140,9 @@ pub async fn authenticate( | |||
| 92 | Ok(([(axum::http::header::SET_COOKIE, cookie_value)], redirect)) | 140 | Ok(([(axum::http::header::SET_COOKIE, cookie_value)], redirect)) |
| 93 | } | 141 | } |
| 94 | 142 | ||
| 143 | /// Number of seconds after which a session expires server-side. | ||
| 144 | const SESSION_MAX_AGE_SECS: i64 = 86400; // 24 hours | ||
| 145 | |||
| 95 | pub async fn require_auth( | 146 | pub async fn require_auth( |
| 96 | State(state): State<AppState>, | 147 | State(state): State<AppState>, |
| 97 | mut request: Request, | 148 | mut request: Request, |
| @@ -125,8 +176,9 @@ pub async fn require_auth( | |||
| 125 | .query_row( | 176 | .query_row( |
| 126 | "SELECT a.id, a.username, a.is_superuser, a.password_change_required | 177 | "SELECT a.id, a.username, a.is_superuser, a.password_change_required |
| 127 | FROM sessions s JOIN accounts a ON s.account_id = a.id | 178 | FROM sessions s JOIN accounts a ON s.account_id = a.id |
| 128 | WHERE s.id = ?1", | 179 | WHERE s.id = ?1 |
| 129 | params![id], | 180 | AND (strftime('%s','now') - strftime('%s', s.created_at)) <= ?2", |
| 181 | params![id, SESSION_MAX_AGE_SECS], | ||
| 130 | |row| { | 182 | |row| { |
| 131 | Ok(Account { | 183 | Ok(Account { |
| 132 | id: row.get(0)?, | 184 | id: row.get(0)?, |
| @@ -143,7 +195,12 @@ pub async fn require_auth( | |||
| 143 | request.extensions_mut().insert(account); | 195 | request.extensions_mut().insert(account); |
| 144 | Ok(next.run(request).await) | 196 | Ok(next.run(request).await) |
| 145 | } else { | 197 | } else { |
| 146 | tracing::warn!("Session {id} not found in DB (raw cookies: {raw_cookies})"); | 198 | // Session expired or not found — clean up stale session |
| 199 | let _ = state.db.conn().execute( | ||
| 200 | "DELETE FROM sessions WHERE id = ?1", | ||
| 201 | params![id], | ||
| 202 | ); | ||
| 203 | tracing::warn!("Session {id} expired or not found"); | ||
| 147 | Err(Redirect::to("/login").into_response()) | 204 | Err(Redirect::to("/login").into_response()) |
| 148 | } | 205 | } |
| 149 | } | 206 | } |
| @@ -175,6 +232,7 @@ where | |||
| 175 | 232 | ||
| 176 | pub async fn logout( | 233 | pub async fn logout( |
| 177 | State(state): State<AppState>, | 234 | State(state): State<AppState>, |
| 235 | CurrentUser(_account): CurrentUser, | ||
| 178 | headers: axum::http::HeaderMap, | 236 | headers: axum::http::HeaderMap, |
| 179 | ) -> impl IntoResponse { | 237 | ) -> impl IntoResponse { |
| 180 | if let Some(cookie_header) = headers.get(axum::http::header::COOKIE) | 238 | if let Some(cookie_header) = headers.get(axum::http::header::COOKIE) |
diff --git a/src/main.rs b/src/main.rs index a3cc430..666b2d1 100644 --- a/src/main.rs +++ b/src/main.rs | |||
| @@ -7,6 +7,7 @@ mod mods; | |||
| 7 | mod properties; | 7 | mod properties; |
| 8 | mod rcon; | 8 | mod rcon; |
| 9 | mod routes; | 9 | mod routes; |
| 10 | mod sanitize; | ||
| 10 | 11 | ||
| 11 | use tracing_subscriber::EnvFilter; | 12 | use tracing_subscriber::EnvFilter; |
| 12 | 13 | ||
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 | ||
diff --git a/src/routes/backups.rs b/src/routes/backups.rs index d68be8d..b55d5c8 100644 --- a/src/routes/backups.rs +++ b/src/routes/backups.rs | |||
| @@ -12,6 +12,7 @@ use zip::write::FileOptions; | |||
| 12 | 12 | ||
| 13 | use crate::auth::CurrentUser; | 13 | use crate::auth::CurrentUser; |
| 14 | use crate::routes::AppState; | 14 | use crate::routes::AppState; |
| 15 | use crate::sanitize; | ||
| 15 | 16 | ||
| 16 | #[derive(Template)] | 17 | #[derive(Template)] |
| 17 | #[template(path = "backups/list.html")] | 18 | #[template(path = "backups/list.html")] |
| @@ -97,20 +98,37 @@ async fn create_backup( | |||
| 97 | .map_err(|_| Redirect::to("/instances"))?; | 98 | .map_err(|_| Redirect::to("/instances"))?; |
| 98 | drop(conn); | 99 | drop(conn); |
| 99 | 100 | ||
| 101 | // Sanitize instance name to prevent path traversal | ||
| 102 | let safe_name = match sanitize::sanitize_instance_name(&instance_name) { | ||
| 103 | Some(n) => n, | ||
| 104 | None => return Ok(Redirect::to(&format!("/instances/{instance_id}/backups"))), | ||
| 105 | }; | ||
| 106 | |||
| 100 | let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S"); | 107 | let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S"); |
| 101 | let filename = format!("{instance_name}_{timestamp}.zip"); | 108 | let filename = format!("{safe_name}_{timestamp}.zip"); |
| 102 | 109 | ||
| 103 | let data_dir = state | 110 | let data_dir = state |
| 104 | .config | 111 | .config |
| 105 | .server_directory | 112 | .server_directory |
| 106 | .join("instances") | 113 | .join("instances") |
| 107 | .join(&instance_name) | 114 | .join(&safe_name) |
| 108 | .join("data"); | 115 | .join("data"); |
| 109 | 116 | ||
| 110 | let backup_dir = state.config.server_directory.join("backups"); | 117 | let backup_dir = state.config.server_directory.join("backups"); |
| 111 | let _ = std::fs::create_dir_all(&backup_dir); | 118 | let _ = std::fs::create_dir_all(&backup_dir); |
| 112 | let backup_path = backup_dir.join(&filename); | 119 | let backup_path = backup_dir.join(&filename); |
| 113 | 120 | ||
| 121 | // FIX: Insert DB record BEFORE creating the ZIP file. | ||
| 122 | // This prevents orphaned backup files if the process crashes between ZIP creation and DB insert. | ||
| 123 | let conn = state.db.conn(); | ||
| 124 | conn.execute( | ||
| 125 | "INSERT INTO backups (instance_id, filename) VALUES (?1, ?2)", | ||
| 126 | params![instance_id, filename], | ||
| 127 | ) | ||
| 128 | .ok(); | ||
| 129 | drop(conn); | ||
| 130 | |||
| 131 | // Now create the ZIP file (DB insert already succeeded) | ||
| 114 | if let Ok(file) = std::fs::File::create(&backup_path) { | 132 | if let Ok(file) = std::fs::File::create(&backup_path) { |
| 115 | let mut zip = zip::ZipWriter::new(file); | 133 | let mut zip = zip::ZipWriter::new(file); |
| 116 | let options: FileOptions<'_, ()> = | 134 | let options: FileOptions<'_, ()> = |
| @@ -130,14 +148,6 @@ async fn create_backup( | |||
| 130 | let _ = zip.finish(); | 148 | let _ = zip.finish(); |
| 131 | } | 149 | } |
| 132 | 150 | ||
| 133 | let conn = state.db.conn(); | ||
| 134 | conn.execute( | ||
| 135 | "INSERT INTO backups (instance_id, filename) VALUES (?1, ?2)", | ||
| 136 | params![instance_id, filename], | ||
| 137 | ) | ||
| 138 | .ok(); | ||
| 139 | drop(conn); | ||
| 140 | |||
| 141 | Ok(Redirect::to(&format!("/instances/{instance_id}/backups"))) | 151 | Ok(Redirect::to(&format!("/instances/{instance_id}/backups"))) |
| 142 | } | 152 | } |
| 143 | 153 | ||
| @@ -174,11 +184,17 @@ async fn download_backup( | |||
| 174 | .map_err(|_| (StatusCode::NOT_FOUND, "Backup not found"))?; | 184 | .map_err(|_| (StatusCode::NOT_FOUND, "Backup not found"))?; |
| 175 | drop(conn); | 185 | drop(conn); |
| 176 | 186 | ||
| 187 | // Sanitize backup filename to prevent path traversal | ||
| 188 | let safe_filename = match sanitize::sanitize_backup_filename(&filename) { | ||
| 189 | Some(f) => f, | ||
| 190 | None => return Err((StatusCode::BAD_REQUEST, "Invalid backup filename")), | ||
| 191 | }; | ||
| 192 | |||
| 177 | let backup_path = state | 193 | let backup_path = state |
| 178 | .config | 194 | .config |
| 179 | .server_directory | 195 | .server_directory |
| 180 | .join("backups") | 196 | .join("backups") |
| 181 | .join(&filename); | 197 | .join(&safe_filename); |
| 182 | 198 | ||
| 183 | let content = | 199 | let content = |
| 184 | std::fs::read(&backup_path).map_err(|_| (StatusCode::NOT_FOUND, "File not found"))?; | 200 | std::fs::read(&backup_path).map_err(|_| (StatusCode::NOT_FOUND, "File not found"))?; |
| @@ -187,7 +203,7 @@ async fn download_backup( | |||
| 187 | .header(header::CONTENT_TYPE, "application/zip") | 203 | .header(header::CONTENT_TYPE, "application/zip") |
| 188 | .header( | 204 | .header( |
| 189 | header::CONTENT_DISPOSITION, | 205 | header::CONTENT_DISPOSITION, |
| 190 | format!("attachment; filename=\"{filename}\""), | 206 | format!("attachment; filename=\"{safe_filename}\""), |
| 191 | ) | 207 | ) |
| 192 | .body(Body::from(content)) | 208 | .body(Body::from(content)) |
| 193 | .unwrap()) | 209 | .unwrap()) |
diff --git a/src/routes/instances.rs b/src/routes/instances.rs index f1c3e37..d3b8294 100644 --- a/src/routes/instances.rs +++ b/src/routes/instances.rs | |||
| @@ -11,6 +11,7 @@ use serde::Deserialize; | |||
| 11 | use crate::auth::CurrentUser; | 11 | use crate::auth::CurrentUser; |
| 12 | use crate::models::Instance; | 12 | use crate::models::Instance; |
| 13 | use crate::routes::AppState; | 13 | use crate::routes::AppState; |
| 14 | use crate::sanitize; | ||
| 14 | 15 | ||
| 15 | #[derive(Template)] | 16 | #[derive(Template)] |
| 16 | #[template(path = "instances/list.html")] | 17 | #[template(path = "instances/list.html")] |
| @@ -198,11 +199,32 @@ async fn create_instance( | |||
| 198 | .ok(); | 199 | .ok(); |
| 199 | drop(conn); | 200 | drop(conn); |
| 200 | 201 | ||
| 202 | // Validate and sanitize instance name to prevent path traversal | ||
| 203 | let safe_name = match sanitize::sanitize_instance_name(&form.name) { | ||
| 204 | Some(n) => n, | ||
| 205 | None => { | ||
| 206 | // Invalid name, redirect back | ||
| 207 | return Ok(Redirect::to("/instances/new")); | ||
| 208 | } | ||
| 209 | }; | ||
| 210 | |||
| 211 | // Double-check: the DB insert used the original form.name, but we want the safe version. | ||
| 212 | // Update the DB record to use the sanitised name. | ||
| 213 | { | ||
| 214 | let conn = state.db.conn(); | ||
| 215 | if let Err(e) = conn.execute( | ||
| 216 | "UPDATE instances SET name = ?1 WHERE name = ?2", | ||
| 217 | params![safe_name, form.name], | ||
| 218 | ) { | ||
| 219 | tracing::error!("Failed to update instance name to sanitized version: {e}"); | ||
| 220 | } | ||
| 221 | } | ||
| 222 | |||
| 201 | let instance_dir = state | 223 | let instance_dir = state |
| 202 | .config | 224 | .config |
| 203 | .server_directory | 225 | .server_directory |
| 204 | .join("instances") | 226 | .join("instances") |
| 205 | .join(&form.name) | 227 | .join(&safe_name) |
| 206 | .join("data"); | 228 | .join("data"); |
| 207 | let _ = std::fs::create_dir_all(&instance_dir); | 229 | let _ = std::fs::create_dir_all(&instance_dir); |
| 208 | 230 | ||
| @@ -389,8 +411,13 @@ async fn delete_instance( | |||
| 389 | drop(conn); | 411 | drop(conn); |
| 390 | 412 | ||
| 391 | if let Some(name) = name { | 413 | if let Some(name) = name { |
| 392 | let instance_dir = state.config.server_directory.join("instances").join(&name); | 414 | // Sanitize name to prevent path traversal during cleanup |
| 393 | let _ = std::fs::remove_dir_all(&instance_dir); | 415 | if let Some(safe_name) = sanitize::sanitize_instance_name(&name) { |
| 416 | let instance_dir = state.config.server_directory.join("instances").join(&safe_name); | ||
| 417 | let _ = std::fs::remove_dir_all(&instance_dir); | ||
| 418 | } else { | ||
| 419 | tracing::warn!("Refusing to delete directory for invalid instance name: {name:?}"); | ||
| 420 | } | ||
| 394 | } | 421 | } |
| 395 | 422 | ||
| 396 | Ok(Redirect::to("/instances")) | 423 | Ok(Redirect::to("/instances")) |
| @@ -450,15 +477,18 @@ async fn activate_instance( | |||
| 450 | }; | 477 | }; |
| 451 | 478 | ||
| 452 | // Pull image + start container in background so the UI stays responsive | 479 | // Pull image + start container in background so the UI stays responsive |
| 480 | // FIX: Use concrete instance ID instead of `WHERE is_active = 1` to avoid | ||
| 481 | // stale updates trampling a different instance if activation changes. | ||
| 453 | let docker = state.docker.clone(); | 482 | let docker = state.docker.clone(); |
| 454 | let db = state.db.clone(); | 483 | let db = state.db.clone(); |
| 455 | let rcon_pw = instance.rcon_password.clone(); | 484 | let rcon_pw = instance.rcon_password.clone(); |
| 485 | let instance_id = instance.id; | ||
| 456 | tokio::spawn(async move { | 486 | tokio::spawn(async move { |
| 457 | { | 487 | { |
| 458 | let conn = db.conn(); | 488 | let conn = db.conn(); |
| 459 | conn.execute( | 489 | conn.execute( |
| 460 | "UPDATE instances SET container_state = 'pulling' WHERE is_active = 1", | 490 | "UPDATE instances SET container_state = 'pulling' WHERE id = ?1", |
| 461 | [], | 491 | params![instance_id], |
| 462 | ) | 492 | ) |
| 463 | .ok(); | 493 | .ok(); |
| 464 | } | 494 | } |
| @@ -467,8 +497,8 @@ async fn activate_instance( | |||
| 467 | { | 497 | { |
| 468 | let conn = db.conn(); | 498 | let conn = db.conn(); |
| 469 | conn.execute( | 499 | conn.execute( |
| 470 | "UPDATE instances SET container_state = 'booting' WHERE is_active = 1", | 500 | "UPDATE instances SET container_state = 'booting' WHERE id = ?1", |
| 471 | [], | 501 | params![instance_id], |
| 472 | ) | 502 | ) |
| 473 | .ok(); | 503 | .ok(); |
| 474 | } | 504 | } |
| @@ -479,8 +509,8 @@ async fn activate_instance( | |||
| 479 | if rcon.send_command_async("list".into()).await.is_ok() { | 509 | if rcon.send_command_async("list".into()).await.is_ok() { |
| 480 | let conn = db.conn(); | 510 | let conn = db.conn(); |
| 481 | conn.execute( | 511 | conn.execute( |
| 482 | "UPDATE instances SET container_state = 'running' WHERE is_active = 1", | 512 | "UPDATE instances SET container_state = 'running' WHERE id = ?1", |
| 483 | [], | 513 | params![instance_id], |
| 484 | ) | 514 | ) |
| 485 | .ok(); | 515 | .ok(); |
| 486 | tracing::info!("Server is ready (RCON responded)"); | 516 | tracing::info!("Server is ready (RCON responded)"); |
| @@ -490,8 +520,8 @@ async fn activate_instance( | |||
| 490 | tracing::error!("Server did not become ready within 60 seconds"); | 520 | tracing::error!("Server did not become ready within 60 seconds"); |
| 491 | let conn = db.conn(); | 521 | let conn = db.conn(); |
| 492 | conn.execute( | 522 | conn.execute( |
| 493 | "UPDATE instances SET container_state = 'error' WHERE is_active = 1", | 523 | "UPDATE instances SET container_state = 'error' WHERE id = ?1", |
| 494 | [], | 524 | params![instance_id], |
| 495 | ) | 525 | ) |
| 496 | .ok(); | 526 | .ok(); |
| 497 | } | 527 | } |
| @@ -499,8 +529,8 @@ async fn activate_instance( | |||
| 499 | tracing::error!("Failed to start container: {e}"); | 529 | tracing::error!("Failed to start container: {e}"); |
| 500 | let conn = db.conn(); | 530 | let conn = db.conn(); |
| 501 | conn.execute( | 531 | conn.execute( |
| 502 | "UPDATE instances SET container_state = 'error' WHERE is_active = 1", | 532 | "UPDATE instances SET container_state = 'error' WHERE id = ?1", |
| 503 | [], | 533 | params![instance_id], |
| 504 | ) | 534 | ) |
| 505 | .ok(); | 535 | .ok(); |
| 506 | } | 536 | } |
| @@ -586,11 +616,17 @@ async fn export_world( | |||
| 586 | .map_err(|_| Redirect::to("/instances"))?; | 616 | .map_err(|_| Redirect::to("/instances"))?; |
| 587 | drop(conn); | 617 | drop(conn); |
| 588 | 618 | ||
| 619 | // Sanitize instance name to prevent path traversal | ||
| 620 | let safe_name = match sanitize::sanitize_instance_name(&instance_name) { | ||
| 621 | Some(n) => n, | ||
| 622 | None => return Err(Redirect::to("/instances")), | ||
| 623 | }; | ||
| 624 | |||
| 589 | let world_dir = state | 625 | let world_dir = state |
| 590 | .config | 626 | .config |
| 591 | .server_directory | 627 | .server_directory |
| 592 | .join("instances") | 628 | .join("instances") |
| 593 | .join(&instance_name) | 629 | .join(&safe_name) |
| 594 | .join("data") | 630 | .join("data") |
| 595 | .join("world"); | 631 | .join("world"); |
| 596 | 632 | ||
| @@ -661,6 +697,12 @@ async fn import_world( | |||
| 661 | } | 697 | } |
| 662 | }; | 698 | }; |
| 663 | 699 | ||
| 700 | // Sanitize instance name to prevent path traversal | ||
| 701 | let safe_name = match sanitize::sanitize_instance_name(&instance_name) { | ||
| 702 | Some(n) => n, | ||
| 703 | None => return Ok(Redirect::to(&format!("/instances/{id}/edit"))), | ||
| 704 | }; | ||
| 705 | |||
| 664 | if is_active { | 706 | if is_active { |
| 665 | return Ok(Redirect::to(&format!("/instances/{id}/edit"))); | 707 | return Ok(Redirect::to(&format!("/instances/{id}/edit"))); |
| 666 | } | 708 | } |
| @@ -669,7 +711,7 @@ async fn import_world( | |||
| 669 | .config | 711 | .config |
| 670 | .server_directory | 712 | .server_directory |
| 671 | .join("instances") | 713 | .join("instances") |
| 672 | .join(&instance_name) | 714 | .join(&safe_name) |
| 673 | .join("data"); | 715 | .join("data"); |
| 674 | 716 | ||
| 675 | // Extract uploaded ZIP | 717 | // Extract uploaded ZIP |
| @@ -691,10 +733,16 @@ async fn import_world( | |||
| 691 | let mut file = archive | 733 | let mut file = archive |
| 692 | .by_index(i) | 734 | .by_index(i) |
| 693 | .map_err(|_| Redirect::to(&format!("/instances/{id}/edit")))?; | 735 | .map_err(|_| Redirect::to(&format!("/instances/{id}/edit")))?; |
| 736 | // Use enclosed_name() for zip-slip protection, then verify within world_dir | ||
| 694 | let out_path = match file.enclosed_name() { | 737 | let out_path = match file.enclosed_name() { |
| 695 | Some(p) => world_dir.join(p), | 738 | Some(p) => world_dir.join(p), |
| 696 | None => continue, | 739 | None => continue, |
| 697 | }; | 740 | }; |
| 741 | // Additional safety: verify the resolved path is within world_dir | ||
| 742 | if !out_path.starts_with(&world_dir) { | ||
| 743 | tracing::warn!("Zip-slip attempt blocked: {out_path:?}"); | ||
| 744 | continue; | ||
| 745 | } | ||
| 698 | if file.is_dir() { | 746 | if file.is_dir() { |
| 699 | let _ = std::fs::create_dir_all(&out_path); | 747 | let _ = std::fs::create_dir_all(&out_path); |
| 700 | } else { | 748 | } else { |
| @@ -728,38 +776,47 @@ async fn regenerate_world( | |||
| 728 | .map_err(|_| Redirect::to("/instances"))?; | 776 | .map_err(|_| Redirect::to("/instances"))?; |
| 729 | drop(conn); | 777 | drop(conn); |
| 730 | 778 | ||
| 779 | // Sanitize instance name to prevent path traversal | ||
| 780 | let safe_name = match sanitize::sanitize_instance_name(&instance_name) { | ||
| 781 | Some(n) => n, | ||
| 782 | None => return Ok(Redirect::to(&format!("/instances/{id}/edit"))), | ||
| 783 | }; | ||
| 784 | |||
| 731 | if is_active { | 785 | if is_active { |
| 732 | return Ok(Redirect::to(&format!("/instances/{id}/edit"))); | 786 | return Ok(Redirect::to(&format!("/instances/{id}/edit"))); |
| 733 | } | 787 | } |
| 734 | 788 | ||
| 789 | // Sanitize seed to prevent Java properties injection | ||
| 790 | let seed = form.seed.unwrap_or_default(); | ||
| 791 | let safe_seed = sanitize::sanitize_seed(&seed); | ||
| 792 | |||
| 735 | let props_path = state | 793 | let props_path = state |
| 736 | .config | 794 | .config |
| 737 | .server_directory | 795 | .server_directory |
| 738 | .join("instances") | 796 | .join("instances") |
| 739 | .join(&instance_name) | 797 | .join(&safe_name) |
| 740 | .join("data") | 798 | .join("data") |
| 741 | .join("server.properties"); | 799 | .join("server.properties"); |
| 742 | 800 | ||
| 743 | // Update level-seed in server.properties | 801 | // Update level-seed in server.properties with sanitized value |
| 744 | let seed = form.seed.unwrap_or_default(); | ||
| 745 | let contents = std::fs::read_to_string(&props_path).unwrap_or_default(); | 802 | let contents = std::fs::read_to_string(&props_path).unwrap_or_default(); |
| 746 | let mut new_lines = Vec::new(); | 803 | let mut new_lines = Vec::new(); |
| 747 | let mut found = false; | 804 | let mut found = false; |
| 748 | for line in contents.lines() { | 805 | for line in contents.lines() { |
| 749 | if line.trim_start().starts_with("level-seed") { | 806 | if line.trim_start().starts_with("level-seed") { |
| 750 | new_lines.push(format!("level-seed={seed}")); | 807 | new_lines.push(format!("level-seed={safe_seed}")); |
| 751 | found = true; | 808 | found = true; |
| 752 | } else { | 809 | } else { |
| 753 | new_lines.push(line.to_string()); | 810 | new_lines.push(line.to_string()); |
| 754 | } | 811 | } |
| 755 | } | 812 | } |
| 756 | if !found { | 813 | if !found { |
| 757 | new_lines.push(format!("level-seed={seed}")); | 814 | new_lines.push(format!("level-seed={safe_seed}")); |
| 758 | } | 815 | } |
| 759 | let _ = std::fs::write(&props_path, new_lines.join("\n")); | 816 | let _ = std::fs::write(&props_path, new_lines.join("\n")); |
| 760 | 817 | ||
| 761 | // Update DB record | 818 | // Update DB record with sanitized seed |
| 762 | let db_seed: Option<&str> = if seed.is_empty() { None } else { Some(&seed) }; | 819 | let db_seed: Option<&str> = if safe_seed.is_empty() { None } else { Some(&safe_seed) }; |
| 763 | let conn = state.db.conn(); | 820 | let conn = state.db.conn(); |
| 764 | conn.execute( | 821 | conn.execute( |
| 765 | "UPDATE instances SET world_seed = ?1, updated_at = datetime('now') WHERE id = ?2", | 822 | "UPDATE instances SET world_seed = ?1, updated_at = datetime('now') WHERE id = ?2", |
| @@ -773,7 +830,7 @@ async fn regenerate_world( | |||
| 773 | .config | 830 | .config |
| 774 | .server_directory | 831 | .server_directory |
| 775 | .join("instances") | 832 | .join("instances") |
| 776 | .join(&instance_name) | 833 | .join(&safe_name) |
| 777 | .join("data") | 834 | .join("data") |
| 778 | .join("world"); | 835 | .join("world"); |
| 779 | let _ = std::fs::remove_dir_all(&world_dir); | 836 | let _ = std::fs::remove_dir_all(&world_dir); |
diff --git a/src/routes/mod.rs b/src/routes/mod.rs index a4d200c..2029bf8 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs | |||
| @@ -31,6 +31,7 @@ pub fn build_router(state: AppState) -> Router { | |||
| 31 | "/change-password", | 31 | "/change-password", |
| 32 | axum::routing::post(accounts::change_password), | 32 | axum::routing::post(accounts::change_password), |
| 33 | ) | 33 | ) |
| 34 | .route("/logout", axum::routing::post(crate::auth::logout)) | ||
| 34 | .merge(dashboard::router()) | 35 | .merge(dashboard::router()) |
| 35 | .merge(instances::router()) | 36 | .merge(instances::router()) |
| 36 | .merge(players::router()) | 37 | .merge(players::router()) |
| @@ -42,7 +43,6 @@ pub fn build_router(state: AppState) -> Router { | |||
| 42 | Router::new() | 43 | Router::new() |
| 43 | .route("/login", axum::routing::get(login_page)) | 44 | .route("/login", axum::routing::get(login_page)) |
| 44 | .route("/login", axum::routing::post(crate::auth::authenticate)) | 45 | .route("/login", axum::routing::post(crate::auth::authenticate)) |
| 45 | .route("/logout", axum::routing::post(crate::auth::logout)) | ||
| 46 | .merge(protected) | 46 | .merge(protected) |
| 47 | .nest_service("/static", ServeDir::new("static")) | 47 | .nest_service("/static", ServeDir::new("static")) |
| 48 | .with_state(state) | 48 | .with_state(state) |
diff --git a/src/routes/mods.rs b/src/routes/mods.rs index 4716fec..d8f0845 100644 --- a/src/routes/mods.rs +++ b/src/routes/mods.rs | |||
| @@ -13,6 +13,7 @@ use std::io::{Cursor, Read}; | |||
| 13 | use crate::auth::CurrentUser; | 13 | use crate::auth::CurrentUser; |
| 14 | use crate::mods; | 14 | use crate::mods; |
| 15 | use crate::routes::AppState; | 15 | use crate::routes::AppState; |
| 16 | use crate::sanitize; | ||
| 16 | 17 | ||
| 17 | #[derive(Template)] | 18 | #[derive(Template)] |
| 18 | #[template(path = "mods/list.html")] | 19 | #[template(path = "mods/list.html")] |
| @@ -119,11 +120,17 @@ async fn upload_mod( | |||
| 119 | None => return Err(Redirect::to("/instances")), | 120 | None => return Err(Redirect::to("/instances")), |
| 120 | }; | 121 | }; |
| 121 | 122 | ||
| 123 | // Sanitize instance name to prevent path traversal | ||
| 124 | let safe_name = match sanitize::sanitize_instance_name(&instance_name) { | ||
| 125 | Some(n) => n, | ||
| 126 | None => return Err(Redirect::to("/instances")), | ||
| 127 | }; | ||
| 128 | |||
| 122 | let mods_dir = state | 129 | let mods_dir = state |
| 123 | .config | 130 | .config |
| 124 | .server_directory | 131 | .server_directory |
| 125 | .join("instances") | 132 | .join("instances") |
| 126 | .join(&instance_name) | 133 | .join(&safe_name) |
| 127 | .join("data") | 134 | .join("data") |
| 128 | .join("mods"); | 135 | .join("mods"); |
| 129 | let _ = std::fs::create_dir_all(&mods_dir); | 136 | let _ = std::fs::create_dir_all(&mods_dir); |
| @@ -243,15 +250,8 @@ fn process_jar_upload( | |||
| 243 | result.side | 250 | result.side |
| 244 | ); | 251 | ); |
| 245 | 252 | ||
| 246 | if result.accepted { | 253 | // FIX: Insert DB record BEFORE writing file to disk. |
| 247 | let dest = mods_dir.join(&safe_name); | 254 | // This prevents orphaned mod files if the process crashes between write and DB insert. |
| 248 | if std::fs::write(&dest, bytes).is_ok() { | ||
| 249 | tracing::info!("Written to disk: {}", dest.display()); | ||
| 250 | } else { | ||
| 251 | tracing::error!("Failed to write {}", dest.display()); | ||
| 252 | } | ||
| 253 | } | ||
| 254 | |||
| 255 | let conn = state.db.conn(); | 255 | let conn = state.db.conn(); |
| 256 | if let Err(e) = conn.execute( | 256 | if let Err(e) = conn.execute( |
| 257 | "INSERT INTO mods (instance_id, filename, display_name, side, accepted, rejection_reason) | 257 | "INSERT INTO mods (instance_id, filename, display_name, side, accepted, rejection_reason) |
| @@ -266,10 +266,20 @@ fn process_jar_upload( | |||
| 266 | ], | 266 | ], |
| 267 | ) { | 267 | ) { |
| 268 | tracing::error!("DB insert failed: {e}"); | 268 | tracing::error!("DB insert failed: {e}"); |
| 269 | drop(conn); | ||
| 270 | return; | ||
| 269 | } | 271 | } |
| 270 | drop(conn); | 272 | drop(conn); |
| 271 | 273 | ||
| 274 | // Now write to disk (DB insert succeeded, so on restart we can detect incomplete writes) | ||
| 272 | if result.accepted { | 275 | if result.accepted { |
| 276 | let dest = mods_dir.join(&safe_name); | ||
| 277 | if std::fs::write(&dest, bytes).is_ok() { | ||
| 278 | tracing::info!("Written to disk: {}", dest.display()); | ||
| 279 | } else { | ||
| 280 | tracing::error!("Failed to write {}", dest.display()); | ||
| 281 | } | ||
| 282 | |||
| 273 | let conn = state.db.conn(); | 283 | let conn = state.db.conn(); |
| 274 | let _ = conn.execute( | 284 | let _ = conn.execute( |
| 275 | "UPDATE instances SET has_modpack = 1 WHERE id = ?1", | 285 | "UPDATE instances SET has_modpack = 1 WHERE id = ?1", |
| @@ -336,11 +346,17 @@ async fn rescan_mods( | |||
| 336 | None => return Err(Redirect::to("/instances")), | 346 | None => return Err(Redirect::to("/instances")), |
| 337 | }; | 347 | }; |
| 338 | 348 | ||
| 349 | // Sanitize instance name to prevent path traversal | ||
| 350 | let safe_name = match sanitize::sanitize_instance_name(&instance_name) { | ||
| 351 | Some(n) => n, | ||
| 352 | None => return Err(Redirect::to("/instances")), | ||
| 353 | }; | ||
| 354 | |||
| 339 | let mods_dir = state | 355 | let mods_dir = state |
| 340 | .config | 356 | .config |
| 341 | .server_directory | 357 | .server_directory |
| 342 | .join("instances") | 358 | .join("instances") |
| 343 | .join(&instance_name) | 359 | .join(&safe_name) |
| 344 | .join("data") | 360 | .join("data") |
| 345 | .join("mods"); | 361 | .join("mods"); |
| 346 | 362 | ||
| @@ -386,15 +402,27 @@ async fn delete_mod( | |||
| 386 | ) | 402 | ) |
| 387 | .map_err(|_| Redirect::to(&format!("/instances/{instance_id}/mods")))?; | 403 | .map_err(|_| Redirect::to(&format!("/instances/{instance_id}/mods")))?; |
| 388 | 404 | ||
| 405 | // Sanitize instance name to prevent path traversal | ||
| 406 | let safe_name = match sanitize::sanitize_instance_name(&instance_name) { | ||
| 407 | Some(n) => n, | ||
| 408 | None => return Err(Redirect::to(&format!("/instances/{instance_id}/mods"))), | ||
| 409 | }; | ||
| 410 | |||
| 411 | // Sanitize filename to prevent path traversal | ||
| 412 | let safe_filename = match sanitize::sanitize_backup_filename(&filename) { | ||
| 413 | Some(f) => f, | ||
| 414 | None => return Err(Redirect::to(&format!("/instances/{instance_id}/mods"))), | ||
| 415 | }; | ||
| 416 | |||
| 389 | // Delete file from disk | 417 | // Delete file from disk |
| 390 | let mod_path = state | 418 | let mod_path = state |
| 391 | .config | 419 | .config |
| 392 | .server_directory | 420 | .server_directory |
| 393 | .join("instances") | 421 | .join("instances") |
| 394 | .join(&instance_name) | 422 | .join(&safe_name) |
| 395 | .join("data") | 423 | .join("data") |
| 396 | .join("mods") | 424 | .join("mods") |
| 397 | .join(&filename); | 425 | .join(&safe_filename); |
| 398 | let _ = std::fs::remove_file(&mod_path); | 426 | let _ = std::fs::remove_file(&mod_path); |
| 399 | 427 | ||
| 400 | // Remove from DB | 428 | // Remove from DB |
diff --git a/src/routes/players.rs b/src/routes/players.rs index 7cfb722..f90113a 100644 --- a/src/routes/players.rs +++ b/src/routes/players.rs | |||
| @@ -9,6 +9,7 @@ use serde::Deserialize; | |||
| 9 | 9 | ||
| 10 | use crate::auth::CurrentUser; | 10 | use crate::auth::CurrentUser; |
| 11 | use crate::routes::AppState; | 11 | use crate::routes::AppState; |
| 12 | use crate::sanitize; | ||
| 12 | 13 | ||
| 13 | #[derive(Template)] | 14 | #[derive(Template)] |
| 14 | #[template(path = "players/manage.html")] | 15 | #[template(path = "players/manage.html")] |
| @@ -46,11 +47,14 @@ async fn manage_players( | |||
| 46 | 47 | ||
| 47 | let (instance_name, whitelist, ops, bans) = | 48 | let (instance_name, whitelist, ops, bans) = |
| 48 | if let Some((_id, inst_name, _rcon_pw, _container_state)) = active { | 49 | if let Some((_id, inst_name, _rcon_pw, _container_state)) = active { |
| 50 | // Sanitize instance name to prevent path traversal in file reads | ||
| 51 | let safe_name = sanitize::sanitize_instance_name(&inst_name) | ||
| 52 | .unwrap_or_else(|| inst_name.clone()); | ||
| 49 | let data_dir = state | 53 | let data_dir = state |
| 50 | .config | 54 | .config |
| 51 | .server_directory | 55 | .server_directory |
| 52 | .join("instances") | 56 | .join("instances") |
| 53 | .join(&inst_name) | 57 | .join(&safe_name) |
| 54 | .join("data"); | 58 | .join("data"); |
| 55 | 59 | ||
| 56 | let w = read_json_list(&data_dir.join("whitelist.json")); | 60 | let w = read_json_list(&data_dir.join("whitelist.json")); |
| @@ -92,9 +96,20 @@ macro_rules! player_action { | |||
| 92 | CurrentUser(_user): CurrentUser, | 96 | CurrentUser(_user): CurrentUser, |
| 93 | Form(form): Form<PlayerAction>, | 97 | Form(form): Form<PlayerAction>, |
| 94 | ) -> Result<Redirect, axum::response::Redirect> { | 98 | ) -> Result<Redirect, axum::response::Redirect> { |
| 99 | // Validate Minecraft username to prevent RCON command injection | ||
| 100 | // Minecraft usernames are 3-16 alphanumeric + underscore chars | ||
| 101 | let safe_username = form.username.trim(); | ||
| 102 | if !sanitize::validate_username(safe_username) { | ||
| 103 | tracing::warn!( | ||
| 104 | "Rejected invalid username for '{}': {safe_username:?}", | ||
| 105 | stringify!($name) | ||
| 106 | ); | ||
| 107 | return Ok(Redirect::to("/players")); | ||
| 108 | } | ||
| 109 | |||
| 95 | if let Some((_, _, rcon_pw, _)) = get_active_instance(&state.db) { | 110 | if let Some((_, _, rcon_pw, _)) = get_active_instance(&state.db) { |
| 96 | let client = crate::rcon::RconClient::new(rcon_pw); | 111 | let client = crate::rcon::RconClient::new(rcon_pw); |
| 97 | let cmd = format!($command, form.username); | 112 | let cmd = format!($command, safe_username); |
| 98 | match client.send_command_async(cmd.clone()).await { | 113 | match client.send_command_async(cmd.clone()).await { |
| 99 | Ok(resp) => tracing::info!("RCON '{}' -> {}", cmd, resp.trim()), | 114 | Ok(resp) => tracing::info!("RCON '{}' -> {}", cmd, resp.trim()), |
| 100 | Err(e) => tracing::error!("RCON '{}' failed: {}", cmd, e), | 115 | Err(e) => tracing::error!("RCON '{}' failed: {}", cmd, e), |
diff --git a/src/sanitize.rs b/src/sanitize.rs new file mode 100644 index 0000000..f251131 --- /dev/null +++ b/src/sanitize.rs | |||
| @@ -0,0 +1,221 @@ | |||
| 1 | //! Input sanitization and validation utilities. | ||
| 2 | //! | ||
| 3 | //! Centralises all user-input validation to avoid path traversal, | ||
| 4 | //! command injection, and property injection vulnerabilities. | ||
| 5 | |||
| 6 | use std::path::{Path, PathBuf}; | ||
| 7 | |||
| 8 | /// The maximum length (in bytes) for an instance name. | ||
| 9 | const INSTANCE_NAME_MAX_BYTES: usize = 64; | ||
| 10 | |||
| 11 | /// The maximum length (in bytes) for a seed value. | ||
| 12 | const SEED_MAX_BYTES: usize = 64; | ||
| 13 | |||
| 14 | /// The maximum length (in bytes) for a Minecraft username. | ||
| 15 | const USERNAME_MAX_BYTES: usize = 16; | ||
| 16 | |||
| 17 | /// Allowed characters for an instance name. | ||
| 18 | /// We restrict to a safe subset: alphanumeric, hyphens, underscores, dots. | ||
| 19 | /// This prevents path traversal via `..` or `/` and avoids filesystem issues. | ||
| 20 | fn instance_name_char_allowed(c: char) -> bool { | ||
| 21 | c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' | ||
| 22 | } | ||
| 23 | |||
| 24 | /// Validate and sanitise an instance name. | ||
| 25 | /// | ||
| 26 | /// Returns `None` if the name is invalid; otherwise returns the sanitised name | ||
| 27 | /// (trimmed, collapsed whitespace removed). | ||
| 28 | pub fn sanitize_instance_name(raw: &str) -> Option<String> { | ||
| 29 | let trimmed = raw.trim(); | ||
| 30 | if trimmed.is_empty() { | ||
| 31 | return None; | ||
| 32 | } | ||
| 33 | // Reject any name containing `..` — path traversal attempt | ||
| 34 | if trimmed.contains("..") { | ||
| 35 | return None; | ||
| 36 | } | ||
| 37 | // Reject names that would be filesystem metacharacters | ||
| 38 | if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains('\0') { | ||
| 39 | return None; | ||
| 40 | } | ||
| 41 | // Length limit | ||
| 42 | if trimmed.len() > INSTANCE_NAME_MAX_BYTES { | ||
| 43 | return None; | ||
| 44 | } | ||
| 45 | // Only allow safe characters | ||
| 46 | if !trimmed.chars().all(instance_name_char_allowed) { | ||
| 47 | return None; | ||
| 48 | } | ||
| 49 | // Must not start with a dot (hidden file issues) | ||
| 50 | if trimmed.starts_with('.') { | ||
| 51 | return None; | ||
| 52 | } | ||
| 53 | // Must not be a reserved name | ||
| 54 | match trimmed.to_ascii_lowercase().as_str() { | ||
| 55 | "" | "." | ".." | "con" | "prn" | "aux" | "nul" | "com1" | "com2" | "com3" | "com4" | ||
| 56 | | "lpt1" | "lpt2" | "lpt3" => return None, | ||
| 57 | _ => {} | ||
| 58 | } | ||
| 59 | Some(trimmed.to_string()) | ||
| 60 | } | ||
| 61 | |||
| 62 | /// Sanitise a world-seed value to prevent Java Properties injection. | ||
| 63 | /// | ||
| 64 | /// Strips newlines, carriage returns, `=`, and `:` characters that could | ||
| 65 | /// inject additional properties or break the properties file format. | ||
| 66 | pub fn sanitize_seed(raw: &str) -> String { | ||
| 67 | let s: String = raw | ||
| 68 | .chars() | ||
| 69 | .filter(|c| !matches!(c, '\n' | '\r' | '=' | ':' | '\0')) | ||
| 70 | .take(SEED_MAX_BYTES) | ||
| 71 | .collect(); | ||
| 72 | s.trim().to_string() | ||
| 73 | } | ||
| 74 | |||
| 75 | /// Validate a Minecraft username for RCON commands. | ||
| 76 | /// | ||
| 77 | /// Minecraft usernames are 3–16 characters, alphanumeric and underscores. | ||
| 78 | /// Returns `true` if the username is valid. | ||
| 79 | pub fn validate_username(raw: &str) -> bool { | ||
| 80 | let trimmed = raw.trim(); | ||
| 81 | if trimmed.len() < 3 || trimmed.len() > USERNAME_MAX_BYTES { | ||
| 82 | return false; | ||
| 83 | } | ||
| 84 | trimmed | ||
| 85 | .chars() | ||
| 86 | .all(|c| c.is_ascii_alphanumeric() || c == '_') | ||
| 87 | } | ||
| 88 | |||
| 89 | /// Verify that a resolved (canonical) path is within the given base directory. | ||
| 90 | /// | ||
| 91 | /// Both paths should be canonicalised before calling. Returns `true` if | ||
| 92 | /// `path` is a descendant of `base`. | ||
| 93 | #[allow(dead_code)] | ||
| 94 | pub fn path_is_within_base(path: &Path, base: &Path) -> bool { | ||
| 95 | path.canonicalize() | ||
| 96 | .ok() | ||
| 97 | .and_then(|p| base.canonicalize().ok().map(|b| p.starts_with(&b))) | ||
| 98 | .unwrap_or(false) | ||
| 99 | } | ||
| 100 | |||
| 101 | /// Build a safe instance data directory path. | ||
| 102 | /// | ||
| 103 | /// The instance name is validated first. Returns `None` if the name is invalid. | ||
| 104 | #[allow(dead_code)] | ||
| 105 | pub fn instance_data_dir(server_dir: &Path, instance_name: &str) -> Option<PathBuf> { | ||
| 106 | let safe = sanitize_instance_name(instance_name)?; | ||
| 107 | let dir = server_dir.join("instances").join(&safe).join("data"); | ||
| 108 | Some(dir) | ||
| 109 | } | ||
| 110 | |||
| 111 | /// Build a safe instance mods directory path. | ||
| 112 | #[allow(dead_code)] | ||
| 113 | pub fn instance_mods_dir(server_dir: &Path, instance_name: &str) -> Option<PathBuf> { | ||
| 114 | let data = instance_data_dir(server_dir, instance_name)?; | ||
| 115 | Some(data.join("mods")) | ||
| 116 | } | ||
| 117 | |||
| 118 | /// Build a safe instance world directory path. | ||
| 119 | #[allow(dead_code)] | ||
| 120 | pub fn instance_world_dir(server_dir: &Path, instance_name: &str) -> Option<PathBuf> { | ||
| 121 | let data = instance_data_dir(server_dir, instance_name)?; | ||
| 122 | Some(data.join("world")) | ||
| 123 | } | ||
| 124 | |||
| 125 | /// Build a safe server.properties path for an instance. | ||
| 126 | #[allow(dead_code)] | ||
| 127 | pub fn instance_properties_path(server_dir: &Path, instance_name: &str) -> Option<PathBuf> { | ||
| 128 | let data = instance_data_dir(server_dir, instance_name)?; | ||
| 129 | Some(data.join("server.properties")) | ||
| 130 | } | ||
| 131 | |||
| 132 | /// Build a safe instance directory (parent of data/) for an instance. | ||
| 133 | #[allow(dead_code)] | ||
| 134 | pub fn instance_dir(server_dir: &Path, instance_name: &str) -> Option<PathBuf> { | ||
| 135 | let safe = sanitize_instance_name(instance_name)?; | ||
| 136 | Some(server_dir.join("instances").join(&safe)) | ||
| 137 | } | ||
| 138 | |||
| 139 | /// Validate that a backup filename does not contain path traversal. | ||
| 140 | pub fn sanitize_backup_filename(raw: &str) -> Option<String> { | ||
| 141 | let trimmed = raw.trim(); | ||
| 142 | if trimmed.is_empty() { | ||
| 143 | return None; | ||
| 144 | } | ||
| 145 | if trimmed.contains("..") | ||
| 146 | || trimmed.contains('/') | ||
| 147 | || trimmed.contains('\\') | ||
| 148 | || trimmed.contains('\0') | ||
| 149 | { | ||
| 150 | return None; | ||
| 151 | } | ||
| 152 | if trimmed.len() > 256 { | ||
| 153 | return None; | ||
| 154 | } | ||
| 155 | Some(trimmed.to_string()) | ||
| 156 | } | ||
| 157 | |||
| 158 | #[cfg(test)] | ||
| 159 | mod tests { | ||
| 160 | use super::*; | ||
| 161 | |||
| 162 | #[test] | ||
| 163 | fn test_sanitize_instance_name_ok() { | ||
| 164 | assert_eq!(sanitize_instance_name("my-world").unwrap(), "my-world"); | ||
| 165 | assert_eq!(sanitize_instance_name("test_123").unwrap(), "test_123"); | ||
| 166 | assert_eq!(sanitize_instance_name("V1.20.4").unwrap(), "V1.20.4"); | ||
| 167 | assert_eq!(sanitize_instance_name(" hello ").unwrap(), "hello"); | ||
| 168 | } | ||
| 169 | |||
| 170 | #[test] | ||
| 171 | fn test_sanitize_instance_name_rejected() { | ||
| 172 | assert!(sanitize_instance_name("").is_none()); | ||
| 173 | assert!(sanitize_instance_name(" ").is_none()); | ||
| 174 | assert!(sanitize_instance_name("a/b").is_none()); | ||
| 175 | assert!(sanitize_instance_name("a\\b").is_none()); | ||
| 176 | assert!(sanitize_instance_name("..").is_none()); | ||
| 177 | assert!(sanitize_instance_name("foo/../bar").is_none()); | ||
| 178 | assert!(sanitize_instance_name(".hidden").is_none()); | ||
| 179 | assert!(sanitize_instance_name("hello world").is_none()); // space not allowed | ||
| 180 | assert!(sanitize_instance_name("a@b").is_none()); | ||
| 181 | } | ||
| 182 | |||
| 183 | #[test] | ||
| 184 | fn test_sanitize_seed() { | ||
| 185 | assert_eq!(sanitize_seed("12345"), "12345"); | ||
| 186 | assert_eq!(sanitize_seed("hello\nworld"), "helloworld"); | ||
| 187 | assert_eq!(sanitize_seed("key=value"), "keyvalue"); | ||
| 188 | // The seed is capped at SEED_MAX_BYTES=64. The string below is 64 chars after sanitization. | ||
| 189 | let long_seed = "-123456789012345678901234567890123456789012345678901234567890123"; | ||
| 190 | assert_eq!(long_seed.len(), 64); | ||
| 191 | assert_eq!( | ||
| 192 | sanitize_seed( | ||
| 193 | "-1234567890123456789012345678901234567890123456789012345678901234567890" | ||
| 194 | ), | ||
| 195 | long_seed | ||
| 196 | ); | ||
| 197 | } | ||
| 198 | |||
| 199 | #[test] | ||
| 200 | fn test_validate_username() { | ||
| 201 | assert!(validate_username("Notch")); | ||
| 202 | assert!(validate_username("player_123")); | ||
| 203 | assert!(!validate_username("ab")); // too short | ||
| 204 | assert!(!validate_username("a@b.com")); // special chars | ||
| 205 | assert!(!validate_username("")); // empty | ||
| 206 | } | ||
| 207 | |||
| 208 | #[test] | ||
| 209 | fn test_path_is_within_base() { | ||
| 210 | use std::fs; | ||
| 211 | let dir = tempfile::tempdir().unwrap(); | ||
| 212 | let base = dir.path().join("server"); | ||
| 213 | fs::create_dir_all(&base).unwrap(); | ||
| 214 | let sub = base.join("instances").join("test").join("data"); | ||
| 215 | fs::create_dir_all(&sub).unwrap(); | ||
| 216 | assert!(path_is_within_base(&sub, &base)); | ||
| 217 | // Path outside base should fail | ||
| 218 | let outside = dir.path().join("other"); | ||
| 219 | assert!(!path_is_within_base(&outside, &base)); | ||
| 220 | } | ||
| 221 | } | ||
