diff options
Diffstat (limited to '')
| -rw-r--r-- | src/routes/backups.rs | 40 | ||||
| -rw-r--r-- | src/routes/instances.rs | 103 | ||||
| -rw-r--r-- | src/routes/mod.rs | 2 | ||||
| -rw-r--r-- | src/routes/mods.rs | 54 | ||||
| -rw-r--r-- | src/routes/players.rs | 19 |
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 | ||
| 13 | use crate::auth::CurrentUser; | 13 | use crate::auth::CurrentUser; |
| 14 | use crate::routes::AppState; | 14 | use crate::routes::AppState; |
| 15 | use crate::sanitize; | ||
| 15 | 16 | ||
| 16 | #[derive(Template)] | 17 | #[derive(Template)] |
| 17 | #[template(path = "backups/list.html")] | 18 | #[template(path = "backups/list.html")] |
| @@ -97,20 +98,37 @@ async fn create_backup( | |||
| 97 | .map_err(|_| Redirect::to("/instances"))?; | 98 | .map_err(|_| Redirect::to("/instances"))?; |
| 98 | drop(conn); | 99 | drop(conn); |
| 99 | 100 | ||
| 101 | // Sanitize instance name to prevent path traversal | ||
| 102 | let safe_name = match sanitize::sanitize_instance_name(&instance_name) { | ||
| 103 | Some(n) => n, | ||
| 104 | None => return Ok(Redirect::to(&format!("/instances/{instance_id}/backups"))), | ||
| 105 | }; | ||
| 106 | |||
| 100 | let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S"); | 107 | let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S"); |
| 101 | let filename = format!("{instance_name}_{timestamp}.zip"); | 108 | let filename = format!("{safe_name}_{timestamp}.zip"); |
| 102 | 109 | ||
| 103 | let data_dir = state | 110 | let data_dir = state |
| 104 | .config | 111 | .config |
| 105 | .server_directory | 112 | .server_directory |
| 106 | .join("instances") | 113 | .join("instances") |
| 107 | .join(&instance_name) | 114 | .join(&safe_name) |
| 108 | .join("data"); | 115 | .join("data"); |
| 109 | 116 | ||
| 110 | let backup_dir = state.config.server_directory.join("backups"); | 117 | let backup_dir = state.config.server_directory.join("backups"); |
| 111 | let _ = std::fs::create_dir_all(&backup_dir); | 118 | let _ = std::fs::create_dir_all(&backup_dir); |
| 112 | let backup_path = backup_dir.join(&filename); | 119 | let backup_path = backup_dir.join(&filename); |
| 113 | 120 | ||
| 121 | // FIX: Insert DB record BEFORE creating the ZIP file. | ||
| 122 | // This prevents orphaned backup files if the process crashes between ZIP creation and DB insert. | ||
| 123 | let conn = state.db.conn(); | ||
| 124 | conn.execute( | ||
| 125 | "INSERT INTO backups (instance_id, filename) VALUES (?1, ?2)", | ||
| 126 | params![instance_id, filename], | ||
| 127 | ) | ||
| 128 | .ok(); | ||
| 129 | drop(conn); | ||
| 130 | |||
| 131 | // Now create the ZIP file (DB insert already succeeded) | ||
| 114 | if let Ok(file) = std::fs::File::create(&backup_path) { | 132 | if let Ok(file) = std::fs::File::create(&backup_path) { |
| 115 | let mut zip = zip::ZipWriter::new(file); | 133 | let mut zip = zip::ZipWriter::new(file); |
| 116 | let options: FileOptions<'_, ()> = | 134 | let options: FileOptions<'_, ()> = |
| @@ -130,14 +148,6 @@ async fn create_backup( | |||
| 130 | let _ = zip.finish(); | 148 | let _ = zip.finish(); |
| 131 | } | 149 | } |
| 132 | 150 | ||
| 133 | let conn = state.db.conn(); | ||
| 134 | conn.execute( | ||
| 135 | "INSERT INTO backups (instance_id, filename) VALUES (?1, ?2)", | ||
| 136 | params![instance_id, filename], | ||
| 137 | ) | ||
| 138 | .ok(); | ||
| 139 | drop(conn); | ||
| 140 | |||
| 141 | Ok(Redirect::to(&format!("/instances/{instance_id}/backups"))) | 151 | Ok(Redirect::to(&format!("/instances/{instance_id}/backups"))) |
| 142 | } | 152 | } |
| 143 | 153 | ||
| @@ -174,11 +184,17 @@ async fn download_backup( | |||
| 174 | .map_err(|_| (StatusCode::NOT_FOUND, "Backup not found"))?; | 184 | .map_err(|_| (StatusCode::NOT_FOUND, "Backup not found"))?; |
| 175 | drop(conn); | 185 | drop(conn); |
| 176 | 186 | ||
| 187 | // Sanitize backup filename to prevent path traversal | ||
| 188 | let safe_filename = match sanitize::sanitize_backup_filename(&filename) { | ||
| 189 | Some(f) => f, | ||
| 190 | None => return Err((StatusCode::BAD_REQUEST, "Invalid backup filename")), | ||
| 191 | }; | ||
| 192 | |||
| 177 | let backup_path = state | 193 | let backup_path = state |
| 178 | .config | 194 | .config |
| 179 | .server_directory | 195 | .server_directory |
| 180 | .join("backups") | 196 | .join("backups") |
| 181 | .join(&filename); | 197 | .join(&safe_filename); |
| 182 | 198 | ||
| 183 | let content = | 199 | let content = |
| 184 | std::fs::read(&backup_path).map_err(|_| (StatusCode::NOT_FOUND, "File not found"))?; | 200 | std::fs::read(&backup_path).map_err(|_| (StatusCode::NOT_FOUND, "File not found"))?; |
| @@ -187,7 +203,7 @@ async fn download_backup( | |||
| 187 | .header(header::CONTENT_TYPE, "application/zip") | 203 | .header(header::CONTENT_TYPE, "application/zip") |
| 188 | .header( | 204 | .header( |
| 189 | header::CONTENT_DISPOSITION, | 205 | header::CONTENT_DISPOSITION, |
| 190 | format!("attachment; filename=\"{filename}\""), | 206 | format!("attachment; filename=\"{safe_filename}\""), |
| 191 | ) | 207 | ) |
| 192 | .body(Body::from(content)) | 208 | .body(Body::from(content)) |
| 193 | .unwrap()) | 209 | .unwrap()) |
diff --git a/src/routes/instances.rs b/src/routes/instances.rs index f1c3e37..d3b8294 100644 --- a/src/routes/instances.rs +++ b/src/routes/instances.rs | |||
| @@ -11,6 +11,7 @@ use serde::Deserialize; | |||
| 11 | use crate::auth::CurrentUser; | 11 | use crate::auth::CurrentUser; |
| 12 | use crate::models::Instance; | 12 | use crate::models::Instance; |
| 13 | use crate::routes::AppState; | 13 | use crate::routes::AppState; |
| 14 | use crate::sanitize; | ||
| 14 | 15 | ||
| 15 | #[derive(Template)] | 16 | #[derive(Template)] |
| 16 | #[template(path = "instances/list.html")] | 17 | #[template(path = "instances/list.html")] |
| @@ -198,11 +199,32 @@ async fn create_instance( | |||
| 198 | .ok(); | 199 | .ok(); |
| 199 | drop(conn); | 200 | drop(conn); |
| 200 | 201 | ||
| 202 | // Validate and sanitize instance name to prevent path traversal | ||
| 203 | let safe_name = match sanitize::sanitize_instance_name(&form.name) { | ||
| 204 | Some(n) => n, | ||
| 205 | None => { | ||
| 206 | // Invalid name, redirect back | ||
| 207 | return Ok(Redirect::to("/instances/new")); | ||
| 208 | } | ||
| 209 | }; | ||
| 210 | |||
| 211 | // Double-check: the DB insert used the original form.name, but we want the safe version. | ||
| 212 | // Update the DB record to use the sanitised name. | ||
| 213 | { | ||
| 214 | let conn = state.db.conn(); | ||
| 215 | if let Err(e) = conn.execute( | ||
| 216 | "UPDATE instances SET name = ?1 WHERE name = ?2", | ||
| 217 | params![safe_name, form.name], | ||
| 218 | ) { | ||
| 219 | tracing::error!("Failed to update instance name to sanitized version: {e}"); | ||
| 220 | } | ||
| 221 | } | ||
| 222 | |||
| 201 | let instance_dir = state | 223 | let instance_dir = state |
| 202 | .config | 224 | .config |
| 203 | .server_directory | 225 | .server_directory |
| 204 | .join("instances") | 226 | .join("instances") |
| 205 | .join(&form.name) | 227 | .join(&safe_name) |
| 206 | .join("data"); | 228 | .join("data"); |
| 207 | let _ = std::fs::create_dir_all(&instance_dir); | 229 | let _ = std::fs::create_dir_all(&instance_dir); |
| 208 | 230 | ||
| @@ -389,8 +411,13 @@ async fn delete_instance( | |||
| 389 | drop(conn); | 411 | drop(conn); |
| 390 | 412 | ||
| 391 | if let Some(name) = name { | 413 | if let Some(name) = name { |
| 392 | let instance_dir = state.config.server_directory.join("instances").join(&name); | 414 | // Sanitize name to prevent path traversal during cleanup |
| 393 | let _ = std::fs::remove_dir_all(&instance_dir); | 415 | if let Some(safe_name) = sanitize::sanitize_instance_name(&name) { |
| 416 | let instance_dir = state.config.server_directory.join("instances").join(&safe_name); | ||
| 417 | let _ = std::fs::remove_dir_all(&instance_dir); | ||
| 418 | } else { | ||
| 419 | tracing::warn!("Refusing to delete directory for invalid instance name: {name:?}"); | ||
| 420 | } | ||
| 394 | } | 421 | } |
| 395 | 422 | ||
| 396 | Ok(Redirect::to("/instances")) | 423 | Ok(Redirect::to("/instances")) |
| @@ -450,15 +477,18 @@ async fn activate_instance( | |||
| 450 | }; | 477 | }; |
| 451 | 478 | ||
| 452 | // Pull image + start container in background so the UI stays responsive | 479 | // Pull image + start container in background so the UI stays responsive |
| 480 | // FIX: Use concrete instance ID instead of `WHERE is_active = 1` to avoid | ||
| 481 | // stale updates trampling a different instance if activation changes. | ||
| 453 | let docker = state.docker.clone(); | 482 | let docker = state.docker.clone(); |
| 454 | let db = state.db.clone(); | 483 | let db = state.db.clone(); |
| 455 | let rcon_pw = instance.rcon_password.clone(); | 484 | let rcon_pw = instance.rcon_password.clone(); |
| 485 | let instance_id = instance.id; | ||
| 456 | tokio::spawn(async move { | 486 | tokio::spawn(async move { |
| 457 | { | 487 | { |
| 458 | let conn = db.conn(); | 488 | let conn = db.conn(); |
| 459 | conn.execute( | 489 | conn.execute( |
| 460 | "UPDATE instances SET container_state = 'pulling' WHERE is_active = 1", | 490 | "UPDATE instances SET container_state = 'pulling' WHERE id = ?1", |
| 461 | [], | 491 | params![instance_id], |
| 462 | ) | 492 | ) |
| 463 | .ok(); | 493 | .ok(); |
| 464 | } | 494 | } |
| @@ -467,8 +497,8 @@ async fn activate_instance( | |||
| 467 | { | 497 | { |
| 468 | let conn = db.conn(); | 498 | let conn = db.conn(); |
| 469 | conn.execute( | 499 | conn.execute( |
| 470 | "UPDATE instances SET container_state = 'booting' WHERE is_active = 1", | 500 | "UPDATE instances SET container_state = 'booting' WHERE id = ?1", |
| 471 | [], | 501 | params![instance_id], |
| 472 | ) | 502 | ) |
| 473 | .ok(); | 503 | .ok(); |
| 474 | } | 504 | } |
| @@ -479,8 +509,8 @@ async fn activate_instance( | |||
| 479 | if rcon.send_command_async("list".into()).await.is_ok() { | 509 | if rcon.send_command_async("list".into()).await.is_ok() { |
| 480 | let conn = db.conn(); | 510 | let conn = db.conn(); |
| 481 | conn.execute( | 511 | conn.execute( |
| 482 | "UPDATE instances SET container_state = 'running' WHERE is_active = 1", | 512 | "UPDATE instances SET container_state = 'running' WHERE id = ?1", |
| 483 | [], | 513 | params![instance_id], |
| 484 | ) | 514 | ) |
| 485 | .ok(); | 515 | .ok(); |
| 486 | tracing::info!("Server is ready (RCON responded)"); | 516 | tracing::info!("Server is ready (RCON responded)"); |
| @@ -490,8 +520,8 @@ async fn activate_instance( | |||
| 490 | tracing::error!("Server did not become ready within 60 seconds"); | 520 | tracing::error!("Server did not become ready within 60 seconds"); |
| 491 | let conn = db.conn(); | 521 | let conn = db.conn(); |
| 492 | conn.execute( | 522 | conn.execute( |
| 493 | "UPDATE instances SET container_state = 'error' WHERE is_active = 1", | 523 | "UPDATE instances SET container_state = 'error' WHERE id = ?1", |
| 494 | [], | 524 | params![instance_id], |
| 495 | ) | 525 | ) |
| 496 | .ok(); | 526 | .ok(); |
| 497 | } | 527 | } |
| @@ -499,8 +529,8 @@ async fn activate_instance( | |||
| 499 | tracing::error!("Failed to start container: {e}"); | 529 | tracing::error!("Failed to start container: {e}"); |
| 500 | let conn = db.conn(); | 530 | let conn = db.conn(); |
| 501 | conn.execute( | 531 | conn.execute( |
| 502 | "UPDATE instances SET container_state = 'error' WHERE is_active = 1", | 532 | "UPDATE instances SET container_state = 'error' WHERE id = ?1", |
| 503 | [], | 533 | params![instance_id], |
| 504 | ) | 534 | ) |
| 505 | .ok(); | 535 | .ok(); |
| 506 | } | 536 | } |
| @@ -586,11 +616,17 @@ async fn export_world( | |||
| 586 | .map_err(|_| Redirect::to("/instances"))?; | 616 | .map_err(|_| Redirect::to("/instances"))?; |
| 587 | drop(conn); | 617 | drop(conn); |
| 588 | 618 | ||
| 619 | // Sanitize instance name to prevent path traversal | ||
| 620 | let safe_name = match sanitize::sanitize_instance_name(&instance_name) { | ||
| 621 | Some(n) => n, | ||
| 622 | None => return Err(Redirect::to("/instances")), | ||
| 623 | }; | ||
| 624 | |||
| 589 | let world_dir = state | 625 | let world_dir = state |
| 590 | .config | 626 | .config |
| 591 | .server_directory | 627 | .server_directory |
| 592 | .join("instances") | 628 | .join("instances") |
| 593 | .join(&instance_name) | 629 | .join(&safe_name) |
| 594 | .join("data") | 630 | .join("data") |
| 595 | .join("world"); | 631 | .join("world"); |
| 596 | 632 | ||
| @@ -661,6 +697,12 @@ async fn import_world( | |||
| 661 | } | 697 | } |
| 662 | }; | 698 | }; |
| 663 | 699 | ||
| 700 | // Sanitize instance name to prevent path traversal | ||
| 701 | let safe_name = match sanitize::sanitize_instance_name(&instance_name) { | ||
| 702 | Some(n) => n, | ||
| 703 | None => return Ok(Redirect::to(&format!("/instances/{id}/edit"))), | ||
| 704 | }; | ||
| 705 | |||
| 664 | if is_active { | 706 | if is_active { |
| 665 | return Ok(Redirect::to(&format!("/instances/{id}/edit"))); | 707 | return Ok(Redirect::to(&format!("/instances/{id}/edit"))); |
| 666 | } | 708 | } |
| @@ -669,7 +711,7 @@ async fn import_world( | |||
| 669 | .config | 711 | .config |
| 670 | .server_directory | 712 | .server_directory |
| 671 | .join("instances") | 713 | .join("instances") |
| 672 | .join(&instance_name) | 714 | .join(&safe_name) |
| 673 | .join("data"); | 715 | .join("data"); |
| 674 | 716 | ||
| 675 | // Extract uploaded ZIP | 717 | // Extract uploaded ZIP |
| @@ -691,10 +733,16 @@ async fn import_world( | |||
| 691 | let mut file = archive | 733 | let mut file = archive |
| 692 | .by_index(i) | 734 | .by_index(i) |
| 693 | .map_err(|_| Redirect::to(&format!("/instances/{id}/edit")))?; | 735 | .map_err(|_| Redirect::to(&format!("/instances/{id}/edit")))?; |
| 736 | // Use enclosed_name() for zip-slip protection, then verify within world_dir | ||
| 694 | let out_path = match file.enclosed_name() { | 737 | let out_path = match file.enclosed_name() { |
| 695 | Some(p) => world_dir.join(p), | 738 | Some(p) => world_dir.join(p), |
| 696 | None => continue, | 739 | None => continue, |
| 697 | }; | 740 | }; |
| 741 | // Additional safety: verify the resolved path is within world_dir | ||
| 742 | if !out_path.starts_with(&world_dir) { | ||
| 743 | tracing::warn!("Zip-slip attempt blocked: {out_path:?}"); | ||
| 744 | continue; | ||
| 745 | } | ||
| 698 | if file.is_dir() { | 746 | if file.is_dir() { |
| 699 | let _ = std::fs::create_dir_all(&out_path); | 747 | let _ = std::fs::create_dir_all(&out_path); |
| 700 | } else { | 748 | } else { |
| @@ -728,38 +776,47 @@ async fn regenerate_world( | |||
| 728 | .map_err(|_| Redirect::to("/instances"))?; | 776 | .map_err(|_| Redirect::to("/instances"))?; |
| 729 | drop(conn); | 777 | drop(conn); |
| 730 | 778 | ||
| 779 | // Sanitize instance name to prevent path traversal | ||
| 780 | let safe_name = match sanitize::sanitize_instance_name(&instance_name) { | ||
| 781 | Some(n) => n, | ||
| 782 | None => return Ok(Redirect::to(&format!("/instances/{id}/edit"))), | ||
| 783 | }; | ||
| 784 | |||
| 731 | if is_active { | 785 | if is_active { |
| 732 | return Ok(Redirect::to(&format!("/instances/{id}/edit"))); | 786 | return Ok(Redirect::to(&format!("/instances/{id}/edit"))); |
| 733 | } | 787 | } |
| 734 | 788 | ||
| 789 | // Sanitize seed to prevent Java properties injection | ||
| 790 | let seed = form.seed.unwrap_or_default(); | ||
| 791 | let safe_seed = sanitize::sanitize_seed(&seed); | ||
| 792 | |||
| 735 | let props_path = state | 793 | let props_path = state |
| 736 | .config | 794 | .config |
| 737 | .server_directory | 795 | .server_directory |
| 738 | .join("instances") | 796 | .join("instances") |
| 739 | .join(&instance_name) | 797 | .join(&safe_name) |
| 740 | .join("data") | 798 | .join("data") |
| 741 | .join("server.properties"); | 799 | .join("server.properties"); |
| 742 | 800 | ||
| 743 | // Update level-seed in server.properties | 801 | // Update level-seed in server.properties with sanitized value |
| 744 | let seed = form.seed.unwrap_or_default(); | ||
| 745 | let contents = std::fs::read_to_string(&props_path).unwrap_or_default(); | 802 | let contents = std::fs::read_to_string(&props_path).unwrap_or_default(); |
| 746 | let mut new_lines = Vec::new(); | 803 | let mut new_lines = Vec::new(); |
| 747 | let mut found = false; | 804 | let mut found = false; |
| 748 | for line in contents.lines() { | 805 | for line in contents.lines() { |
| 749 | if line.trim_start().starts_with("level-seed") { | 806 | if line.trim_start().starts_with("level-seed") { |
| 750 | new_lines.push(format!("level-seed={seed}")); | 807 | new_lines.push(format!("level-seed={safe_seed}")); |
| 751 | found = true; | 808 | found = true; |
| 752 | } else { | 809 | } else { |
| 753 | new_lines.push(line.to_string()); | 810 | new_lines.push(line.to_string()); |
| 754 | } | 811 | } |
| 755 | } | 812 | } |
| 756 | if !found { | 813 | if !found { |
| 757 | new_lines.push(format!("level-seed={seed}")); | 814 | new_lines.push(format!("level-seed={safe_seed}")); |
| 758 | } | 815 | } |
| 759 | let _ = std::fs::write(&props_path, new_lines.join("\n")); | 816 | let _ = std::fs::write(&props_path, new_lines.join("\n")); |
| 760 | 817 | ||
| 761 | // Update DB record | 818 | // Update DB record with sanitized seed |
| 762 | let db_seed: Option<&str> = if seed.is_empty() { None } else { Some(&seed) }; | 819 | let db_seed: Option<&str> = if safe_seed.is_empty() { None } else { Some(&safe_seed) }; |
| 763 | let conn = state.db.conn(); | 820 | let conn = state.db.conn(); |
| 764 | conn.execute( | 821 | conn.execute( |
| 765 | "UPDATE instances SET world_seed = ?1, updated_at = datetime('now') WHERE id = ?2", | 822 | "UPDATE instances SET world_seed = ?1, updated_at = datetime('now') WHERE id = ?2", |
| @@ -773,7 +830,7 @@ async fn regenerate_world( | |||
| 773 | .config | 830 | .config |
| 774 | .server_directory | 831 | .server_directory |
| 775 | .join("instances") | 832 | .join("instances") |
| 776 | .join(&instance_name) | 833 | .join(&safe_name) |
| 777 | .join("data") | 834 | .join("data") |
| 778 | .join("world"); | 835 | .join("world"); |
| 779 | let _ = std::fs::remove_dir_all(&world_dir); | 836 | let _ = std::fs::remove_dir_all(&world_dir); |
diff --git a/src/routes/mod.rs b/src/routes/mod.rs index a4d200c..2029bf8 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs | |||
| @@ -31,6 +31,7 @@ pub fn build_router(state: AppState) -> Router { | |||
| 31 | "/change-password", | 31 | "/change-password", |
| 32 | axum::routing::post(accounts::change_password), | 32 | axum::routing::post(accounts::change_password), |
| 33 | ) | 33 | ) |
| 34 | .route("/logout", axum::routing::post(crate::auth::logout)) | ||
| 34 | .merge(dashboard::router()) | 35 | .merge(dashboard::router()) |
| 35 | .merge(instances::router()) | 36 | .merge(instances::router()) |
| 36 | .merge(players::router()) | 37 | .merge(players::router()) |
| @@ -42,7 +43,6 @@ pub fn build_router(state: AppState) -> Router { | |||
| 42 | Router::new() | 43 | Router::new() |
| 43 | .route("/login", axum::routing::get(login_page)) | 44 | .route("/login", axum::routing::get(login_page)) |
| 44 | .route("/login", axum::routing::post(crate::auth::authenticate)) | 45 | .route("/login", axum::routing::post(crate::auth::authenticate)) |
| 45 | .route("/logout", axum::routing::post(crate::auth::logout)) | ||
| 46 | .merge(protected) | 46 | .merge(protected) |
| 47 | .nest_service("/static", ServeDir::new("static")) | 47 | .nest_service("/static", ServeDir::new("static")) |
| 48 | .with_state(state) | 48 | .with_state(state) |
diff --git a/src/routes/mods.rs b/src/routes/mods.rs index 4716fec..d8f0845 100644 --- a/src/routes/mods.rs +++ b/src/routes/mods.rs | |||
| @@ -13,6 +13,7 @@ use std::io::{Cursor, Read}; | |||
| 13 | use crate::auth::CurrentUser; | 13 | use crate::auth::CurrentUser; |
| 14 | use crate::mods; | 14 | use crate::mods; |
| 15 | use crate::routes::AppState; | 15 | use crate::routes::AppState; |
| 16 | use crate::sanitize; | ||
| 16 | 17 | ||
| 17 | #[derive(Template)] | 18 | #[derive(Template)] |
| 18 | #[template(path = "mods/list.html")] | 19 | #[template(path = "mods/list.html")] |
| @@ -119,11 +120,17 @@ async fn upload_mod( | |||
| 119 | None => return Err(Redirect::to("/instances")), | 120 | None => return Err(Redirect::to("/instances")), |
| 120 | }; | 121 | }; |
| 121 | 122 | ||
| 123 | // Sanitize instance name to prevent path traversal | ||
| 124 | let safe_name = match sanitize::sanitize_instance_name(&instance_name) { | ||
| 125 | Some(n) => n, | ||
| 126 | None => return Err(Redirect::to("/instances")), | ||
| 127 | }; | ||
| 128 | |||
| 122 | let mods_dir = state | 129 | let mods_dir = state |
| 123 | .config | 130 | .config |
| 124 | .server_directory | 131 | .server_directory |
| 125 | .join("instances") | 132 | .join("instances") |
| 126 | .join(&instance_name) | 133 | .join(&safe_name) |
| 127 | .join("data") | 134 | .join("data") |
| 128 | .join("mods"); | 135 | .join("mods"); |
| 129 | let _ = std::fs::create_dir_all(&mods_dir); | 136 | let _ = std::fs::create_dir_all(&mods_dir); |
| @@ -243,15 +250,8 @@ fn process_jar_upload( | |||
| 243 | result.side | 250 | result.side |
| 244 | ); | 251 | ); |
| 245 | 252 | ||
| 246 | if result.accepted { | 253 | // FIX: Insert DB record BEFORE writing file to disk. |
| 247 | let dest = mods_dir.join(&safe_name); | 254 | // This prevents orphaned mod files if the process crashes between write and DB insert. |
| 248 | if std::fs::write(&dest, bytes).is_ok() { | ||
| 249 | tracing::info!("Written to disk: {}", dest.display()); | ||
| 250 | } else { | ||
| 251 | tracing::error!("Failed to write {}", dest.display()); | ||
| 252 | } | ||
| 253 | } | ||
| 254 | |||
| 255 | let conn = state.db.conn(); | 255 | let conn = state.db.conn(); |
| 256 | if let Err(e) = conn.execute( | 256 | if let Err(e) = conn.execute( |
| 257 | "INSERT INTO mods (instance_id, filename, display_name, side, accepted, rejection_reason) | 257 | "INSERT INTO mods (instance_id, filename, display_name, side, accepted, rejection_reason) |
| @@ -266,10 +266,20 @@ fn process_jar_upload( | |||
| 266 | ], | 266 | ], |
| 267 | ) { | 267 | ) { |
| 268 | tracing::error!("DB insert failed: {e}"); | 268 | tracing::error!("DB insert failed: {e}"); |
| 269 | drop(conn); | ||
| 270 | return; | ||
| 269 | } | 271 | } |
| 270 | drop(conn); | 272 | drop(conn); |
| 271 | 273 | ||
| 274 | // Now write to disk (DB insert succeeded, so on restart we can detect incomplete writes) | ||
| 272 | if result.accepted { | 275 | if result.accepted { |
| 276 | let dest = mods_dir.join(&safe_name); | ||
| 277 | if std::fs::write(&dest, bytes).is_ok() { | ||
| 278 | tracing::info!("Written to disk: {}", dest.display()); | ||
| 279 | } else { | ||
| 280 | tracing::error!("Failed to write {}", dest.display()); | ||
| 281 | } | ||
| 282 | |||
| 273 | let conn = state.db.conn(); | 283 | let conn = state.db.conn(); |
| 274 | let _ = conn.execute( | 284 | let _ = conn.execute( |
| 275 | "UPDATE instances SET has_modpack = 1 WHERE id = ?1", | 285 | "UPDATE instances SET has_modpack = 1 WHERE id = ?1", |
| @@ -336,11 +346,17 @@ async fn rescan_mods( | |||
| 336 | None => return Err(Redirect::to("/instances")), | 346 | None => return Err(Redirect::to("/instances")), |
| 337 | }; | 347 | }; |
| 338 | 348 | ||
| 349 | // Sanitize instance name to prevent path traversal | ||
| 350 | let safe_name = match sanitize::sanitize_instance_name(&instance_name) { | ||
| 351 | Some(n) => n, | ||
| 352 | None => return Err(Redirect::to("/instances")), | ||
| 353 | }; | ||
| 354 | |||
| 339 | let mods_dir = state | 355 | let mods_dir = state |
| 340 | .config | 356 | .config |
| 341 | .server_directory | 357 | .server_directory |
| 342 | .join("instances") | 358 | .join("instances") |
| 343 | .join(&instance_name) | 359 | .join(&safe_name) |
| 344 | .join("data") | 360 | .join("data") |
| 345 | .join("mods"); | 361 | .join("mods"); |
| 346 | 362 | ||
| @@ -386,15 +402,27 @@ async fn delete_mod( | |||
| 386 | ) | 402 | ) |
| 387 | .map_err(|_| Redirect::to(&format!("/instances/{instance_id}/mods")))?; | 403 | .map_err(|_| Redirect::to(&format!("/instances/{instance_id}/mods")))?; |
| 388 | 404 | ||
| 405 | // Sanitize instance name to prevent path traversal | ||
| 406 | let safe_name = match sanitize::sanitize_instance_name(&instance_name) { | ||
| 407 | Some(n) => n, | ||
| 408 | None => return Err(Redirect::to(&format!("/instances/{instance_id}/mods"))), | ||
| 409 | }; | ||
| 410 | |||
| 411 | // Sanitize filename to prevent path traversal | ||
| 412 | let safe_filename = match sanitize::sanitize_backup_filename(&filename) { | ||
| 413 | Some(f) => f, | ||
| 414 | None => return Err(Redirect::to(&format!("/instances/{instance_id}/mods"))), | ||
| 415 | }; | ||
| 416 | |||
| 389 | // Delete file from disk | 417 | // Delete file from disk |
| 390 | let mod_path = state | 418 | let mod_path = state |
| 391 | .config | 419 | .config |
| 392 | .server_directory | 420 | .server_directory |
| 393 | .join("instances") | 421 | .join("instances") |
| 394 | .join(&instance_name) | 422 | .join(&safe_name) |
| 395 | .join("data") | 423 | .join("data") |
| 396 | .join("mods") | 424 | .join("mods") |
| 397 | .join(&filename); | 425 | .join(&safe_filename); |
| 398 | let _ = std::fs::remove_file(&mod_path); | 426 | let _ = std::fs::remove_file(&mod_path); |
| 399 | 427 | ||
| 400 | // Remove from DB | 428 | // Remove from DB |
diff --git a/src/routes/players.rs b/src/routes/players.rs index 7cfb722..f90113a 100644 --- a/src/routes/players.rs +++ b/src/routes/players.rs | |||
| @@ -9,6 +9,7 @@ use serde::Deserialize; | |||
| 9 | 9 | ||
| 10 | use crate::auth::CurrentUser; | 10 | use crate::auth::CurrentUser; |
| 11 | use crate::routes::AppState; | 11 | use crate::routes::AppState; |
| 12 | use crate::sanitize; | ||
| 12 | 13 | ||
| 13 | #[derive(Template)] | 14 | #[derive(Template)] |
| 14 | #[template(path = "players/manage.html")] | 15 | #[template(path = "players/manage.html")] |
| @@ -46,11 +47,14 @@ async fn manage_players( | |||
| 46 | 47 | ||
| 47 | let (instance_name, whitelist, ops, bans) = | 48 | let (instance_name, whitelist, ops, bans) = |
| 48 | if let Some((_id, inst_name, _rcon_pw, _container_state)) = active { | 49 | if let Some((_id, inst_name, _rcon_pw, _container_state)) = active { |
| 50 | // Sanitize instance name to prevent path traversal in file reads | ||
| 51 | let safe_name = sanitize::sanitize_instance_name(&inst_name) | ||
| 52 | .unwrap_or_else(|| inst_name.clone()); | ||
| 49 | let data_dir = state | 53 | let data_dir = state |
| 50 | .config | 54 | .config |
| 51 | .server_directory | 55 | .server_directory |
| 52 | .join("instances") | 56 | .join("instances") |
| 53 | .join(&inst_name) | 57 | .join(&safe_name) |
| 54 | .join("data"); | 58 | .join("data"); |
| 55 | 59 | ||
| 56 | let w = read_json_list(&data_dir.join("whitelist.json")); | 60 | let w = read_json_list(&data_dir.join("whitelist.json")); |
| @@ -92,9 +96,20 @@ macro_rules! player_action { | |||
| 92 | CurrentUser(_user): CurrentUser, | 96 | CurrentUser(_user): CurrentUser, |
| 93 | Form(form): Form<PlayerAction>, | 97 | Form(form): Form<PlayerAction>, |
| 94 | ) -> Result<Redirect, axum::response::Redirect> { | 98 | ) -> Result<Redirect, axum::response::Redirect> { |
| 99 | // Validate Minecraft username to prevent RCON command injection | ||
| 100 | // Minecraft usernames are 3-16 alphanumeric + underscore chars | ||
| 101 | let safe_username = form.username.trim(); | ||
| 102 | if !sanitize::validate_username(safe_username) { | ||
| 103 | tracing::warn!( | ||
| 104 | "Rejected invalid username for '{}': {safe_username:?}", | ||
| 105 | stringify!($name) | ||
| 106 | ); | ||
| 107 | return Ok(Redirect::to("/players")); | ||
| 108 | } | ||
| 109 | |||
| 95 | if let Some((_, _, rcon_pw, _)) = get_active_instance(&state.db) { | 110 | if let Some((_, _, rcon_pw, _)) = get_active_instance(&state.db) { |
| 96 | let client = crate::rcon::RconClient::new(rcon_pw); | 111 | let client = crate::rcon::RconClient::new(rcon_pw); |
| 97 | let cmd = format!($command, form.username); | 112 | let cmd = format!($command, safe_username); |
| 98 | match client.send_command_async(cmd.clone()).await { | 113 | match client.send_command_async(cmd.clone()).await { |
| 99 | Ok(resp) => tracing::info!("RCON '{}' -> {}", cmd, resp.trim()), | 114 | Ok(resp) => tracing::info!("RCON '{}' -> {}", cmd, resp.trim()), |
| 100 | Err(e) => tracing::error!("RCON '{}' failed: {}", cmd, e), | 115 | Err(e) => tracing::error!("RCON '{}' failed: {}", cmd, e), |
