1use crate::{Error, Result, ServerConfig, Transport};
39use std::path::Path;
40use std::time::Duration;
41
42const FORBIDDEN_CHARS: &[char] = &[';', '|', '&', '>', '<', '`', '$', '(', ')', '\n', '\r'];
44
45const FORBIDDEN_ENV_NAMES: &[&str] = &[
81 "LD_PRELOAD",
82 "LD_LIBRARY_PATH",
83 "LD_AUDIT",
84 "DYLD_INSERT_LIBRARIES",
85 "DYLD_LIBRARY_PATH",
86 "DYLD_FRAMEWORK_PATH",
87 "PATH", "NODE_OPTIONS", "BASH_ENV", "PYTHONPATH",
91 "PYTHONSTARTUP",
92 "RUBYOPT",
93 "PERL5OPT",
94 "JAVA_TOOL_OPTIONS",
95];
96
97const FORBIDDEN_ENV_PREFIX: &str = "DYLD_";
100
101const MAX_TIMEOUT: Duration = Duration::from_mins(10);
105
106pub const MAX_ARG_COUNT: usize = 256;
120
121pub const MAX_ARG_LEN: usize = 4096;
134
135pub const MAX_ENV_COUNT: usize = 256;
145
146pub const MAX_ENV_VALUE_LEN: usize = 32 * 1024;
159
160pub const MAX_HEADER_COUNT: usize = 128;
170
171pub const MAX_HEADER_VALUE_LEN: usize = 8 * 1024;
184
185pub const MAX_URL_LEN: usize = 8 * 1024;
199
200#[must_use]
216pub const fn forbidden_chars() -> &'static [char] {
217 FORBIDDEN_CHARS
218}
219
220#[must_use]
233pub const fn forbidden_env_names() -> &'static [&'static str] {
234 FORBIDDEN_ENV_NAMES
235}
236
237#[must_use]
248pub const fn forbidden_env_prefix() -> &'static str {
249 FORBIDDEN_ENV_PREFIX
250}
251
252pub fn validate_server_config(config: &ServerConfig) -> Result<()> {
344 match config.transport() {
345 Transport::Stdio {
346 command, args, env, ..
347 } => {
348 validate_stdio_size_bounds(command, args, env)?;
351 validate_stdio_config(command, args, env)?;
352 }
353 Transport::Http { url, headers } | Transport::Sse { url, headers } => {
354 validate_network_size_bounds(url, headers)?;
355 validate_network_config(url, headers)?;
356 }
357 }
358
359 validate_timeout(config.connect_timeout(), "connect_timeout")?;
365 validate_timeout(config.discover_timeout(), "discover_timeout")?;
366
367 Ok(())
368}
369
370fn validate_stdio_size_bounds(
382 command: &str,
383 args: &[String],
384 env: &std::collections::HashMap<String, String>,
385) -> Result<()> {
386 if command.len() > MAX_ARG_LEN {
387 return Err(Error::SecurityViolation {
388 reason: format!(
389 "command too long: {} bytes exceeds the {MAX_ARG_LEN} limit",
390 command.len()
391 ),
392 });
393 }
394
395 if args.len() > MAX_ARG_COUNT {
396 return Err(Error::SecurityViolation {
397 reason: format!(
398 "too many arguments: {} exceeds the {MAX_ARG_COUNT} limit",
399 args.len()
400 ),
401 });
402 }
403 for (idx, arg) in args.iter().enumerate() {
404 if arg.len() > MAX_ARG_LEN {
405 return Err(Error::SecurityViolation {
406 reason: format!(
407 "argument {idx} too long: {} bytes exceeds the {MAX_ARG_LEN} limit",
408 arg.len()
409 ),
410 });
411 }
412 }
413
414 if env.len() > MAX_ENV_COUNT {
415 return Err(Error::SecurityViolation {
416 reason: format!(
417 "too many environment variables: {} exceeds the {MAX_ENV_COUNT} limit",
418 env.len()
419 ),
420 });
421 }
422 for (env_name, env_value) in env {
423 if env_name.len() > MAX_ARG_LEN {
424 return Err(Error::SecurityViolation {
425 reason: format!(
426 "environment variable name too long: {} bytes exceeds the {MAX_ARG_LEN} \
427 limit",
428 env_name.len()
429 ),
430 });
431 }
432 if env_value.len() > MAX_ENV_VALUE_LEN {
433 return Err(Error::SecurityViolation {
434 reason: format!(
435 "environment variable '{env_name}' value too long: {} bytes exceeds the \
436 {MAX_ENV_VALUE_LEN} limit",
437 env_value.len()
438 ),
439 });
440 }
441 }
442
443 Ok(())
444}
445
446fn validate_network_size_bounds(
452 url: &str,
453 headers: &std::collections::HashMap<String, String>,
454) -> Result<()> {
455 if url.len() > MAX_URL_LEN {
456 return Err(Error::SecurityViolation {
457 reason: format!(
458 "url too long: {} bytes exceeds the {MAX_URL_LEN} limit",
459 url.len()
460 ),
461 });
462 }
463
464 if headers.len() > MAX_HEADER_COUNT {
465 return Err(Error::SecurityViolation {
466 reason: format!(
467 "too many headers: {} exceeds the {MAX_HEADER_COUNT} limit",
468 headers.len()
469 ),
470 });
471 }
472 for (name, value) in headers {
473 if name.len() > MAX_ARG_LEN {
474 return Err(Error::SecurityViolation {
475 reason: format!(
476 "header name too long: {} bytes exceeds the {MAX_ARG_LEN} limit",
477 name.len()
478 ),
479 });
480 }
481 if value.len() > MAX_HEADER_VALUE_LEN {
482 return Err(Error::SecurityViolation {
483 reason: format!(
484 "header value too long: {} bytes exceeds the {MAX_HEADER_VALUE_LEN} limit",
485 value.len()
486 ),
487 });
488 }
489 }
490
491 Ok(())
492}
493
494fn validate_stdio_config(
501 command: &str,
502 args: &[String],
503 env: &std::collections::HashMap<String, String>,
504) -> Result<()> {
505 validate_command_string(command, "command")?;
507
508 let command_path = Path::new(command);
510 if command_path.is_absolute() {
511 validate_absolute_path(command)?;
512 }
513 for (idx, arg) in args.iter().enumerate() {
517 validate_command_string(arg, &format!("argument {idx}"))?;
518 }
519
520 for env_name in env.keys() {
522 validate_env_name(env_name)?;
523 }
524
525 Ok(())
526}
527
528fn validate_network_config(
539 url: &str,
540 headers: &std::collections::HashMap<String, String>,
541) -> Result<()> {
542 validate_url_scheme(url)?;
543
544 let mut seen_header_names = std::collections::HashSet::new();
549 for (name, value) in headers {
550 validate_header_name_string(name)?;
551 validate_header_value_string(value)?;
552 if !seen_header_names.insert(name.to_ascii_lowercase()) {
553 return Err(Error::SecurityViolation {
554 reason: "duplicate header name (case-insensitive); name omitted as it may \
555 be secret-shaped"
556 .to_string(),
557 });
558 }
559 }
560
561 Ok(())
562}
563
564pub fn validate_url_scheme(url: &str) -> Result<()> {
594 let is_valid = url.split_once("://").is_some_and(|(scheme, _)| {
595 scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
596 });
597 if is_valid {
598 Ok(())
599 } else {
600 Err(Error::SecurityViolation {
601 reason: "url must use the http:// or https:// scheme".to_string(),
602 })
603 }
604}
605
606fn contains_control_char(value: &str) -> bool {
610 value.chars().any(char::is_control)
611}
612
613const fn is_header_name_tchar(c: char) -> bool {
616 c.is_ascii_alphanumeric()
617 || matches!(
618 c,
619 '!' | '#'
620 | '$'
621 | '%'
622 | '&'
623 | '\''
624 | '*'
625 | '+'
626 | '-'
627 | '.'
628 | '^'
629 | '_'
630 | '`'
631 | '|'
632 | '~'
633 )
634}
635
636fn validate_header_name_string(name: &str) -> Result<()> {
652 if name.is_empty() {
653 return Err(Error::SecurityViolation {
654 reason: "header name cannot be empty".to_string(),
655 });
656 }
657 if !name.chars().all(is_header_name_tchar) {
658 return Err(Error::SecurityViolation {
659 reason: "header name contains characters outside the allowed HTTP token charset"
660 .to_string(),
661 });
662 }
663 Ok(())
664}
665
666fn validate_header_value_string(value: &str) -> Result<()> {
678 if contains_control_char(value) {
679 return Err(Error::SecurityViolation {
680 reason: "header value contains control characters".to_string(),
681 });
682 }
683 Ok(())
684}
685
686fn validate_timeout(timeout: Duration, field: &str) -> Result<()> {
698 if timeout.is_zero() {
699 return Err(Error::ValidationError {
700 field: field.to_string(),
701 reason: "timeout must be greater than zero".to_string(),
702 });
703 }
704 if timeout > MAX_TIMEOUT {
705 return Err(Error::ValidationError {
706 field: field.to_string(),
707 reason: format!("timeout {timeout:?} exceeds maximum allowed {MAX_TIMEOUT:?}"),
708 });
709 }
710 Ok(())
711}
712
713fn validate_command_string(value: &str, context: &str) -> Result<()> {
727 let value = value.trim();
729 if value.is_empty() {
730 return Err(Error::SecurityViolation {
731 reason: format!("{context} cannot be empty"),
732 });
733 }
734
735 for forbidden in FORBIDDEN_CHARS {
737 if value.contains(*forbidden) {
738 return Err(Error::SecurityViolation {
739 reason: format!(
740 "{context} contains forbidden shell metacharacter '{forbidden}'; \
741 value omitted as it may be secret-shaped"
742 ),
743 });
744 }
745 }
746
747 Ok(())
748}
749
750fn validate_absolute_path(command: &str) -> Result<()> {
755 let path = Path::new(command);
756
757 if !path.exists() {
759 return Err(Error::SecurityViolation {
760 reason: format!("Command file does not exist: {command}"),
761 });
762 }
763
764 if !path.is_file() {
766 return Err(Error::SecurityViolation {
767 reason: format!("Command path is not a file: {command}"),
768 });
769 }
770
771 #[cfg(unix)]
773 {
774 use std::os::unix::fs::PermissionsExt;
775 let metadata = std::fs::metadata(path).map_err(|e| Error::SecurityViolation {
776 reason: format!("Cannot read command metadata: {e}"),
777 })?;
778 let permissions = metadata.permissions();
779 let mode = permissions.mode();
780
781 if mode & 0o111 == 0 {
783 return Err(Error::SecurityViolation {
784 reason: format!("Command file is not executable: {command}"),
785 });
786 }
787 }
788
789 Ok(())
790}
791
792fn validate_env_name(name: &str) -> Result<()> {
798 if FORBIDDEN_ENV_NAMES.contains(&name) {
800 return Err(Error::SecurityViolation {
801 reason: format!("Forbidden environment variable name: {name}"),
802 });
803 }
804
805 if name.starts_with(FORBIDDEN_ENV_PREFIX) {
807 return Err(Error::SecurityViolation {
808 reason: format!("Forbidden environment variable prefix DYLD_: {name}"),
809 });
810 }
811
812 Ok(())
813}
814
815#[cfg(test)]
816mod tests {
817 use super::*;
818 use std::collections::HashMap;
819 use std::fs;
820 use std::io::Write;
821
822 #[test]
823 fn test_validate_server_config_binary_name() {
824 assert!(
826 ServerConfig::builder()
827 .command("docker".to_string())
828 .build()
829 .is_ok()
830 );
831 assert!(
832 ServerConfig::builder()
833 .command("python".to_string())
834 .build()
835 .is_ok()
836 );
837 assert!(
838 ServerConfig::builder()
839 .command("node".to_string())
840 .build()
841 .is_ok()
842 );
843 }
844
845 #[test]
846 fn test_validate_server_config_binary_with_args() {
847 let result = ServerConfig::builder()
848 .command("docker".to_string())
849 .arg("run".to_string())
850 .arg("--rm".to_string())
851 .arg("mcp-server".to_string())
852 .build();
853 assert!(result.is_ok());
854 }
855
856 #[test]
857 fn test_validate_server_config_empty_command() {
858 let result = ServerConfig::builder().command(String::new()).build();
860 assert!(result.is_err());
861 assert!(result.unwrap_err().to_string().contains("empty"));
862
863 let result = ServerConfig::builder().command(" ".to_string()).build();
865 assert!(result.is_err());
866 assert!(result.unwrap_err().to_string().contains("empty"));
867 }
868
869 #[test]
870 fn test_validate_server_config_command_with_metacharacters() {
871 let dangerous_commands = vec![
872 "docker; rm -rf /",
873 "docker | cat",
874 "docker && echo pwned",
875 "docker > /tmp/out",
876 "docker < /tmp/in",
877 "docker `whoami`",
878 "docker $(whoami)",
879 "docker & background",
880 "docker\nrm -rf /",
881 ];
882
883 for cmd in dangerous_commands {
884 let result = ServerConfig::builder().command(cmd.to_string()).build();
887 assert!(
888 result.is_err(),
889 "Should reject command with metacharacters: {cmd}"
890 );
891 if let Err(Error::SecurityViolation { reason }) = result {
892 assert!(
893 reason.contains("forbidden") || reason.contains("metacharacter"),
894 "Error should mention forbidden character: {reason}"
895 );
896 }
897 }
898 }
899
900 #[test]
901 fn test_validate_server_config_args_with_metacharacters() {
902 let dangerous_args = vec![
903 "run; rm -rf /",
904 "run | cat",
905 "run && echo pwned",
906 "run > /tmp/out",
907 "run < /tmp/in",
908 "run `whoami`",
909 "run $(whoami)",
910 "run & background",
911 "run\nrm -rf /",
912 ];
913
914 for arg in dangerous_args {
915 let result = ServerConfig::builder()
916 .command("docker".to_string())
917 .arg(arg.to_string())
918 .build();
919 assert!(
920 result.is_err(),
921 "Should reject arg with metacharacters: {arg}"
922 );
923 if let Err(Error::SecurityViolation { reason }) = result {
924 assert!(
925 reason.contains("argument")
926 && (reason.contains("forbidden") || reason.contains("metacharacter")),
927 "Error should mention argument and forbidden character: {reason}"
928 );
929 }
930 }
931 }
932
933 #[test]
934 fn test_validate_server_config_arg_with_metacharacter_does_not_leak_secret() {
935 let secret_shaped_arg = "--api-key sk-live-supersecretvalue1234567890;whoami";
939 let result = ServerConfig::builder()
940 .command("docker".to_string())
941 .arg(secret_shaped_arg.to_string())
942 .build();
943
944 assert!(result.is_err());
945 if let Err(Error::SecurityViolation { reason }) = result {
946 assert!(!reason.contains(secret_shaped_arg));
947 assert!(!reason.contains("sk-live-supersecretvalue1234567890"));
948 }
949 }
950
951 #[test]
952 fn test_validate_server_config_empty_arg() {
953 let result = ServerConfig::builder()
954 .command("docker".to_string())
955 .arg(String::new())
956 .build();
957 assert!(result.is_err());
958 }
959
960 #[test]
961 fn test_validate_server_config_forbidden_env_ld_preload() {
962 let result = ServerConfig::builder()
963 .command("docker".to_string())
964 .env("LD_PRELOAD".to_string(), "/evil.so".to_string())
965 .build();
966 assert!(result.is_err());
967 if let Err(Error::SecurityViolation { reason }) = result {
968 assert!(reason.contains("LD_PRELOAD"));
969 }
970 }
971
972 #[test]
973 fn test_validate_server_config_forbidden_env_ld_library_path() {
974 let result = ServerConfig::builder()
975 .command("docker".to_string())
976 .env("LD_LIBRARY_PATH".to_string(), "/evil".to_string())
977 .build();
978 assert!(result.is_err());
979 if let Err(Error::SecurityViolation { reason }) = result {
980 assert!(reason.contains("LD_LIBRARY_PATH"));
981 }
982 }
983
984 #[test]
985 fn test_validate_server_config_forbidden_env_dyld() {
986 let dyld_vars = vec![
987 "DYLD_INSERT_LIBRARIES",
988 "DYLD_LIBRARY_PATH",
989 "DYLD_FRAMEWORK_PATH",
990 "DYLD_PRINT_TO_FILE",
991 "DYLD_CUSTOM_VAR",
992 ];
993
994 for var in dyld_vars {
995 let result = ServerConfig::builder()
996 .command("docker".to_string())
997 .env(var.to_string(), "/evil".to_string())
998 .build();
999 assert!(result.is_err(), "Should reject DYLD_* variable: {var}");
1000 if let Err(Error::SecurityViolation { reason }) = result {
1001 assert!(
1002 reason.contains("DYLD_"),
1003 "Error should mention DYLD_: {reason}"
1004 );
1005 }
1006 }
1007 }
1008
1009 #[test]
1010 fn test_validate_server_config_forbidden_env_path() {
1011 let result = ServerConfig::builder()
1012 .command("docker".to_string())
1013 .env("PATH".to_string(), "/evil:/usr/bin".to_string())
1014 .build();
1015 assert!(result.is_err());
1016 if let Err(Error::SecurityViolation { reason }) = result {
1017 assert!(reason.contains("PATH"));
1018 }
1019 }
1020
1021 #[test]
1024 fn test_validate_server_config_forbidden_env_interpreter_hijack_vectors() {
1025 let interpreter_vars = vec![
1027 "PYTHONPATH",
1028 "PYTHONSTARTUP",
1029 "RUBYOPT",
1030 "PERL5OPT",
1031 "JAVA_TOOL_OPTIONS",
1032 "LD_AUDIT",
1033 ];
1034
1035 for var in interpreter_vars {
1036 let result = ServerConfig::builder()
1037 .command("docker".to_string())
1038 .env(var.to_string(), "evil".to_string())
1039 .build();
1040 assert!(result.is_err(), "Should reject variable: {var}");
1041 if let Err(Error::SecurityViolation { reason }) = result {
1042 assert!(reason.contains(var), "Error should mention {var}: {reason}");
1043 }
1044 }
1045 }
1046
1047 #[test]
1048 fn test_validate_server_config_forbidden_env_node_options() {
1049 let result = ServerConfig::builder()
1052 .command("node".to_string())
1053 .env(
1054 "NODE_OPTIONS".to_string(),
1055 "--require /tmp/evil.js".to_string(),
1056 )
1057 .build();
1058 assert!(result.is_err());
1059 if let Err(Error::SecurityViolation { reason }) = result {
1060 assert!(reason.contains("NODE_OPTIONS"));
1061 }
1062 }
1063
1064 #[test]
1065 fn test_validate_server_config_forbidden_env_bash_env() {
1066 let result = ServerConfig::builder()
1068 .command("bash".to_string())
1069 .env("BASH_ENV".to_string(), "/tmp/evil.sh".to_string())
1070 .build();
1071 assert!(result.is_err());
1072 if let Err(Error::SecurityViolation { reason }) = result {
1073 assert!(reason.contains("BASH_ENV"));
1074 }
1075 }
1076
1077 #[test]
1078 fn test_validate_server_config_safe_env() {
1079 let result = ServerConfig::builder()
1080 .command("docker".to_string())
1081 .env("LOG_LEVEL".to_string(), "debug".to_string())
1082 .env("DEBUG".to_string(), "1".to_string())
1083 .env("HOME".to_string(), "/home/user".to_string())
1084 .env("MY_CUSTOM_VAR".to_string(), "value".to_string())
1085 .build();
1086 assert!(result.is_ok());
1087 }
1088
1089 #[test]
1090 #[cfg(unix)]
1091 fn test_validate_server_config_absolute_path_valid() {
1092 use std::os::unix::fs::PermissionsExt;
1093
1094 let temp_file = "/tmp/test-mcp-server-config";
1096 let mut file = fs::File::create(temp_file).unwrap();
1097 writeln!(file, "#!/bin/sh").unwrap();
1098
1099 let mut perms = fs::metadata(temp_file).unwrap().permissions();
1101 perms.set_mode(0o755);
1102 fs::set_permissions(temp_file, perms).unwrap();
1103
1104 let result = ServerConfig::builder()
1105 .command(temp_file.to_string())
1106 .arg("--port".to_string())
1107 .arg("8080".to_string())
1108 .build();
1109
1110 fs::remove_file(temp_file).ok();
1111
1112 assert!(result.is_ok());
1113 }
1114
1115 #[test]
1116 #[cfg(unix)]
1117 fn test_validate_server_config_absolute_path_not_executable() {
1118 use std::os::unix::fs::PermissionsExt;
1119
1120 let temp_file = "/tmp/test-mcp-server-config-noexec";
1122 let mut file = fs::File::create(temp_file).unwrap();
1123 writeln!(file, "#!/bin/sh").unwrap();
1124
1125 let mut perms = fs::metadata(temp_file).unwrap().permissions();
1127 perms.set_mode(0o644);
1128 fs::set_permissions(temp_file, perms).unwrap();
1129
1130 let result = ServerConfig::builder()
1131 .command(temp_file.to_string())
1132 .build();
1133
1134 fs::remove_file(temp_file).ok();
1135
1136 assert!(result.is_err());
1137 if let Err(Error::SecurityViolation { reason }) = result {
1138 assert!(reason.contains("not executable"));
1139 }
1140 }
1141
1142 #[test]
1143 fn test_validate_server_config_absolute_path_nonexistent() {
1144 #[cfg(unix)]
1145 let nonexistent = "/absolutely/nonexistent/path/to/server";
1146 #[cfg(windows)]
1147 let nonexistent = "C:\\absolutely\\nonexistent\\path\\to\\server.exe";
1148
1149 let result = ServerConfig::builder()
1150 .command(nonexistent.to_string())
1151 .build();
1152
1153 assert!(result.is_err());
1154 if let Err(Error::SecurityViolation { reason }) = result {
1155 assert!(reason.contains("does not exist"));
1156 }
1157 }
1158
1159 #[test]
1160 fn test_validate_server_config_with_cwd() {
1161 let result = ServerConfig::builder()
1163 .command("docker".to_string())
1164 .cwd(std::path::PathBuf::from("/tmp"))
1165 .build();
1166 assert!(result.is_ok());
1167 }
1168
1169 #[test]
1170 fn test_validate_server_config_complex_valid() {
1171 let result = ServerConfig::builder()
1172 .command("docker".to_string())
1173 .arg("run".to_string())
1174 .arg("--rm".to_string())
1175 .arg("-e".to_string())
1176 .arg("DEBUG=1".to_string())
1177 .arg("mcp-server".to_string())
1178 .env("LOG_LEVEL".to_string(), "info".to_string())
1179 .env("CACHE_DIR".to_string(), "/var/cache".to_string())
1180 .cwd(std::path::PathBuf::from("/opt/app"))
1181 .build();
1182 assert!(result.is_ok());
1183 }
1184
1185 #[test]
1186 fn test_validate_server_config_default_timeouts_pass() {
1187 let result = ServerConfig::builder()
1188 .command("docker".to_string())
1189 .build();
1190 assert!(result.is_ok());
1191 }
1192
1193 #[test]
1194 fn test_validate_server_config_zero_connect_timeout_rejected() {
1195 let result = ServerConfig::builder()
1196 .command("docker".to_string())
1197 .connect_timeout(std::time::Duration::ZERO)
1198 .build();
1199 assert!(result.is_err());
1200 if let Err(Error::ValidationError { field, reason }) = result {
1201 assert_eq!(field, "connect_timeout");
1202 assert!(reason.contains("greater than zero"));
1203 } else {
1204 panic!("expected ValidationError");
1205 }
1206 }
1207
1208 #[test]
1209 fn test_validate_server_config_zero_discover_timeout_rejected() {
1210 let result = ServerConfig::builder()
1211 .command("docker".to_string())
1212 .discover_timeout(std::time::Duration::ZERO)
1213 .build();
1214 assert!(result.is_err());
1215 if let Err(Error::ValidationError { field, .. }) = result {
1216 assert_eq!(field, "discover_timeout");
1217 } else {
1218 panic!("expected ValidationError");
1219 }
1220 }
1221
1222 #[test]
1223 fn test_validate_server_config_above_max_timeout_rejected() {
1224 let result = ServerConfig::builder()
1225 .command("docker".to_string())
1226 .connect_timeout(std::time::Duration::from_secs(601))
1227 .build();
1228 assert!(result.is_err());
1229 if let Err(Error::ValidationError { field, reason }) = result {
1230 assert_eq!(field, "connect_timeout");
1231 assert!(reason.contains("exceeds maximum"));
1232 } else {
1233 panic!("expected ValidationError");
1234 }
1235 }
1236
1237 #[test]
1238 fn test_validate_server_config_in_bounds_timeout_accepted() {
1239 let result = ServerConfig::builder()
1240 .command("docker".to_string())
1241 .connect_timeout(std::time::Duration::from_mins(1))
1242 .discover_timeout(std::time::Duration::from_mins(10))
1243 .build();
1244 assert!(result.is_ok());
1245 }
1246
1247 #[test]
1248 fn test_validate_env_name_edge_cases() {
1249 assert!(validate_env_name("LD_PRELOAD").is_err());
1251 assert!(validate_env_name("DYLD_TEST").is_err());
1252 assert!(validate_env_name("PATH").is_err());
1253
1254 assert!(validate_env_name("LD_DEBUG").is_ok()); assert!(validate_env_name("MY_PATH").is_ok()); assert!(validate_env_name("DYLD").is_ok()); }
1259
1260 #[test]
1263 fn test_validate_server_config_http_valid() {
1264 let result = ServerConfig::builder()
1265 .http_transport("https://api.example.com/mcp".to_string())
1266 .build();
1267 assert!(result.is_ok());
1268 }
1269
1270 #[test]
1271 fn test_validate_server_config_sse_valid() {
1272 let result = ServerConfig::builder()
1273 .sse_transport("https://api.example.com/sse".to_string())
1274 .build();
1275 assert!(result.is_ok());
1276 }
1277
1278 #[test]
1279 fn test_validate_server_config_http_with_valid_headers() {
1280 let result = ServerConfig::builder()
1281 .http_transport("https://api.example.com/mcp".to_string())
1282 .header("Authorization".to_string(), "Bearer token123".to_string())
1283 .build();
1284 assert!(result.is_ok());
1285 }
1286
1287 #[test]
1293 fn test_validate_server_config_http_missing_url_rejected() {
1294 let result: std::result::Result<ServerConfig, _> =
1295 serde_json::from_str(r#"{"transport": "http"}"#);
1296 assert!(result.is_err());
1297 }
1298
1299 #[test]
1300 fn test_validate_server_config_sse_missing_url_rejected() {
1301 let result: std::result::Result<ServerConfig, _> =
1302 serde_json::from_str(r#"{"transport": "sse"}"#);
1303 assert!(result.is_err());
1304 }
1305
1306 #[test]
1307 fn test_validate_server_config_http_rejects_non_http_scheme() {
1308 for url in [
1309 "file:///etc/passwd",
1310 "unix:///tmp/socket",
1311 "ftp://host/path",
1312 ] {
1313 let result = ServerConfig::builder()
1314 .http_transport(url.to_string())
1315 .build();
1316 assert!(result.is_err(), "should reject scheme: {url}");
1317 if let Err(Error::SecurityViolation { reason }) = result {
1318 assert!(reason.contains("http://") || reason.contains("https://"));
1319 } else {
1320 panic!("expected SecurityViolation for url: {url}");
1321 }
1322 }
1323 }
1324
1325 #[test]
1326 fn test_validate_server_config_http_accepts_case_insensitive_scheme() {
1327 for url in ["HTTP://api.example.com/mcp", "HTTPS://api.example.com/mcp"] {
1328 let result = ServerConfig::builder()
1329 .http_transport(url.to_string())
1330 .build();
1331 assert!(
1332 result.is_ok(),
1333 "should accept case-insensitive scheme: {url}"
1334 );
1335 }
1336 }
1337
1338 #[test]
1339 fn test_validate_server_config_http_rejects_scheme_lookalike() {
1340 let result = ServerConfig::builder()
1342 .http_transport("httpsomething://api.example.com/mcp".to_string())
1343 .build();
1344 assert!(result.is_err());
1345 }
1346
1347 #[test]
1348 fn test_validate_server_config_http_rejects_duplicate_header_case_insensitive() {
1349 let result = ServerConfig::builder()
1350 .http_transport("https://api.example.com/mcp".to_string())
1351 .header("Authorization".to_string(), "Bearer one".to_string())
1352 .header("authorization".to_string(), "Bearer two".to_string())
1353 .build();
1354
1355 assert!(result.is_err());
1356 if let Err(Error::SecurityViolation { reason }) = result {
1357 assert!(reason.contains("duplicate header"));
1358 assert!(!reason.contains("Authorization"));
1359 assert!(!reason.to_ascii_lowercase().contains("authorization"));
1360 } else {
1361 panic!("expected SecurityViolation for duplicate header name");
1362 }
1363 }
1364
1365 #[test]
1366 fn test_validate_server_config_http_rejects_duplicate_header_secret_shaped_name() {
1367 let secret_name = "eyJhbGciOiJIUzI1NiJ9.super-secret-token-material";
1373 let result = ServerConfig::builder()
1374 .http_transport("https://api.example.com/mcp".to_string())
1375 .header(secret_name.to_string(), "value one".to_string())
1376 .header(secret_name.to_ascii_uppercase(), "value two".to_string())
1377 .build();
1378
1379 assert!(result.is_err());
1380 if let Err(Error::SecurityViolation { reason }) = result {
1381 assert!(reason.contains("duplicate header"));
1382 assert!(!reason.contains(secret_name));
1383 assert!(
1384 !reason
1385 .to_ascii_lowercase()
1386 .contains(&secret_name.to_ascii_lowercase())
1387 );
1388 } else {
1389 panic!("expected SecurityViolation for duplicate header name");
1390 }
1391 }
1392
1393 #[test]
1394 fn test_validate_server_config_http_rejects_header_name_with_invalid_tchar() {
1395 for bad_name in ["X Bad Header", "X:Bad", "X@Bad"] {
1398 let result = ServerConfig::builder()
1399 .http_transport("https://api.example.com/mcp".to_string())
1400 .header(bad_name.to_string(), "value".to_string())
1401 .build();
1402
1403 assert!(result.is_err(), "should reject header name: {bad_name}");
1404 if let Err(Error::SecurityViolation { reason }) = result {
1405 assert!(reason.contains("header name"));
1406 assert!(!reason.contains(bad_name));
1407 } else {
1408 panic!("expected SecurityViolation for header name: {bad_name}");
1409 }
1410 }
1411 }
1412
1413 #[test]
1414 fn test_validate_server_config_http_rejects_secret_shaped_header_name_without_leaking_it() {
1415 let secret_name = "aGVsbG8/d29ybGQK=supersecretpayload";
1421 let result = ServerConfig::builder()
1422 .http_transport("https://api.example.com/mcp".to_string())
1423 .header(secret_name.to_string(), "value".to_string())
1424 .build();
1425
1426 assert!(result.is_err());
1427 if let Err(Error::SecurityViolation { reason }) = result {
1428 assert!(reason.contains("header name"));
1429 assert!(!reason.contains(secret_name));
1430 assert!(!reason.contains("aGVsbG8"));
1431 } else {
1432 panic!("expected SecurityViolation for header name");
1433 }
1434 }
1435
1436 #[test]
1437 fn test_validate_server_config_http_rejects_control_char_in_header_name() {
1438 let result = ServerConfig::builder()
1439 .http_transport("https://api.example.com/mcp".to_string())
1440 .header("X-Bad\r\nHeader".to_string(), "value".to_string())
1441 .build();
1442
1443 assert!(result.is_err());
1444 if let Err(Error::SecurityViolation { reason }) = result {
1445 assert!(reason.contains("header name"));
1446 assert!(!reason.contains("X-Bad"));
1447 } else {
1448 panic!("expected SecurityViolation for header name");
1449 }
1450 }
1451
1452 #[test]
1453 fn test_validate_server_config_http_rejects_control_char_in_header_value() {
1454 let result = ServerConfig::builder()
1455 .http_transport("https://api.example.com/mcp".to_string())
1456 .header(
1457 "Authorization".to_string(),
1458 "Bearer sekrit\r\nX-Injected: evil".to_string(),
1459 )
1460 .build();
1461
1462 assert!(result.is_err());
1463 if let Err(Error::SecurityViolation { reason }) = result {
1464 assert!(reason.contains("header value"));
1465 assert!(!reason.contains("Authorization"));
1470 assert!(!reason.contains("sekrit"));
1471 assert!(!reason.contains("X-Injected"));
1472 } else {
1473 panic!("expected SecurityViolation for header value");
1474 }
1475 }
1476
1477 #[test]
1478 fn test_validate_server_config_http_rejects_control_char_in_value_with_secret_shaped_name() {
1479 let secret_name = "eyJhbGciOiJIUzI1NiJ9.abc-secret_material";
1484 let result = ServerConfig::builder()
1485 .http_transport("https://api.example.com/mcp".to_string())
1486 .header(secret_name.to_string(), "x\ry".to_string())
1487 .build();
1488
1489 assert!(result.is_err());
1490 if let Err(Error::SecurityViolation { reason }) = result {
1491 assert!(reason.contains("header value"));
1492 assert!(!reason.contains(secret_name));
1493 assert!(!reason.contains("eyJhbGciOiJIUzI1NiJ9"));
1494 } else {
1495 panic!("expected SecurityViolation for header value");
1496 }
1497 }
1498
1499 #[test]
1502 fn test_validate_server_config_rejects_too_many_args() {
1503 let args = (0..=MAX_ARG_COUNT).map(|i| format!("a{i}")).collect();
1504 let result = ServerConfig::builder()
1505 .command("docker".to_string())
1506 .args(args)
1507 .build();
1508 assert!(result.is_err());
1509 if let Err(Error::SecurityViolation { reason }) = result {
1510 assert!(reason.contains("too many arguments"));
1511 } else {
1512 panic!("expected SecurityViolation for too many arguments");
1513 }
1514 }
1515
1516 #[test]
1517 fn test_validate_server_config_accepts_max_arg_count() {
1518 let args = (0..MAX_ARG_COUNT).map(|i| format!("a{i}")).collect();
1519 let result = ServerConfig::builder()
1520 .command("docker".to_string())
1521 .args(args)
1522 .build();
1523 assert!(result.is_ok());
1524 }
1525
1526 #[test]
1527 fn test_validate_server_config_rejects_oversized_arg() {
1528 let long_arg = "a".repeat(MAX_ARG_LEN + 1);
1529 let result = ServerConfig::builder()
1530 .command("docker".to_string())
1531 .arg(long_arg)
1532 .build();
1533 assert!(result.is_err());
1534 if let Err(Error::SecurityViolation { reason }) = result {
1535 assert!(reason.contains("too long"));
1536 } else {
1537 panic!("expected SecurityViolation for oversized argument");
1538 }
1539 }
1540
1541 #[test]
1542 fn test_validate_server_config_accepts_arg_at_max_len() {
1543 let arg_at_cap = "a".repeat(MAX_ARG_LEN);
1544 let result = ServerConfig::builder()
1545 .command("docker".to_string())
1546 .arg(arg_at_cap)
1547 .build();
1548 assert!(result.is_ok());
1549 }
1550
1551 #[test]
1552 fn test_validate_server_config_rejects_oversized_command() {
1553 let long_command = "a".repeat(MAX_ARG_LEN + 1);
1554 let result = ServerConfig::builder().command(long_command).build();
1555 assert!(result.is_err());
1556 if let Err(Error::SecurityViolation { reason }) = result {
1557 assert!(reason.contains("too long"));
1558 } else {
1559 panic!("expected SecurityViolation for oversized command");
1560 }
1561 }
1562
1563 #[test]
1564 fn test_validate_server_config_rejects_too_many_env_vars() {
1565 let env: HashMap<String, String> = (0..=MAX_ENV_COUNT)
1566 .map(|i| (format!("VAR_{i}"), "value".to_string()))
1567 .collect();
1568 let result = ServerConfig::builder()
1569 .command("docker".to_string())
1570 .environment(env)
1571 .build();
1572 assert!(result.is_err());
1573 if let Err(Error::SecurityViolation { reason }) = result {
1574 assert!(reason.contains("too many environment variables"));
1575 } else {
1576 panic!("expected SecurityViolation for too many env vars");
1577 }
1578 }
1579
1580 #[test]
1581 fn test_validate_server_config_accepts_max_env_count() {
1582 let env: HashMap<String, String> = (0..MAX_ENV_COUNT)
1583 .map(|i| (format!("VAR_{i}"), "value".to_string()))
1584 .collect();
1585 let result = ServerConfig::builder()
1586 .command("docker".to_string())
1587 .environment(env)
1588 .build();
1589 assert!(result.is_ok());
1590 }
1591
1592 #[test]
1593 fn test_validate_server_config_rejects_oversized_env_value() {
1594 let long_value = "v".repeat(MAX_ENV_VALUE_LEN + 1);
1595 let result = ServerConfig::builder()
1596 .command("docker".to_string())
1597 .env("MY_VAR".to_string(), long_value)
1598 .build();
1599 assert!(result.is_err());
1600 if let Err(Error::SecurityViolation { reason }) = result {
1601 assert!(reason.contains("too long"));
1602 } else {
1603 panic!("expected SecurityViolation for oversized env value");
1604 }
1605 }
1606
1607 #[test]
1608 fn test_validate_server_config_accepts_env_value_at_max_len() {
1609 let value_at_cap = "v".repeat(MAX_ENV_VALUE_LEN);
1610 let result = ServerConfig::builder()
1611 .command("docker".to_string())
1612 .env("MY_VAR".to_string(), value_at_cap)
1613 .build();
1614 assert!(result.is_ok());
1615 }
1616
1617 #[test]
1618 fn test_validate_server_config_rejects_oversized_env_name() {
1619 let long_name = "V".repeat(MAX_ARG_LEN + 1);
1620 let result = ServerConfig::builder()
1621 .command("docker".to_string())
1622 .env(long_name, "value".to_string())
1623 .build();
1624 assert!(result.is_err());
1625 if let Err(Error::SecurityViolation { reason }) = result {
1626 assert!(reason.contains("too long"));
1627 } else {
1628 panic!("expected SecurityViolation for oversized env name");
1629 }
1630 }
1631
1632 #[test]
1633 fn test_validate_server_config_rejects_too_many_headers() {
1634 let headers: HashMap<String, String> = (0..=MAX_HEADER_COUNT)
1635 .map(|i| (format!("X-Header-{i}"), "value".to_string()))
1636 .collect();
1637 let result = ServerConfig::builder()
1638 .http_transport("https://api.example.com/mcp".to_string())
1639 .headers(headers)
1640 .build();
1641 assert!(result.is_err());
1642 if let Err(Error::SecurityViolation { reason }) = result {
1643 assert!(reason.contains("too many headers"));
1644 } else {
1645 panic!("expected SecurityViolation for too many headers");
1646 }
1647 }
1648
1649 #[test]
1650 fn test_validate_server_config_accepts_max_header_count() {
1651 let headers: HashMap<String, String> = (0..MAX_HEADER_COUNT)
1652 .map(|i| (format!("X-Header-{i}"), "value".to_string()))
1653 .collect();
1654 let result = ServerConfig::builder()
1655 .http_transport("https://api.example.com/mcp".to_string())
1656 .headers(headers)
1657 .build();
1658 assert!(result.is_ok());
1659 }
1660
1661 #[test]
1662 fn test_validate_server_config_rejects_oversized_header_value() {
1663 let long_value = "v".repeat(MAX_HEADER_VALUE_LEN + 1);
1664 let result = ServerConfig::builder()
1665 .http_transport("https://api.example.com/mcp".to_string())
1666 .header("Authorization".to_string(), long_value)
1667 .build();
1668 assert!(result.is_err());
1669 if let Err(Error::SecurityViolation { reason }) = result {
1670 assert!(reason.contains("too long"));
1671 } else {
1672 panic!("expected SecurityViolation for oversized header value");
1673 }
1674 }
1675
1676 #[test]
1677 fn test_validate_server_config_accepts_header_value_at_max_len() {
1678 let value_at_cap = "v".repeat(MAX_HEADER_VALUE_LEN);
1679 let result = ServerConfig::builder()
1680 .http_transport("https://api.example.com/mcp".to_string())
1681 .header("Authorization".to_string(), value_at_cap)
1682 .build();
1683 assert!(result.is_ok());
1684 }
1685
1686 #[test]
1697 fn test_deserialize_ignores_cross_transport_command_field() {
1698 let json = serde_json::json!({
1699 "transport": "http",
1700 "url": "https://api.example.com/mcp",
1701 "command": "a".repeat(MAX_ARG_LEN + 1),
1702 });
1703 let config: ServerConfig = serde_json::from_value(json).expect("valid ServerConfig JSON");
1704
1705 assert!(config.command().is_none());
1708 assert!(validate_server_config(&config).is_ok());
1709 }
1710
1711 #[test]
1712 fn test_deserialize_ignores_cross_transport_headers_field() {
1713 let headers: HashMap<String, String> = (0..=MAX_HEADER_COUNT)
1714 .map(|i| (format!("X-Header-{i}"), "value".to_string()))
1715 .collect();
1716 let json = serde_json::json!({
1717 "transport": "stdio",
1718 "command": "docker",
1719 "headers": headers,
1720 });
1721 let config: ServerConfig = serde_json::from_value(json).expect("valid ServerConfig JSON");
1722
1723 assert!(config.headers().is_empty());
1724 assert!(validate_server_config(&config).is_ok());
1725 }
1726
1727 #[test]
1728 fn test_validate_server_config_http_rejects_url_too_long() {
1729 let long_url = format!("https://example.com/{}", "a".repeat(MAX_URL_LEN));
1730 let result = ServerConfig::builder().http_transport(long_url).build();
1731
1732 assert!(result.is_err());
1733 if let Err(Error::SecurityViolation { reason }) = result {
1734 assert!(reason.contains("url too long"));
1735 } else {
1736 panic!("expected SecurityViolation for oversized url");
1737 }
1738 }
1739
1740 #[test]
1741 fn test_validate_server_config_http_accepts_url_at_max_len() {
1742 let prefix = "https://example.com/";
1743 let padding_len = MAX_URL_LEN - prefix.len();
1744 let url_at_cap = format!("{prefix}{}", "a".repeat(padding_len));
1745 assert_eq!(url_at_cap.len(), MAX_URL_LEN);
1746
1747 let result = ServerConfig::builder().http_transport(url_at_cap).build();
1748 assert!(result.is_ok());
1749 }
1750
1751 #[test]
1752 fn test_validate_server_config_http_rejects_header_name_too_long() {
1753 let long_name = format!("X-{}", "a".repeat(MAX_ARG_LEN));
1754 let result = ServerConfig::builder()
1755 .http_transport("https://api.example.com/mcp".to_string())
1756 .header(long_name, "value".to_string())
1757 .build();
1758
1759 assert!(result.is_err());
1760 if let Err(Error::SecurityViolation { reason }) = result {
1761 assert!(reason.contains("header name too long"));
1762 } else {
1763 panic!("expected SecurityViolation for oversized header name");
1764 }
1765 }
1766
1767 #[test]
1768 fn test_validate_server_config_http_accepts_header_name_at_max_len() {
1769 let name_at_cap = "a".repeat(MAX_ARG_LEN);
1770 let result = ServerConfig::builder()
1771 .http_transport("https://api.example.com/mcp".to_string())
1772 .header(name_at_cap, "value".to_string())
1773 .build();
1774
1775 assert!(result.is_ok());
1776 }
1777
1778 #[test]
1779 fn test_validate_server_config_http_timeout_bounds_still_enforced() {
1780 let result = ServerConfig::builder()
1781 .http_transport("https://api.example.com/mcp".to_string())
1782 .connect_timeout(std::time::Duration::ZERO)
1783 .build();
1784
1785 assert!(result.is_err());
1786 if let Err(Error::ValidationError { field, .. }) = result {
1787 assert_eq!(field, "connect_timeout");
1788 } else {
1789 panic!("expected ValidationError for connect_timeout");
1790 }
1791 }
1792}