Skip to main content

mcp_execution_core/
command.rs

1//! Command validation and sanitization for secure subprocess execution.
2//!
3//! This module provides security-focused validation of server configurations before
4//! they are executed as subprocesses, preventing command injection attacks.
5//!
6//! # Security
7//!
8//! The validation enforces:
9//! - Command validation (absolute path or binary name)
10//! - Argument sanitization (no shell metacharacters)
11//! - Environment variable validation (block dangerous names)
12//! - Executable permission checks (for absolute paths)
13//!
14//! # Examples
15//!
16//! ```
17//! use mcp_execution_core::{ServerConfig, validate_server_config};
18//!
19//! // Valid binary name (resolved via PATH)
20//! let config = ServerConfig::builder()
21//!     .command("docker".to_string())
22//!     .arg("run".to_string())
23//!     .build();
24//! assert!(validate_server_config(&config).is_ok());
25//!
26//! // Invalid: shell metacharacters in arg
27//! let config = ServerConfig::builder()
28//!     .command("docker".to_string())
29//!     .arg("run; rm -rf /".to_string())
30//!     .build();
31//! assert!(validate_server_config(&config).is_err());
32//! ```
33
34use crate::{Error, Result, ServerConfig};
35use std::path::Path;
36
37/// Shell metacharacters that indicate potential command injection.
38const FORBIDDEN_CHARS: &[char] = &[';', '|', '&', '>', '<', '`', '$', '(', ')', '\n', '\r'];
39
40/// Forbidden environment variable names that pose security risks.
41const 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", // Block PATH override to prevent binary substitution
48];
49
50/// Validates a `ServerConfig` for safe subprocess execution.
51///
52/// This function performs comprehensive security validation to prevent
53/// command injection attacks. It validates:
54///
55/// 1. **Command**: Can be absolute path (with existence/permission checks) or binary name
56/// 2. **Arguments**: Each arg checked for shell metacharacters
57/// 3. **Environment**: Variables checked for dangerous names
58///
59/// # Security Rules
60///
61/// - **Forbidden chars in command/args**: `;`, `|`, `&`, `>`, `<`, `` ` ``, `$`, `(`, `)`, `\n`, `\r`
62/// - **Forbidden env names**: `LD_PRELOAD`, `LD_LIBRARY_PATH`, `DYLD_*`, `PATH`
63/// - **Absolute paths**: Must exist and be executable
64/// - **Binary names**: Allowed (resolved via PATH at runtime)
65///
66/// # Errors
67///
68/// Returns `Error::SecurityViolation` if:
69/// - Command is empty or whitespace
70/// - Command/args contain shell metacharacters
71/// - Absolute path does not exist or is not executable
72/// - Environment variable name is forbidden
73///
74/// # Examples
75///
76/// ```
77/// use mcp_execution_core::{ServerConfig, validate_server_config};
78///
79/// // Valid: binary name
80/// let config = ServerConfig::builder()
81///     .command("docker".to_string())
82///     .build();
83/// assert!(validate_server_config(&config).is_ok());
84///
85/// // Invalid: forbidden env var
86/// let config = ServerConfig::builder()
87///     .command("docker".to_string())
88///     .env("LD_PRELOAD".to_string(), "/evil.so".to_string())
89///     .build();
90/// assert!(validate_server_config(&config).is_err());
91/// ```
92///
93/// # Security Considerations
94///
95/// - Binary names are allowed and resolved via PATH at runtime
96/// - Absolute paths undergo strict validation (existence, permissions)
97/// - All arguments are validated separately to prevent injection
98/// - Environment variables are checked against forbidden names
99pub fn validate_server_config(config: &ServerConfig) -> Result<()> {
100    // Validate command
101    validate_command_string(&config.command, "command")?;
102
103    // If command is absolute path, perform additional checks
104    let command_path = Path::new(&config.command);
105    if command_path.is_absolute() {
106        validate_absolute_path(&config.command)?;
107    }
108    // If not absolute, it's a binary name (to be resolved via PATH) - this is OK
109
110    // Validate each argument separately
111    for (idx, arg) in config.args.iter().enumerate() {
112        validate_command_string(arg, &format!("argument {idx}"))?;
113    }
114
115    // Validate environment variable names
116    for env_name in config.env.keys() {
117        validate_env_name(env_name)?;
118    }
119
120    Ok(())
121}
122
123/// Validates a command string for forbidden shell metacharacters.
124///
125/// This is an internal helper that checks a string (command or argument)
126/// for dangerous shell metacharacters.
127fn validate_command_string(value: &str, context: &str) -> Result<()> {
128    // Check for empty
129    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    // Check for shell metacharacters
137    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
150/// Validates an absolute path command for existence and executability.
151///
152/// This is an internal helper that performs file system checks on
153/// absolute path commands.
154fn validate_absolute_path(command: &str) -> Result<()> {
155    let path = Path::new(command);
156
157    // Verify file exists
158    if !path.exists() {
159        return Err(Error::SecurityViolation {
160            reason: format!("Command file does not exist: {command}"),
161        });
162    }
163
164    // Verify it's a file (not a directory)
165    if !path.is_file() {
166        return Err(Error::SecurityViolation {
167            reason: format!("Command path is not a file: {command}"),
168        });
169    }
170
171    // Verify executable permissions (Unix only)
172    #[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        // Check if any execute bit is set (owner, group, or other)
182        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
192/// Validates an environment variable name.
193///
194/// This is an internal helper that checks if an environment variable
195/// name is in the forbidden list.
196fn validate_env_name(name: &str) -> Result<()> {
197    // Check for forbidden env names (exact match)
198    if FORBIDDEN_ENV_NAMES.contains(&name) {
199        return Err(Error::SecurityViolation {
200            reason: format!("Forbidden environment variable name: {name}"),
201        });
202    }
203
204    // Check for DYLD_* prefix (macOS dynamic linker variables)
205    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        // Binary names (not absolute paths) should be valid
223        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        // Empty command should fail during build
251        let result = ServerConfig::builder().command(String::new()).try_build();
252        assert!(result.is_err());
253        assert!(result.unwrap_err().contains("empty"));
254
255        // Whitespace-only command should fail during build
256        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        // Create a temporary executable file
419        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        // Set execute permissions
424        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        // Create a temporary non-executable file
446        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        // Remove execute permissions
451        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        // cwd doesn't affect validation (it's not security-critical)
489        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        // Test exact matches and prefix matches
515        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        // These should be OK (not in forbidden list)
520        assert!(validate_env_name("LD_DEBUG").is_ok()); // Not in list
521        assert!(validate_env_name("MY_PATH").is_ok()); // Not exact match
522        assert!(validate_env_name("DYLD").is_ok()); // No underscore, not prefix match
523    }
524}