use crate::{Error, Result, ServerConfig, Transport};
use std::path::Path;
use std::time::Duration;
const FORBIDDEN_CHARS: &[char] = &[';', '|', '&', '>', '<', '`', '$', '(', ')', '\n', '\r'];
const FORBIDDEN_ENV_NAMES: &[&str] = &[
"LD_PRELOAD",
"LD_LIBRARY_PATH",
"LD_AUDIT",
"DYLD_INSERT_LIBRARIES",
"DYLD_LIBRARY_PATH",
"DYLD_FRAMEWORK_PATH",
"PATH", "NODE_OPTIONS", "BASH_ENV", "PYTHONPATH",
"PYTHONSTARTUP",
"RUBYOPT",
"PERL5OPT",
"JAVA_TOOL_OPTIONS",
];
const FORBIDDEN_ENV_PREFIX: &str = "DYLD_";
const MAX_TIMEOUT: Duration = Duration::from_mins(10);
pub const MAX_ARG_COUNT: usize = 256;
pub const MAX_ARG_LEN: usize = 4096;
pub const MAX_ENV_COUNT: usize = 256;
pub const MAX_ENV_VALUE_LEN: usize = 32 * 1024;
pub const MAX_HEADER_COUNT: usize = 128;
pub const MAX_HEADER_VALUE_LEN: usize = 8 * 1024;
pub const MAX_URL_LEN: usize = 8 * 1024;
#[must_use]
pub const fn forbidden_chars() -> &'static [char] {
FORBIDDEN_CHARS
}
#[must_use]
pub const fn forbidden_env_names() -> &'static [&'static str] {
FORBIDDEN_ENV_NAMES
}
#[must_use]
pub const fn forbidden_env_prefix() -> &'static str {
FORBIDDEN_ENV_PREFIX
}
pub fn validate_server_config(config: &ServerConfig) -> Result<()> {
match config.transport() {
Transport::Stdio {
command, args, env, ..
} => {
validate_stdio_size_bounds(command, args, env)?;
validate_stdio_config(command, args, env)?;
}
Transport::Http { url, headers } | Transport::Sse { url, headers } => {
validate_network_size_bounds(url, headers)?;
validate_network_config(url, headers)?;
}
}
validate_timeout(config.connect_timeout(), "connect_timeout")?;
validate_timeout(config.discover_timeout(), "discover_timeout")?;
Ok(())
}
fn validate_stdio_size_bounds(
command: &str,
args: &[String],
env: &std::collections::HashMap<String, String>,
) -> Result<()> {
if command.len() > MAX_ARG_LEN {
return Err(Error::SecurityViolation {
reason: format!(
"command too long: {} bytes exceeds the {MAX_ARG_LEN} limit",
command.len()
),
});
}
if args.len() > MAX_ARG_COUNT {
return Err(Error::SecurityViolation {
reason: format!(
"too many arguments: {} exceeds the {MAX_ARG_COUNT} limit",
args.len()
),
});
}
for (idx, arg) in args.iter().enumerate() {
if arg.len() > MAX_ARG_LEN {
return Err(Error::SecurityViolation {
reason: format!(
"argument {idx} too long: {} bytes exceeds the {MAX_ARG_LEN} limit",
arg.len()
),
});
}
}
if env.len() > MAX_ENV_COUNT {
return Err(Error::SecurityViolation {
reason: format!(
"too many environment variables: {} exceeds the {MAX_ENV_COUNT} limit",
env.len()
),
});
}
for (env_name, env_value) in env {
if env_name.len() > MAX_ARG_LEN {
return Err(Error::SecurityViolation {
reason: format!(
"environment variable name too long: {} bytes exceeds the {MAX_ARG_LEN} \
limit",
env_name.len()
),
});
}
if env_value.len() > MAX_ENV_VALUE_LEN {
return Err(Error::SecurityViolation {
reason: format!(
"environment variable '{env_name}' value too long: {} bytes exceeds the \
{MAX_ENV_VALUE_LEN} limit",
env_value.len()
),
});
}
}
Ok(())
}
fn validate_network_size_bounds(
url: &str,
headers: &std::collections::HashMap<String, String>,
) -> Result<()> {
if url.len() > MAX_URL_LEN {
return Err(Error::SecurityViolation {
reason: format!(
"url too long: {} bytes exceeds the {MAX_URL_LEN} limit",
url.len()
),
});
}
if headers.len() > MAX_HEADER_COUNT {
return Err(Error::SecurityViolation {
reason: format!(
"too many headers: {} exceeds the {MAX_HEADER_COUNT} limit",
headers.len()
),
});
}
for (name, value) in headers {
if name.len() > MAX_ARG_LEN {
return Err(Error::SecurityViolation {
reason: format!(
"header name too long: {} bytes exceeds the {MAX_ARG_LEN} limit",
name.len()
),
});
}
if value.len() > MAX_HEADER_VALUE_LEN {
return Err(Error::SecurityViolation {
reason: format!(
"header value too long: {} bytes exceeds the {MAX_HEADER_VALUE_LEN} limit",
value.len()
),
});
}
}
Ok(())
}
fn validate_stdio_config(
command: &str,
args: &[String],
env: &std::collections::HashMap<String, String>,
) -> Result<()> {
validate_command_string(command, "command")?;
let command_path = Path::new(command);
if command_path.is_absolute() {
validate_absolute_path(command)?;
}
for (idx, arg) in args.iter().enumerate() {
validate_command_string(arg, &format!("argument {idx}"))?;
}
for env_name in env.keys() {
validate_env_name(env_name)?;
}
Ok(())
}
fn validate_network_config(
url: &str,
headers: &std::collections::HashMap<String, String>,
) -> Result<()> {
validate_url_scheme(url)?;
let mut seen_header_names = std::collections::HashSet::new();
for (name, value) in headers {
validate_header_name_string(name)?;
validate_header_value_string(value)?;
if !seen_header_names.insert(name.to_ascii_lowercase()) {
return Err(Error::SecurityViolation {
reason: "duplicate header name (case-insensitive); name omitted as it may \
be secret-shaped"
.to_string(),
});
}
}
Ok(())
}
pub fn validate_url_scheme(url: &str) -> Result<()> {
let is_valid = url.split_once("://").is_some_and(|(scheme, _)| {
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
});
if is_valid {
Ok(())
} else {
Err(Error::SecurityViolation {
reason: "url must use the http:// or https:// scheme".to_string(),
})
}
}
fn contains_control_char(value: &str) -> bool {
value.chars().any(char::is_control)
}
const fn is_header_name_tchar(c: char) -> bool {
c.is_ascii_alphanumeric()
|| matches!(
c,
'!' | '#'
| '$'
| '%'
| '&'
| '\''
| '*'
| '+'
| '-'
| '.'
| '^'
| '_'
| '`'
| '|'
| '~'
)
}
fn validate_header_name_string(name: &str) -> Result<()> {
if name.is_empty() {
return Err(Error::SecurityViolation {
reason: "header name cannot be empty".to_string(),
});
}
if !name.chars().all(is_header_name_tchar) {
return Err(Error::SecurityViolation {
reason: "header name contains characters outside the allowed HTTP token charset"
.to_string(),
});
}
Ok(())
}
fn validate_header_value_string(value: &str) -> Result<()> {
if contains_control_char(value) {
return Err(Error::SecurityViolation {
reason: "header value contains control characters".to_string(),
});
}
Ok(())
}
fn validate_timeout(timeout: Duration, field: &str) -> Result<()> {
if timeout.is_zero() {
return Err(Error::ValidationError {
field: field.to_string(),
reason: "timeout must be greater than zero".to_string(),
});
}
if timeout > MAX_TIMEOUT {
return Err(Error::ValidationError {
field: field.to_string(),
reason: format!("timeout {timeout:?} exceeds maximum allowed {MAX_TIMEOUT:?}"),
});
}
Ok(())
}
fn validate_command_string(value: &str, context: &str) -> Result<()> {
let value = value.trim();
if value.is_empty() {
return Err(Error::SecurityViolation {
reason: format!("{context} cannot be empty"),
});
}
for forbidden in FORBIDDEN_CHARS {
if value.contains(*forbidden) {
return Err(Error::SecurityViolation {
reason: format!(
"{context} contains forbidden shell metacharacter '{forbidden}'; \
value omitted as it may be secret-shaped"
),
});
}
}
Ok(())
}
fn validate_absolute_path(command: &str) -> Result<()> {
let path = Path::new(command);
if !path.exists() {
return Err(Error::SecurityViolation {
reason: format!("Command file does not exist: {command}"),
});
}
if !path.is_file() {
return Err(Error::SecurityViolation {
reason: format!("Command path is not a file: {command}"),
});
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let metadata = std::fs::metadata(path).map_err(|e| Error::SecurityViolation {
reason: format!("Cannot read command metadata: {e}"),
})?;
let permissions = metadata.permissions();
let mode = permissions.mode();
if mode & 0o111 == 0 {
return Err(Error::SecurityViolation {
reason: format!("Command file is not executable: {command}"),
});
}
}
Ok(())
}
fn validate_env_name(name: &str) -> Result<()> {
if FORBIDDEN_ENV_NAMES.contains(&name) {
return Err(Error::SecurityViolation {
reason: format!("Forbidden environment variable name: {name}"),
});
}
if name.starts_with(FORBIDDEN_ENV_PREFIX) {
return Err(Error::SecurityViolation {
reason: format!("Forbidden environment variable prefix DYLD_: {name}"),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::fs;
use std::io::Write;
#[test]
fn test_validate_server_config_binary_name() {
assert!(
ServerConfig::builder()
.command("docker".to_string())
.build()
.is_ok()
);
assert!(
ServerConfig::builder()
.command("python".to_string())
.build()
.is_ok()
);
assert!(
ServerConfig::builder()
.command("node".to_string())
.build()
.is_ok()
);
}
#[test]
fn test_validate_server_config_binary_with_args() {
let result = ServerConfig::builder()
.command("docker".to_string())
.arg("run".to_string())
.arg("--rm".to_string())
.arg("mcp-server".to_string())
.build();
assert!(result.is_ok());
}
#[test]
fn test_validate_server_config_empty_command() {
let result = ServerConfig::builder().command(String::new()).build();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("empty"));
let result = ServerConfig::builder().command(" ".to_string()).build();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("empty"));
}
#[test]
fn test_validate_server_config_command_with_metacharacters() {
let dangerous_commands = vec![
"docker; rm -rf /",
"docker | cat",
"docker && echo pwned",
"docker > /tmp/out",
"docker < /tmp/in",
"docker `whoami`",
"docker $(whoami)",
"docker & background",
"docker\nrm -rf /",
];
for cmd in dangerous_commands {
let result = ServerConfig::builder().command(cmd.to_string()).build();
assert!(
result.is_err(),
"Should reject command with metacharacters: {cmd}"
);
if let Err(Error::SecurityViolation { reason }) = result {
assert!(
reason.contains("forbidden") || reason.contains("metacharacter"),
"Error should mention forbidden character: {reason}"
);
}
}
}
#[test]
fn test_validate_server_config_args_with_metacharacters() {
let dangerous_args = vec![
"run; rm -rf /",
"run | cat",
"run && echo pwned",
"run > /tmp/out",
"run < /tmp/in",
"run `whoami`",
"run $(whoami)",
"run & background",
"run\nrm -rf /",
];
for arg in dangerous_args {
let result = ServerConfig::builder()
.command("docker".to_string())
.arg(arg.to_string())
.build();
assert!(
result.is_err(),
"Should reject arg with metacharacters: {arg}"
);
if let Err(Error::SecurityViolation { reason }) = result {
assert!(
reason.contains("argument")
&& (reason.contains("forbidden") || reason.contains("metacharacter")),
"Error should mention argument and forbidden character: {reason}"
);
}
}
}
#[test]
fn test_validate_server_config_arg_with_metacharacter_does_not_leak_secret() {
let secret_shaped_arg = "--api-key sk-live-supersecretvalue1234567890;whoami";
let result = ServerConfig::builder()
.command("docker".to_string())
.arg(secret_shaped_arg.to_string())
.build();
assert!(result.is_err());
if let Err(Error::SecurityViolation { reason }) = result {
assert!(!reason.contains(secret_shaped_arg));
assert!(!reason.contains("sk-live-supersecretvalue1234567890"));
}
}
#[test]
fn test_validate_server_config_empty_arg() {
let result = ServerConfig::builder()
.command("docker".to_string())
.arg(String::new())
.build();
assert!(result.is_err());
}
#[test]
fn test_validate_server_config_forbidden_env_ld_preload() {
let result = ServerConfig::builder()
.command("docker".to_string())
.env("LD_PRELOAD".to_string(), "/evil.so".to_string())
.build();
assert!(result.is_err());
if let Err(Error::SecurityViolation { reason }) = result {
assert!(reason.contains("LD_PRELOAD"));
}
}
#[test]
fn test_validate_server_config_forbidden_env_ld_library_path() {
let result = ServerConfig::builder()
.command("docker".to_string())
.env("LD_LIBRARY_PATH".to_string(), "/evil".to_string())
.build();
assert!(result.is_err());
if let Err(Error::SecurityViolation { reason }) = result {
assert!(reason.contains("LD_LIBRARY_PATH"));
}
}
#[test]
fn test_validate_server_config_forbidden_env_dyld() {
let dyld_vars = vec![
"DYLD_INSERT_LIBRARIES",
"DYLD_LIBRARY_PATH",
"DYLD_FRAMEWORK_PATH",
"DYLD_PRINT_TO_FILE",
"DYLD_CUSTOM_VAR",
];
for var in dyld_vars {
let result = ServerConfig::builder()
.command("docker".to_string())
.env(var.to_string(), "/evil".to_string())
.build();
assert!(result.is_err(), "Should reject DYLD_* variable: {var}");
if let Err(Error::SecurityViolation { reason }) = result {
assert!(
reason.contains("DYLD_"),
"Error should mention DYLD_: {reason}"
);
}
}
}
#[test]
fn test_validate_server_config_forbidden_env_path() {
let result = ServerConfig::builder()
.command("docker".to_string())
.env("PATH".to_string(), "/evil:/usr/bin".to_string())
.build();
assert!(result.is_err());
if let Err(Error::SecurityViolation { reason }) = result {
assert!(reason.contains("PATH"));
}
}
#[test]
fn test_validate_server_config_forbidden_env_interpreter_hijack_vectors() {
let interpreter_vars = vec![
"PYTHONPATH",
"PYTHONSTARTUP",
"RUBYOPT",
"PERL5OPT",
"JAVA_TOOL_OPTIONS",
"LD_AUDIT",
];
for var in interpreter_vars {
let result = ServerConfig::builder()
.command("docker".to_string())
.env(var.to_string(), "evil".to_string())
.build();
assert!(result.is_err(), "Should reject variable: {var}");
if let Err(Error::SecurityViolation { reason }) = result {
assert!(reason.contains(var), "Error should mention {var}: {reason}");
}
}
}
#[test]
fn test_validate_server_config_forbidden_env_node_options() {
let result = ServerConfig::builder()
.command("node".to_string())
.env(
"NODE_OPTIONS".to_string(),
"--require /tmp/evil.js".to_string(),
)
.build();
assert!(result.is_err());
if let Err(Error::SecurityViolation { reason }) = result {
assert!(reason.contains("NODE_OPTIONS"));
}
}
#[test]
fn test_validate_server_config_forbidden_env_bash_env() {
let result = ServerConfig::builder()
.command("bash".to_string())
.env("BASH_ENV".to_string(), "/tmp/evil.sh".to_string())
.build();
assert!(result.is_err());
if let Err(Error::SecurityViolation { reason }) = result {
assert!(reason.contains("BASH_ENV"));
}
}
#[test]
fn test_validate_server_config_safe_env() {
let result = ServerConfig::builder()
.command("docker".to_string())
.env("LOG_LEVEL".to_string(), "debug".to_string())
.env("DEBUG".to_string(), "1".to_string())
.env("HOME".to_string(), "/home/user".to_string())
.env("MY_CUSTOM_VAR".to_string(), "value".to_string())
.build();
assert!(result.is_ok());
}
#[test]
#[cfg(unix)]
fn test_validate_server_config_absolute_path_valid() {
use std::os::unix::fs::PermissionsExt;
let temp_file = "/tmp/test-mcp-server-config";
let mut file = fs::File::create(temp_file).unwrap();
writeln!(file, "#!/bin/sh").unwrap();
let mut perms = fs::metadata(temp_file).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(temp_file, perms).unwrap();
let result = ServerConfig::builder()
.command(temp_file.to_string())
.arg("--port".to_string())
.arg("8080".to_string())
.build();
fs::remove_file(temp_file).ok();
assert!(result.is_ok());
}
#[test]
#[cfg(unix)]
fn test_validate_server_config_absolute_path_not_executable() {
use std::os::unix::fs::PermissionsExt;
let temp_file = "/tmp/test-mcp-server-config-noexec";
let mut file = fs::File::create(temp_file).unwrap();
writeln!(file, "#!/bin/sh").unwrap();
let mut perms = fs::metadata(temp_file).unwrap().permissions();
perms.set_mode(0o644);
fs::set_permissions(temp_file, perms).unwrap();
let result = ServerConfig::builder()
.command(temp_file.to_string())
.build();
fs::remove_file(temp_file).ok();
assert!(result.is_err());
if let Err(Error::SecurityViolation { reason }) = result {
assert!(reason.contains("not executable"));
}
}
#[test]
fn test_validate_server_config_absolute_path_nonexistent() {
#[cfg(unix)]
let nonexistent = "/absolutely/nonexistent/path/to/server";
#[cfg(windows)]
let nonexistent = "C:\\absolutely\\nonexistent\\path\\to\\server.exe";
let result = ServerConfig::builder()
.command(nonexistent.to_string())
.build();
assert!(result.is_err());
if let Err(Error::SecurityViolation { reason }) = result {
assert!(reason.contains("does not exist"));
}
}
#[test]
fn test_validate_server_config_with_cwd() {
let result = ServerConfig::builder()
.command("docker".to_string())
.cwd(std::path::PathBuf::from("/tmp"))
.build();
assert!(result.is_ok());
}
#[test]
fn test_validate_server_config_complex_valid() {
let result = ServerConfig::builder()
.command("docker".to_string())
.arg("run".to_string())
.arg("--rm".to_string())
.arg("-e".to_string())
.arg("DEBUG=1".to_string())
.arg("mcp-server".to_string())
.env("LOG_LEVEL".to_string(), "info".to_string())
.env("CACHE_DIR".to_string(), "/var/cache".to_string())
.cwd(std::path::PathBuf::from("/opt/app"))
.build();
assert!(result.is_ok());
}
#[test]
fn test_validate_server_config_default_timeouts_pass() {
let result = ServerConfig::builder()
.command("docker".to_string())
.build();
assert!(result.is_ok());
}
#[test]
fn test_validate_server_config_zero_connect_timeout_rejected() {
let result = ServerConfig::builder()
.command("docker".to_string())
.connect_timeout(std::time::Duration::ZERO)
.build();
assert!(result.is_err());
if let Err(Error::ValidationError { field, reason }) = result {
assert_eq!(field, "connect_timeout");
assert!(reason.contains("greater than zero"));
} else {
panic!("expected ValidationError");
}
}
#[test]
fn test_validate_server_config_zero_discover_timeout_rejected() {
let result = ServerConfig::builder()
.command("docker".to_string())
.discover_timeout(std::time::Duration::ZERO)
.build();
assert!(result.is_err());
if let Err(Error::ValidationError { field, .. }) = result {
assert_eq!(field, "discover_timeout");
} else {
panic!("expected ValidationError");
}
}
#[test]
fn test_validate_server_config_above_max_timeout_rejected() {
let result = ServerConfig::builder()
.command("docker".to_string())
.connect_timeout(std::time::Duration::from_secs(601))
.build();
assert!(result.is_err());
if let Err(Error::ValidationError { field, reason }) = result {
assert_eq!(field, "connect_timeout");
assert!(reason.contains("exceeds maximum"));
} else {
panic!("expected ValidationError");
}
}
#[test]
fn test_validate_server_config_in_bounds_timeout_accepted() {
let result = ServerConfig::builder()
.command("docker".to_string())
.connect_timeout(std::time::Duration::from_mins(1))
.discover_timeout(std::time::Duration::from_mins(10))
.build();
assert!(result.is_ok());
}
#[test]
fn test_validate_env_name_edge_cases() {
assert!(validate_env_name("LD_PRELOAD").is_err());
assert!(validate_env_name("DYLD_TEST").is_err());
assert!(validate_env_name("PATH").is_err());
assert!(validate_env_name("LD_DEBUG").is_ok()); assert!(validate_env_name("MY_PATH").is_ok()); assert!(validate_env_name("DYLD").is_ok()); }
#[test]
fn test_validate_server_config_http_valid() {
let result = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.build();
assert!(result.is_ok());
}
#[test]
fn test_validate_server_config_sse_valid() {
let result = ServerConfig::builder()
.sse_transport("https://api.example.com/sse".to_string())
.build();
assert!(result.is_ok());
}
#[test]
fn test_validate_server_config_http_with_valid_headers() {
let result = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.header("Authorization".to_string(), "Bearer token123".to_string())
.build();
assert!(result.is_ok());
}
#[test]
fn test_validate_server_config_http_missing_url_rejected() {
let result: std::result::Result<ServerConfig, _> =
serde_json::from_str(r#"{"transport": "http"}"#);
assert!(result.is_err());
}
#[test]
fn test_validate_server_config_sse_missing_url_rejected() {
let result: std::result::Result<ServerConfig, _> =
serde_json::from_str(r#"{"transport": "sse"}"#);
assert!(result.is_err());
}
#[test]
fn test_validate_server_config_http_rejects_non_http_scheme() {
for url in [
"file:///etc/passwd",
"unix:///tmp/socket",
"ftp://host/path",
] {
let result = ServerConfig::builder()
.http_transport(url.to_string())
.build();
assert!(result.is_err(), "should reject scheme: {url}");
if let Err(Error::SecurityViolation { reason }) = result {
assert!(reason.contains("http://") || reason.contains("https://"));
} else {
panic!("expected SecurityViolation for url: {url}");
}
}
}
#[test]
fn test_validate_server_config_http_accepts_case_insensitive_scheme() {
for url in ["HTTP://api.example.com/mcp", "HTTPS://api.example.com/mcp"] {
let result = ServerConfig::builder()
.http_transport(url.to_string())
.build();
assert!(
result.is_ok(),
"should accept case-insensitive scheme: {url}"
);
}
}
#[test]
fn test_validate_server_config_http_rejects_scheme_lookalike() {
let result = ServerConfig::builder()
.http_transport("httpsomething://api.example.com/mcp".to_string())
.build();
assert!(result.is_err());
}
#[test]
fn test_validate_server_config_http_rejects_duplicate_header_case_insensitive() {
let result = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.header("Authorization".to_string(), "Bearer one".to_string())
.header("authorization".to_string(), "Bearer two".to_string())
.build();
assert!(result.is_err());
if let Err(Error::SecurityViolation { reason }) = result {
assert!(reason.contains("duplicate header"));
assert!(!reason.contains("Authorization"));
assert!(!reason.to_ascii_lowercase().contains("authorization"));
} else {
panic!("expected SecurityViolation for duplicate header name");
}
}
#[test]
fn test_validate_server_config_http_rejects_duplicate_header_secret_shaped_name() {
let secret_name = "eyJhbGciOiJIUzI1NiJ9.super-secret-token-material";
let result = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.header(secret_name.to_string(), "value one".to_string())
.header(secret_name.to_ascii_uppercase(), "value two".to_string())
.build();
assert!(result.is_err());
if let Err(Error::SecurityViolation { reason }) = result {
assert!(reason.contains("duplicate header"));
assert!(!reason.contains(secret_name));
assert!(
!reason
.to_ascii_lowercase()
.contains(&secret_name.to_ascii_lowercase())
);
} else {
panic!("expected SecurityViolation for duplicate header name");
}
}
#[test]
fn test_validate_server_config_http_rejects_header_name_with_invalid_tchar() {
for bad_name in ["X Bad Header", "X:Bad", "X@Bad"] {
let result = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.header(bad_name.to_string(), "value".to_string())
.build();
assert!(result.is_err(), "should reject header name: {bad_name}");
if let Err(Error::SecurityViolation { reason }) = result {
assert!(reason.contains("header name"));
assert!(!reason.contains(bad_name));
} else {
panic!("expected SecurityViolation for header name: {bad_name}");
}
}
}
#[test]
fn test_validate_server_config_http_rejects_secret_shaped_header_name_without_leaking_it() {
let secret_name = "aGVsbG8/d29ybGQK=supersecretpayload";
let result = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.header(secret_name.to_string(), "value".to_string())
.build();
assert!(result.is_err());
if let Err(Error::SecurityViolation { reason }) = result {
assert!(reason.contains("header name"));
assert!(!reason.contains(secret_name));
assert!(!reason.contains("aGVsbG8"));
} else {
panic!("expected SecurityViolation for header name");
}
}
#[test]
fn test_validate_server_config_http_rejects_control_char_in_header_name() {
let result = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.header("X-Bad\r\nHeader".to_string(), "value".to_string())
.build();
assert!(result.is_err());
if let Err(Error::SecurityViolation { reason }) = result {
assert!(reason.contains("header name"));
assert!(!reason.contains("X-Bad"));
} else {
panic!("expected SecurityViolation for header name");
}
}
#[test]
fn test_validate_server_config_http_rejects_control_char_in_header_value() {
let result = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.header(
"Authorization".to_string(),
"Bearer sekrit\r\nX-Injected: evil".to_string(),
)
.build();
assert!(result.is_err());
if let Err(Error::SecurityViolation { reason }) = result {
assert!(reason.contains("header value"));
assert!(!reason.contains("Authorization"));
assert!(!reason.contains("sekrit"));
assert!(!reason.contains("X-Injected"));
} else {
panic!("expected SecurityViolation for header value");
}
}
#[test]
fn test_validate_server_config_http_rejects_control_char_in_value_with_secret_shaped_name() {
let secret_name = "eyJhbGciOiJIUzI1NiJ9.abc-secret_material";
let result = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.header(secret_name.to_string(), "x\ry".to_string())
.build();
assert!(result.is_err());
if let Err(Error::SecurityViolation { reason }) = result {
assert!(reason.contains("header value"));
assert!(!reason.contains(secret_name));
assert!(!reason.contains("eyJhbGciOiJIUzI1NiJ9"));
} else {
panic!("expected SecurityViolation for header value");
}
}
#[test]
fn test_validate_server_config_rejects_too_many_args() {
let args = (0..=MAX_ARG_COUNT).map(|i| format!("a{i}")).collect();
let result = ServerConfig::builder()
.command("docker".to_string())
.args(args)
.build();
assert!(result.is_err());
if let Err(Error::SecurityViolation { reason }) = result {
assert!(reason.contains("too many arguments"));
} else {
panic!("expected SecurityViolation for too many arguments");
}
}
#[test]
fn test_validate_server_config_accepts_max_arg_count() {
let args = (0..MAX_ARG_COUNT).map(|i| format!("a{i}")).collect();
let result = ServerConfig::builder()
.command("docker".to_string())
.args(args)
.build();
assert!(result.is_ok());
}
#[test]
fn test_validate_server_config_rejects_oversized_arg() {
let long_arg = "a".repeat(MAX_ARG_LEN + 1);
let result = ServerConfig::builder()
.command("docker".to_string())
.arg(long_arg)
.build();
assert!(result.is_err());
if let Err(Error::SecurityViolation { reason }) = result {
assert!(reason.contains("too long"));
} else {
panic!("expected SecurityViolation for oversized argument");
}
}
#[test]
fn test_validate_server_config_accepts_arg_at_max_len() {
let arg_at_cap = "a".repeat(MAX_ARG_LEN);
let result = ServerConfig::builder()
.command("docker".to_string())
.arg(arg_at_cap)
.build();
assert!(result.is_ok());
}
#[test]
fn test_validate_server_config_rejects_oversized_command() {
let long_command = "a".repeat(MAX_ARG_LEN + 1);
let result = ServerConfig::builder().command(long_command).build();
assert!(result.is_err());
if let Err(Error::SecurityViolation { reason }) = result {
assert!(reason.contains("too long"));
} else {
panic!("expected SecurityViolation for oversized command");
}
}
#[test]
fn test_validate_server_config_rejects_too_many_env_vars() {
let env: HashMap<String, String> = (0..=MAX_ENV_COUNT)
.map(|i| (format!("VAR_{i}"), "value".to_string()))
.collect();
let result = ServerConfig::builder()
.command("docker".to_string())
.environment(env)
.build();
assert!(result.is_err());
if let Err(Error::SecurityViolation { reason }) = result {
assert!(reason.contains("too many environment variables"));
} else {
panic!("expected SecurityViolation for too many env vars");
}
}
#[test]
fn test_validate_server_config_accepts_max_env_count() {
let env: HashMap<String, String> = (0..MAX_ENV_COUNT)
.map(|i| (format!("VAR_{i}"), "value".to_string()))
.collect();
let result = ServerConfig::builder()
.command("docker".to_string())
.environment(env)
.build();
assert!(result.is_ok());
}
#[test]
fn test_validate_server_config_rejects_oversized_env_value() {
let long_value = "v".repeat(MAX_ENV_VALUE_LEN + 1);
let result = ServerConfig::builder()
.command("docker".to_string())
.env("MY_VAR".to_string(), long_value)
.build();
assert!(result.is_err());
if let Err(Error::SecurityViolation { reason }) = result {
assert!(reason.contains("too long"));
} else {
panic!("expected SecurityViolation for oversized env value");
}
}
#[test]
fn test_validate_server_config_accepts_env_value_at_max_len() {
let value_at_cap = "v".repeat(MAX_ENV_VALUE_LEN);
let result = ServerConfig::builder()
.command("docker".to_string())
.env("MY_VAR".to_string(), value_at_cap)
.build();
assert!(result.is_ok());
}
#[test]
fn test_validate_server_config_rejects_oversized_env_name() {
let long_name = "V".repeat(MAX_ARG_LEN + 1);
let result = ServerConfig::builder()
.command("docker".to_string())
.env(long_name, "value".to_string())
.build();
assert!(result.is_err());
if let Err(Error::SecurityViolation { reason }) = result {
assert!(reason.contains("too long"));
} else {
panic!("expected SecurityViolation for oversized env name");
}
}
#[test]
fn test_validate_server_config_rejects_too_many_headers() {
let headers: HashMap<String, String> = (0..=MAX_HEADER_COUNT)
.map(|i| (format!("X-Header-{i}"), "value".to_string()))
.collect();
let result = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.headers(headers)
.build();
assert!(result.is_err());
if let Err(Error::SecurityViolation { reason }) = result {
assert!(reason.contains("too many headers"));
} else {
panic!("expected SecurityViolation for too many headers");
}
}
#[test]
fn test_validate_server_config_accepts_max_header_count() {
let headers: HashMap<String, String> = (0..MAX_HEADER_COUNT)
.map(|i| (format!("X-Header-{i}"), "value".to_string()))
.collect();
let result = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.headers(headers)
.build();
assert!(result.is_ok());
}
#[test]
fn test_validate_server_config_rejects_oversized_header_value() {
let long_value = "v".repeat(MAX_HEADER_VALUE_LEN + 1);
let result = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.header("Authorization".to_string(), long_value)
.build();
assert!(result.is_err());
if let Err(Error::SecurityViolation { reason }) = result {
assert!(reason.contains("too long"));
} else {
panic!("expected SecurityViolation for oversized header value");
}
}
#[test]
fn test_validate_server_config_accepts_header_value_at_max_len() {
let value_at_cap = "v".repeat(MAX_HEADER_VALUE_LEN);
let result = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.header("Authorization".to_string(), value_at_cap)
.build();
assert!(result.is_ok());
}
#[test]
fn test_deserialize_ignores_cross_transport_command_field() {
let json = serde_json::json!({
"transport": "http",
"url": "https://api.example.com/mcp",
"command": "a".repeat(MAX_ARG_LEN + 1),
});
let config: ServerConfig = serde_json::from_value(json).expect("valid ServerConfig JSON");
assert!(config.command().is_none());
assert!(validate_server_config(&config).is_ok());
}
#[test]
fn test_deserialize_ignores_cross_transport_headers_field() {
let headers: HashMap<String, String> = (0..=MAX_HEADER_COUNT)
.map(|i| (format!("X-Header-{i}"), "value".to_string()))
.collect();
let json = serde_json::json!({
"transport": "stdio",
"command": "docker",
"headers": headers,
});
let config: ServerConfig = serde_json::from_value(json).expect("valid ServerConfig JSON");
assert!(config.headers().is_empty());
assert!(validate_server_config(&config).is_ok());
}
#[test]
fn test_validate_server_config_http_rejects_url_too_long() {
let long_url = format!("https://example.com/{}", "a".repeat(MAX_URL_LEN));
let result = ServerConfig::builder().http_transport(long_url).build();
assert!(result.is_err());
if let Err(Error::SecurityViolation { reason }) = result {
assert!(reason.contains("url too long"));
} else {
panic!("expected SecurityViolation for oversized url");
}
}
#[test]
fn test_validate_server_config_http_accepts_url_at_max_len() {
let prefix = "https://example.com/";
let padding_len = MAX_URL_LEN - prefix.len();
let url_at_cap = format!("{prefix}{}", "a".repeat(padding_len));
assert_eq!(url_at_cap.len(), MAX_URL_LEN);
let result = ServerConfig::builder().http_transport(url_at_cap).build();
assert!(result.is_ok());
}
#[test]
fn test_validate_server_config_http_rejects_header_name_too_long() {
let long_name = format!("X-{}", "a".repeat(MAX_ARG_LEN));
let result = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.header(long_name, "value".to_string())
.build();
assert!(result.is_err());
if let Err(Error::SecurityViolation { reason }) = result {
assert!(reason.contains("header name too long"));
} else {
panic!("expected SecurityViolation for oversized header name");
}
}
#[test]
fn test_validate_server_config_http_accepts_header_name_at_max_len() {
let name_at_cap = "a".repeat(MAX_ARG_LEN);
let result = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.header(name_at_cap, "value".to_string())
.build();
assert!(result.is_ok());
}
#[test]
fn test_validate_server_config_http_timeout_bounds_still_enforced() {
let result = ServerConfig::builder()
.http_transport("https://api.example.com/mcp".to_string())
.connect_timeout(std::time::Duration::ZERO)
.build();
assert!(result.is_err());
if let Err(Error::ValidationError { field, .. }) = result {
assert_eq!(field, "connect_timeout");
} else {
panic!("expected ValidationError for connect_timeout");
}
}
}