summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/auth.rs66
-rw-r--r--src/main.rs1
-rw-r--r--src/mods.rs31
-rw-r--r--src/routes/backups.rs40
-rw-r--r--src/routes/instances.rs103
-rw-r--r--src/routes/mod.rs2
-rw-r--r--src/routes/mods.rs54
-rw-r--r--src/routes/players.rs19
-rw-r--r--src/sanitize.rs221
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};
11use rusqlite::params; 11use rusqlite::params;
12use serde::Deserialize; 12use serde::Deserialize;
13use std::collections::HashMap;
14use std::sync::Mutex as StdMutex;
15use std::time::Instant;
13use uuid::Uuid; 16use uuid::Uuid;
14 17
15use crate::models::Account; 18use crate::models::Account;
@@ -23,6 +26,34 @@ pub struct LoginForm {
23 26
24const SESSION_COOKIE: &str = "mcui_session"; 27const 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.
31const RATE_LIMIT_WINDOW_SECS: u64 = 300; // 5 minutes
32const RATE_LIMIT_MAX_ATTEMPTS: u32 = 10;
33
34static LOGIN_ATTEMPTS: std::sync::LazyLock<StdMutex<HashMap<String, (u32, Instant)>>> =
35 std::sync::LazyLock::new(|| StdMutex::new(HashMap::new()));
36
37fn 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
52fn reset_rate_limit(ip: &str) {
53 let mut attempts = LOGIN_ATTEMPTS.lock().unwrap();
54 attempts.remove(ip);
55}
56
26pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> { 57pub 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.
144const SESSION_MAX_AGE_SECS: i64 = 86400; // 24 hours
145
95pub async fn require_auth( 146pub 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
176pub async fn logout( 233pub 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;
7mod properties; 7mod properties;
8mod rcon; 8mod rcon;
9mod routes; 9mod routes;
10mod sanitize;
10 11
11use tracing_subscriber::EnvFilter; 12use 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).
47const 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.
58pub fn validate_mod(data: &[u8], filename: &str) -> ValidationResult { 61pub 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).
221const 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).
348const MAX_CP_ENTRIES: usize = 10_000;
349
321fn scan_single_class(data: &[u8]) -> ClassScan { 350fn 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
13use crate::auth::CurrentUser; 13use crate::auth::CurrentUser;
14use crate::routes::AppState; 14use crate::routes::AppState;
15use 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;
11use crate::auth::CurrentUser; 11use crate::auth::CurrentUser;
12use crate::models::Instance; 12use crate::models::Instance;
13use crate::routes::AppState; 13use crate::routes::AppState;
14use 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};
13use crate::auth::CurrentUser; 13use crate::auth::CurrentUser;
14use crate::mods; 14use crate::mods;
15use crate::routes::AppState; 15use crate::routes::AppState;
16use 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
10use crate::auth::CurrentUser; 10use crate::auth::CurrentUser;
11use crate::routes::AppState; 11use crate::routes::AppState;
12use 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
6use std::path::{Path, PathBuf};
7
8/// The maximum length (in bytes) for an instance name.
9const INSTANCE_NAME_MAX_BYTES: usize = 64;
10
11/// The maximum length (in bytes) for a seed value.
12const SEED_MAX_BYTES: usize = 64;
13
14/// The maximum length (in bytes) for a Minecraft username.
15const 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.
20fn 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).
28pub 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.
66pub 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.
79pub 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)]
94pub 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)]
105pub 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)]
113pub 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)]
120pub 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)]
127pub 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)]
134pub 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.
140pub 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)]
159mod 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}