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