1use crate::{Error, Result, ServerConfig};
35use std::path::Path;
36use std::time::Duration;
37
38const FORBIDDEN_CHARS: &[char] = &[';', '|', '&', '>', '<', '`', '$', '(', ')', '\n', '\r'];
40
41const FORBIDDEN_ENV_NAMES: &[&str] = &[
43 "LD_PRELOAD",
44 "LD_LIBRARY_PATH",
45 "DYLD_INSERT_LIBRARIES",
46 "DYLD_LIBRARY_PATH",
47 "DYLD_FRAMEWORK_PATH",
48 "PATH", ];
50
51const MAX_TIMEOUT: Duration = Duration::from_mins(10);
55
56pub fn validate_server_config(config: &ServerConfig) -> Result<()> {
116 validate_command_string(&config.command, "command")?;
118
119 let command_path = Path::new(&config.command);
121 if command_path.is_absolute() {
122 validate_absolute_path(&config.command)?;
123 }
124 for (idx, arg) in config.args.iter().enumerate() {
128 validate_command_string(arg, &format!("argument {idx}"))?;
129 }
130
131 for env_name in config.env.keys() {
133 validate_env_name(env_name)?;
134 }
135
136 validate_timeout(config.connect_timeout(), "connect_timeout")?;
142 validate_timeout(config.discover_timeout(), "discover_timeout")?;
143
144 Ok(())
145}
146
147fn validate_timeout(timeout: Duration, field: &str) -> Result<()> {
159 if timeout.is_zero() {
160 return Err(Error::ValidationError {
161 field: field.to_string(),
162 reason: "timeout must be greater than zero".to_string(),
163 });
164 }
165 if timeout > MAX_TIMEOUT {
166 return Err(Error::ValidationError {
167 field: field.to_string(),
168 reason: format!("timeout {timeout:?} exceeds maximum allowed {MAX_TIMEOUT:?}"),
169 });
170 }
171 Ok(())
172}
173
174fn validate_command_string(value: &str, context: &str) -> Result<()> {
179 let value = value.trim();
181 if value.is_empty() {
182 return Err(Error::SecurityViolation {
183 reason: format!("{context} cannot be empty"),
184 });
185 }
186
187 for forbidden in FORBIDDEN_CHARS {
189 if value.contains(*forbidden) {
190 return Err(Error::SecurityViolation {
191 reason: format!(
192 "{context} contains forbidden shell metacharacter '{forbidden}': {value}"
193 ),
194 });
195 }
196 }
197
198 Ok(())
199}
200
201fn validate_absolute_path(command: &str) -> Result<()> {
206 let path = Path::new(command);
207
208 if !path.exists() {
210 return Err(Error::SecurityViolation {
211 reason: format!("Command file does not exist: {command}"),
212 });
213 }
214
215 if !path.is_file() {
217 return Err(Error::SecurityViolation {
218 reason: format!("Command path is not a file: {command}"),
219 });
220 }
221
222 #[cfg(unix)]
224 {
225 use std::os::unix::fs::PermissionsExt;
226 let metadata = std::fs::metadata(path).map_err(|e| Error::SecurityViolation {
227 reason: format!("Cannot read command metadata: {e}"),
228 })?;
229 let permissions = metadata.permissions();
230 let mode = permissions.mode();
231
232 if mode & 0o111 == 0 {
234 return Err(Error::SecurityViolation {
235 reason: format!("Command file is not executable: {command}"),
236 });
237 }
238 }
239
240 Ok(())
241}
242
243fn validate_env_name(name: &str) -> Result<()> {
248 if FORBIDDEN_ENV_NAMES.contains(&name) {
250 return Err(Error::SecurityViolation {
251 reason: format!("Forbidden environment variable name: {name}"),
252 });
253 }
254
255 if name.starts_with("DYLD_") {
257 return Err(Error::SecurityViolation {
258 reason: format!("Forbidden environment variable prefix DYLD_: {name}"),
259 });
260 }
261
262 Ok(())
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use std::fs;
269 use std::io::Write;
270
271 #[test]
272 fn test_validate_server_config_binary_name() {
273 let config = ServerConfig::builder()
275 .command("docker".to_string())
276 .build();
277 assert!(validate_server_config(&config).is_ok());
278
279 let config = ServerConfig::builder()
280 .command("python".to_string())
281 .build();
282 assert!(validate_server_config(&config).is_ok());
283
284 let config = ServerConfig::builder().command("node".to_string()).build();
285 assert!(validate_server_config(&config).is_ok());
286 }
287
288 #[test]
289 fn test_validate_server_config_binary_with_args() {
290 let config = ServerConfig::builder()
291 .command("docker".to_string())
292 .arg("run".to_string())
293 .arg("--rm".to_string())
294 .arg("mcp-server".to_string())
295 .build();
296 assert!(validate_server_config(&config).is_ok());
297 }
298
299 #[test]
300 fn test_validate_server_config_empty_command() {
301 let result = ServerConfig::builder().command(String::new()).try_build();
303 assert!(result.is_err());
304 assert!(result.unwrap_err().contains("empty"));
305
306 let result = ServerConfig::builder()
308 .command(" ".to_string())
309 .try_build();
310 assert!(result.is_err());
311 assert!(result.unwrap_err().contains("empty"));
312 }
313
314 #[test]
315 fn test_validate_server_config_command_with_metacharacters() {
316 let dangerous_commands = vec![
317 "docker; rm -rf /",
318 "docker | cat",
319 "docker && echo pwned",
320 "docker > /tmp/out",
321 "docker < /tmp/in",
322 "docker `whoami`",
323 "docker $(whoami)",
324 "docker & background",
325 "docker\nrm -rf /",
326 ];
327
328 for cmd in dangerous_commands {
329 let config = ServerConfig::builder().command(cmd.to_string()).build();
330 let result = validate_server_config(&config);
331 assert!(
332 result.is_err(),
333 "Should reject command with metacharacters: {cmd}"
334 );
335 if let Err(Error::SecurityViolation { reason }) = result {
336 assert!(
337 reason.contains("forbidden") || reason.contains("metacharacter"),
338 "Error should mention forbidden character: {reason}"
339 );
340 }
341 }
342 }
343
344 #[test]
345 fn test_validate_server_config_args_with_metacharacters() {
346 let dangerous_args = vec![
347 "run; rm -rf /",
348 "run | cat",
349 "run && echo pwned",
350 "run > /tmp/out",
351 "run < /tmp/in",
352 "run `whoami`",
353 "run $(whoami)",
354 "run & background",
355 "run\nrm -rf /",
356 ];
357
358 for arg in dangerous_args {
359 let config = ServerConfig::builder()
360 .command("docker".to_string())
361 .arg(arg.to_string())
362 .build();
363 let result = validate_server_config(&config);
364 assert!(
365 result.is_err(),
366 "Should reject arg with metacharacters: {arg}"
367 );
368 if let Err(Error::SecurityViolation { reason }) = result {
369 assert!(
370 reason.contains("argument")
371 && (reason.contains("forbidden") || reason.contains("metacharacter")),
372 "Error should mention argument and forbidden character: {reason}"
373 );
374 }
375 }
376 }
377
378 #[test]
379 fn test_validate_server_config_empty_arg() {
380 let config = ServerConfig::builder()
381 .command("docker".to_string())
382 .arg(String::new())
383 .build();
384 assert!(validate_server_config(&config).is_err());
385 }
386
387 #[test]
388 fn test_validate_server_config_forbidden_env_ld_preload() {
389 let config = ServerConfig::builder()
390 .command("docker".to_string())
391 .env("LD_PRELOAD".to_string(), "/evil.so".to_string())
392 .build();
393 let result = validate_server_config(&config);
394 assert!(result.is_err());
395 if let Err(Error::SecurityViolation { reason }) = result {
396 assert!(reason.contains("LD_PRELOAD"));
397 }
398 }
399
400 #[test]
401 fn test_validate_server_config_forbidden_env_ld_library_path() {
402 let config = ServerConfig::builder()
403 .command("docker".to_string())
404 .env("LD_LIBRARY_PATH".to_string(), "/evil".to_string())
405 .build();
406 let result = validate_server_config(&config);
407 assert!(result.is_err());
408 if let Err(Error::SecurityViolation { reason }) = result {
409 assert!(reason.contains("LD_LIBRARY_PATH"));
410 }
411 }
412
413 #[test]
414 fn test_validate_server_config_forbidden_env_dyld() {
415 let dyld_vars = vec![
416 "DYLD_INSERT_LIBRARIES",
417 "DYLD_LIBRARY_PATH",
418 "DYLD_FRAMEWORK_PATH",
419 "DYLD_PRINT_TO_FILE",
420 "DYLD_CUSTOM_VAR",
421 ];
422
423 for var in dyld_vars {
424 let config = ServerConfig::builder()
425 .command("docker".to_string())
426 .env(var.to_string(), "/evil".to_string())
427 .build();
428 let result = validate_server_config(&config);
429 assert!(result.is_err(), "Should reject DYLD_* variable: {var}");
430 if let Err(Error::SecurityViolation { reason }) = result {
431 assert!(
432 reason.contains("DYLD_"),
433 "Error should mention DYLD_: {reason}"
434 );
435 }
436 }
437 }
438
439 #[test]
440 fn test_validate_server_config_forbidden_env_path() {
441 let config = ServerConfig::builder()
442 .command("docker".to_string())
443 .env("PATH".to_string(), "/evil:/usr/bin".to_string())
444 .build();
445 let result = validate_server_config(&config);
446 assert!(result.is_err());
447 if let Err(Error::SecurityViolation { reason }) = result {
448 assert!(reason.contains("PATH"));
449 }
450 }
451
452 #[test]
453 fn test_validate_server_config_safe_env() {
454 let config = ServerConfig::builder()
455 .command("docker".to_string())
456 .env("LOG_LEVEL".to_string(), "debug".to_string())
457 .env("DEBUG".to_string(), "1".to_string())
458 .env("HOME".to_string(), "/home/user".to_string())
459 .env("MY_CUSTOM_VAR".to_string(), "value".to_string())
460 .build();
461 assert!(validate_server_config(&config).is_ok());
462 }
463
464 #[test]
465 #[cfg(unix)]
466 fn test_validate_server_config_absolute_path_valid() {
467 use std::os::unix::fs::PermissionsExt;
468
469 let temp_file = "/tmp/test-mcp-server-config";
471 let mut file = fs::File::create(temp_file).unwrap();
472 writeln!(file, "#!/bin/sh").unwrap();
473
474 let mut perms = fs::metadata(temp_file).unwrap().permissions();
476 perms.set_mode(0o755);
477 fs::set_permissions(temp_file, perms).unwrap();
478
479 let config = ServerConfig::builder()
480 .command(temp_file.to_string())
481 .arg("--port".to_string())
482 .arg("8080".to_string())
483 .build();
484
485 let result = validate_server_config(&config);
486 fs::remove_file(temp_file).ok();
487
488 assert!(result.is_ok());
489 }
490
491 #[test]
492 #[cfg(unix)]
493 fn test_validate_server_config_absolute_path_not_executable() {
494 use std::os::unix::fs::PermissionsExt;
495
496 let temp_file = "/tmp/test-mcp-server-config-noexec";
498 let mut file = fs::File::create(temp_file).unwrap();
499 writeln!(file, "#!/bin/sh").unwrap();
500
501 let mut perms = fs::metadata(temp_file).unwrap().permissions();
503 perms.set_mode(0o644);
504 fs::set_permissions(temp_file, perms).unwrap();
505
506 let config = ServerConfig::builder()
507 .command(temp_file.to_string())
508 .build();
509
510 let result = validate_server_config(&config);
511 fs::remove_file(temp_file).ok();
512
513 assert!(result.is_err());
514 if let Err(Error::SecurityViolation { reason }) = result {
515 assert!(reason.contains("not executable"));
516 }
517 }
518
519 #[test]
520 fn test_validate_server_config_absolute_path_nonexistent() {
521 #[cfg(unix)]
522 let nonexistent = "/absolutely/nonexistent/path/to/server";
523 #[cfg(windows)]
524 let nonexistent = "C:\\absolutely\\nonexistent\\path\\to\\server.exe";
525
526 let config = ServerConfig::builder()
527 .command(nonexistent.to_string())
528 .build();
529
530 let result = validate_server_config(&config);
531 assert!(result.is_err());
532 if let Err(Error::SecurityViolation { reason }) = result {
533 assert!(reason.contains("does not exist"));
534 }
535 }
536
537 #[test]
538 fn test_validate_server_config_with_cwd() {
539 let config = ServerConfig::builder()
541 .command("docker".to_string())
542 .cwd(std::path::PathBuf::from("/tmp"))
543 .build();
544 assert!(validate_server_config(&config).is_ok());
545 }
546
547 #[test]
548 fn test_validate_server_config_complex_valid() {
549 let config = ServerConfig::builder()
550 .command("docker".to_string())
551 .arg("run".to_string())
552 .arg("--rm".to_string())
553 .arg("-e".to_string())
554 .arg("DEBUG=1".to_string())
555 .arg("mcp-server".to_string())
556 .env("LOG_LEVEL".to_string(), "info".to_string())
557 .env("CACHE_DIR".to_string(), "/var/cache".to_string())
558 .cwd(std::path::PathBuf::from("/opt/app"))
559 .build();
560 assert!(validate_server_config(&config).is_ok());
561 }
562
563 #[test]
564 fn test_validate_server_config_default_timeouts_pass() {
565 let config = ServerConfig::builder()
566 .command("docker".to_string())
567 .build();
568 assert!(validate_server_config(&config).is_ok());
569 }
570
571 #[test]
572 fn test_validate_server_config_zero_connect_timeout_rejected() {
573 let config = ServerConfig::builder()
574 .command("docker".to_string())
575 .connect_timeout(std::time::Duration::ZERO)
576 .build();
577 let result = validate_server_config(&config);
578 assert!(result.is_err());
579 if let Err(Error::ValidationError { field, reason }) = result {
580 assert_eq!(field, "connect_timeout");
581 assert!(reason.contains("greater than zero"));
582 } else {
583 panic!("expected ValidationError");
584 }
585 }
586
587 #[test]
588 fn test_validate_server_config_zero_discover_timeout_rejected() {
589 let config = ServerConfig::builder()
590 .command("docker".to_string())
591 .discover_timeout(std::time::Duration::ZERO)
592 .build();
593 let result = validate_server_config(&config);
594 assert!(result.is_err());
595 if let Err(Error::ValidationError { field, .. }) = result {
596 assert_eq!(field, "discover_timeout");
597 } else {
598 panic!("expected ValidationError");
599 }
600 }
601
602 #[test]
603 fn test_validate_server_config_above_max_timeout_rejected() {
604 let config = ServerConfig::builder()
605 .command("docker".to_string())
606 .connect_timeout(std::time::Duration::from_secs(601))
607 .build();
608 let result = validate_server_config(&config);
609 assert!(result.is_err());
610 if let Err(Error::ValidationError { field, reason }) = result {
611 assert_eq!(field, "connect_timeout");
612 assert!(reason.contains("exceeds maximum"));
613 } else {
614 panic!("expected ValidationError");
615 }
616 }
617
618 #[test]
619 fn test_validate_server_config_in_bounds_timeout_accepted() {
620 let config = ServerConfig::builder()
621 .command("docker".to_string())
622 .connect_timeout(std::time::Duration::from_mins(1))
623 .discover_timeout(std::time::Duration::from_mins(10))
624 .build();
625 assert!(validate_server_config(&config).is_ok());
626 }
627
628 #[test]
629 fn test_validate_env_name_edge_cases() {
630 assert!(validate_env_name("LD_PRELOAD").is_err());
632 assert!(validate_env_name("DYLD_TEST").is_err());
633 assert!(validate_env_name("PATH").is_err());
634
635 assert!(validate_env_name("LD_DEBUG").is_ok()); assert!(validate_env_name("MY_PATH").is_ok()); assert!(validate_env_name("DYLD").is_ok()); }
640}