diff options
| author | salmonik <salmonik@tuta.io> | 2026-07-29 02:24:41 +0200 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-07-29 02:24:41 +0200 |
| commit | c654a6d17eb58adbb15b9c47ab32bcc3a1666307 (patch) | |
| tree | 545544bf7d805254415da787fad1cdc711767833 /src/routes/instances.rs | |
| parent | Add per-instance mod management with client-side detection (diff) | |
| download | minecraft-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/instances.rs')
| -rw-r--r-- | src/routes/instances.rs | 103 |
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; | |||
| 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); |
