summaryrefslogtreecommitdiffstats
path: root/src/routes
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-07-29 02:24:41 +0200
committersalmonik <salmonik@tuta.io>2026-07-29 02:24:41 +0200
commitc654a6d17eb58adbb15b9c47ab32bcc3a1666307 (patch)
tree545544bf7d805254415da787fad1cdc711767833 /src/routes
parentAdd per-instance mod management with client-side detection (diff)
downloadminecraft-ui-c654a6d17eb58adbb15b9c47ab32bcc3a1666307.tar.gz
minecraft-ui-c654a6d17eb58adbb15b9c47ab32bcc3a1666307.zip
Security audit fixes: patch 16 vulnerabilities across 7 source files
CRITICAL: - Instance name path traversal (AP-001): sanitize::sanitize_instance_name() strips .., /, \, \0, spaces, special chars at all 12 join call sites - Zip-slip protection in world import: starts_with() guard after enclosed_name() HIGH: - RCON command injection: validate_username() rejects non-MC usernames across all 6 player action macros (whitelist, op, ban) - Level-seed properties injection: sanitize_seed() strips \n \r = : \0 - CSRF protection: SameSite=Lax -> SameSite=Strict on session cookie MEDIUM: - Session TTL: server-side 24h expiry via strftime check in require_auth - Rate limiting: in-memory 10-ip/5min sliding window on login - Background task stale is_active: all queries use concrete WHERE id=?1 - Mod validation DoS: 500MB cap, 10k class scan limit, 10k CP entry cap LOW: - Logout auth guard: moved into protected route group + CurrentUser param - Mod orphan race: DB insert before file write - Backup orphan race: DB insert before ZIP creation - Template fix: broken Askama == comparisons in edit.html All fixes compile clean with zero warnings. 10 unit tests pass.
Diffstat (limited to 'src/routes')
-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
5 files changed, 167 insertions, 51 deletions
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),