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/sanitize.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/sanitize.rs')
| -rw-r--r-- | src/sanitize.rs | 221 |
1 files changed, 221 insertions, 0 deletions
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 | |||
| 6 | use std::path::{Path, PathBuf}; | ||
| 7 | |||
| 8 | /// The maximum length (in bytes) for an instance name. | ||
| 9 | const INSTANCE_NAME_MAX_BYTES: usize = 64; | ||
| 10 | |||
| 11 | /// The maximum length (in bytes) for a seed value. | ||
| 12 | const SEED_MAX_BYTES: usize = 64; | ||
| 13 | |||
| 14 | /// The maximum length (in bytes) for a Minecraft username. | ||
| 15 | const 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. | ||
| 20 | fn 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). | ||
| 28 | pub 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. | ||
| 66 | pub 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. | ||
| 79 | pub 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)] | ||
| 94 | pub 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)] | ||
| 105 | pub 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)] | ||
| 113 | pub 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)] | ||
| 120 | pub 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)] | ||
| 127 | pub 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)] | ||
| 134 | pub 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. | ||
| 140 | pub 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)] | ||
| 159 | mod 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 | } | ||
