mcp_execution_core/
command.rs1use crate::{Error, Result, ServerConfig};
35use std::path::Path;
36
37const FORBIDDEN_CHARS: &[char] = &[';', '|', '&', '>', '<', '`', '$', '(', ')', '\n', '\r'];
39
40const FORBIDDEN_ENV_NAMES: &[&str] = &[
42 "LD_PRELOAD",
43 "LD_LIBRARY_PATH",
44 "DYLD_INSERT_LIBRARIES",
45 "DYLD_LIBRARY_PATH",
46 "DYLD_FRAMEWORK_PATH",
47 "PATH", ];
49
50pub fn validate_server_config(config: &ServerConfig) -> Result<()> {
100 validate_command_string(&config.command, "command")?;
102
103 let command_path = Path::new(&config.command);
105 if command_path.is_absolute() {
106 validate_absolute_path(&config.command)?;
107 }
108 for (idx, arg) in config.args.iter().enumerate() {
112 validate_command_string(arg, &format!("argument {idx}"))?;
113 }
114
115 for env_name in config.env.keys() {
117 validate_env_name(env_name)?;
118 }
119
120 Ok(())
121}
122
123fn validate_command_string(value: &str, context: &str) -> Result<()> {
128 let value = value.trim();
130 if value.is_empty() {
131 return Err(Error::SecurityViolation {
132 reason: format!("{context} cannot be empty"),
133 });
134 }
135
136 for forbidden in FORBIDDEN_CHARS {
138 if value.contains(*forbidden) {
139 return Err(Error::SecurityViolation {
140 reason: format!(
141 "{context} contains forbidden shell metacharacter '{forbidden}': {value}"
142 ),
143 });
144 }
145 }
146
147 Ok(())
148}
149
150fn validate_absolute_path(command: &str) -> Result<()> {
155 let path = Path::new(command);
156
157 if !path.exists() {
159 return Err(Error::SecurityViolation {
160 reason: format!("Command file does not exist: {command}"),
161 });
162 }
163
164 if !path.is_file() {
166 return Err(Error::SecurityViolation {
167 reason: format!("Command path is not a file: {command}"),
168 });
169 }
170
171 #[cfg(unix)]
173 {
174 use std::os::unix::fs::PermissionsExt;
175 let metadata = std::fs::metadata(path).map_err(|e| Error::SecurityViolation {
176 reason: format!("Cannot read command metadata: {e}"),
177 })?;
178 let permissions = metadata.permissions();
179 let mode = permissions.mode();
180
181 if mode & 0o111 == 0 {
183 return Err(Error::SecurityViolation {
184 reason: format!("Command file is not executable: {command}"),
185 });
186 }
187 }
188
189 Ok(())
190}
191
192fn validate_env_name(name: &str) -> Result<()> {
197 if FORBIDDEN_ENV_NAMES.contains(&name) {
199 return Err(Error::SecurityViolation {
200 reason: format!("Forbidden environment variable name: {name}"),
201 });
202 }
203
204 if name.starts_with("DYLD_") {
206 return Err(Error::SecurityViolation {
207 reason: format!("Forbidden environment variable prefix DYLD_: {name}"),
208 });
209 }
210
211 Ok(())
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use std::fs;
218 use std::io::Write;
219
220 #[test]
221 fn test_validate_server_config_binary_name() {
222 let config = ServerConfig::builder()
224 .command("docker".to_string())
225 .build();
226 assert!(validate_server_config(&config).is_ok());
227
228 let config = ServerConfig::builder()
229 .command("python".to_string())
230 .build();
231 assert!(validate_server_config(&config).is_ok());
232
233 let config = ServerConfig::builder().command("node".to_string()).build();
234 assert!(validate_server_config(&config).is_ok());
235 }
236
237 #[test]
238 fn test_validate_server_config_binary_with_args() {
239 let config = ServerConfig::builder()
240 .command("docker".to_string())
241 .arg("run".to_string())
242 .arg("--rm".to_string())
243 .arg("mcp-server".to_string())
244 .build();
245 assert!(validate_server_config(&config).is_ok());
246 }
247
248 #[test]
249 fn test_validate_server_config_empty_command() {
250 let result = ServerConfig::builder().command(String::new()).try_build();
252 assert!(result.is_err());
253 assert!(result.unwrap_err().contains("empty"));
254
255 let result = ServerConfig::builder()
257 .command(" ".to_string())
258 .try_build();
259 assert!(result.is_err());
260 assert!(result.unwrap_err().contains("empty"));
261 }
262
263 #[test]
264 fn test_validate_server_config_command_with_metacharacters() {
265 let dangerous_commands = vec![
266 "docker; rm -rf /",
267 "docker | cat",
268 "docker && echo pwned",
269 "docker > /tmp/out",
270 "docker < /tmp/in",
271 "docker `whoami`",
272 "docker $(whoami)",
273 "docker & background",
274 "docker\nrm -rf /",
275 ];
276
277 for cmd in dangerous_commands {
278 let config = ServerConfig::builder().command(cmd.to_string()).build();
279 let result = validate_server_config(&config);
280 assert!(
281 result.is_err(),
282 "Should reject command with metacharacters: {cmd}"
283 );
284 if let Err(Error::SecurityViolation { reason }) = result {
285 assert!(
286 reason.contains("forbidden") || reason.contains("metacharacter"),
287 "Error should mention forbidden character: {reason}"
288 );
289 }
290 }
291 }
292
293 #[test]
294 fn test_validate_server_config_args_with_metacharacters() {
295 let dangerous_args = vec![
296 "run; rm -rf /",
297 "run | cat",
298 "run && echo pwned",
299 "run > /tmp/out",
300 "run < /tmp/in",
301 "run `whoami`",
302 "run $(whoami)",
303 "run & background",
304 "run\nrm -rf /",
305 ];
306
307 for arg in dangerous_args {
308 let config = ServerConfig::builder()
309 .command("docker".to_string())
310 .arg(arg.to_string())
311 .build();
312 let result = validate_server_config(&config);
313 assert!(
314 result.is_err(),
315 "Should reject arg with metacharacters: {arg}"
316 );
317 if let Err(Error::SecurityViolation { reason }) = result {
318 assert!(
319 reason.contains("argument")
320 && (reason.contains("forbidden") || reason.contains("metacharacter")),
321 "Error should mention argument and forbidden character: {reason}"
322 );
323 }
324 }
325 }
326
327 #[test]
328 fn test_validate_server_config_empty_arg() {
329 let config = ServerConfig::builder()
330 .command("docker".to_string())
331 .arg(String::new())
332 .build();
333 assert!(validate_server_config(&config).is_err());
334 }
335
336 #[test]
337 fn test_validate_server_config_forbidden_env_ld_preload() {
338 let config = ServerConfig::builder()
339 .command("docker".to_string())
340 .env("LD_PRELOAD".to_string(), "/evil.so".to_string())
341 .build();
342 let result = validate_server_config(&config);
343 assert!(result.is_err());
344 if let Err(Error::SecurityViolation { reason }) = result {
345 assert!(reason.contains("LD_PRELOAD"));
346 }
347 }
348
349 #[test]
350 fn test_validate_server_config_forbidden_env_ld_library_path() {
351 let config = ServerConfig::builder()
352 .command("docker".to_string())
353 .env("LD_LIBRARY_PATH".to_string(), "/evil".to_string())
354 .build();
355 let result = validate_server_config(&config);
356 assert!(result.is_err());
357 if let Err(Error::SecurityViolation { reason }) = result {
358 assert!(reason.contains("LD_LIBRARY_PATH"));
359 }
360 }
361
362 #[test]
363 fn test_validate_server_config_forbidden_env_dyld() {
364 let dyld_vars = vec![
365 "DYLD_INSERT_LIBRARIES",
366 "DYLD_LIBRARY_PATH",
367 "DYLD_FRAMEWORK_PATH",
368 "DYLD_PRINT_TO_FILE",
369 "DYLD_CUSTOM_VAR",
370 ];
371
372 for var in dyld_vars {
373 let config = ServerConfig::builder()
374 .command("docker".to_string())
375 .env(var.to_string(), "/evil".to_string())
376 .build();
377 let result = validate_server_config(&config);
378 assert!(result.is_err(), "Should reject DYLD_* variable: {var}");
379 if let Err(Error::SecurityViolation { reason }) = result {
380 assert!(
381 reason.contains("DYLD_"),
382 "Error should mention DYLD_: {reason}"
383 );
384 }
385 }
386 }
387
388 #[test]
389 fn test_validate_server_config_forbidden_env_path() {
390 let config = ServerConfig::builder()
391 .command("docker".to_string())
392 .env("PATH".to_string(), "/evil:/usr/bin".to_string())
393 .build();
394 let result = validate_server_config(&config);
395 assert!(result.is_err());
396 if let Err(Error::SecurityViolation { reason }) = result {
397 assert!(reason.contains("PATH"));
398 }
399 }
400
401 #[test]
402 fn test_validate_server_config_safe_env() {
403 let config = ServerConfig::builder()
404 .command("docker".to_string())
405 .env("LOG_LEVEL".to_string(), "debug".to_string())
406 .env("DEBUG".to_string(), "1".to_string())
407 .env("HOME".to_string(), "/home/user".to_string())
408 .env("MY_CUSTOM_VAR".to_string(), "value".to_string())
409 .build();
410 assert!(validate_server_config(&config).is_ok());
411 }
412
413 #[test]
414 #[cfg(unix)]
415 fn test_validate_server_config_absolute_path_valid() {
416 use std::os::unix::fs::PermissionsExt;
417
418 let temp_file = "/tmp/test-mcp-server-config";
420 let mut file = fs::File::create(temp_file).unwrap();
421 writeln!(file, "#!/bin/sh").unwrap();
422
423 let mut perms = fs::metadata(temp_file).unwrap().permissions();
425 perms.set_mode(0o755);
426 fs::set_permissions(temp_file, perms).unwrap();
427
428 let config = ServerConfig::builder()
429 .command(temp_file.to_string())
430 .arg("--port".to_string())
431 .arg("8080".to_string())
432 .build();
433
434 let result = validate_server_config(&config);
435 fs::remove_file(temp_file).ok();
436
437 assert!(result.is_ok());
438 }
439
440 #[test]
441 #[cfg(unix)]
442 fn test_validate_server_config_absolute_path_not_executable() {
443 use std::os::unix::fs::PermissionsExt;
444
445 let temp_file = "/tmp/test-mcp-server-config-noexec";
447 let mut file = fs::File::create(temp_file).unwrap();
448 writeln!(file, "#!/bin/sh").unwrap();
449
450 let mut perms = fs::metadata(temp_file).unwrap().permissions();
452 perms.set_mode(0o644);
453 fs::set_permissions(temp_file, perms).unwrap();
454
455 let config = ServerConfig::builder()
456 .command(temp_file.to_string())
457 .build();
458
459 let result = validate_server_config(&config);
460 fs::remove_file(temp_file).ok();
461
462 assert!(result.is_err());
463 if let Err(Error::SecurityViolation { reason }) = result {
464 assert!(reason.contains("not executable"));
465 }
466 }
467
468 #[test]
469 fn test_validate_server_config_absolute_path_nonexistent() {
470 #[cfg(unix)]
471 let nonexistent = "/absolutely/nonexistent/path/to/server";
472 #[cfg(windows)]
473 let nonexistent = "C:\\absolutely\\nonexistent\\path\\to\\server.exe";
474
475 let config = ServerConfig::builder()
476 .command(nonexistent.to_string())
477 .build();
478
479 let result = validate_server_config(&config);
480 assert!(result.is_err());
481 if let Err(Error::SecurityViolation { reason }) = result {
482 assert!(reason.contains("does not exist"));
483 }
484 }
485
486 #[test]
487 fn test_validate_server_config_with_cwd() {
488 let config = ServerConfig::builder()
490 .command("docker".to_string())
491 .cwd(std::path::PathBuf::from("/tmp"))
492 .build();
493 assert!(validate_server_config(&config).is_ok());
494 }
495
496 #[test]
497 fn test_validate_server_config_complex_valid() {
498 let config = ServerConfig::builder()
499 .command("docker".to_string())
500 .arg("run".to_string())
501 .arg("--rm".to_string())
502 .arg("-e".to_string())
503 .arg("DEBUG=1".to_string())
504 .arg("mcp-server".to_string())
505 .env("LOG_LEVEL".to_string(), "info".to_string())
506 .env("CACHE_DIR".to_string(), "/var/cache".to_string())
507 .cwd(std::path::PathBuf::from("/opt/app"))
508 .build();
509 assert!(validate_server_config(&config).is_ok());
510 }
511
512 #[test]
513 fn test_validate_env_name_edge_cases() {
514 assert!(validate_env_name("LD_PRELOAD").is_err());
516 assert!(validate_env_name("DYLD_TEST").is_err());
517 assert!(validate_env_name("PATH").is_err());
518
519 assert!(validate_env_name("LD_DEBUG").is_ok()); assert!(validate_env_name("MY_PATH").is_ok()); assert!(validate_env_name("DYLD").is_ok()); }
524}