summaryrefslogtreecommitdiffstats
path: root/src/routes/instances.rs
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/instances.rs
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 '')
-rw-r--r--src/routes/instances.rs103
1 files changed, 80 insertions, 23 deletions
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);