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;
36use std::time::Duration;
37
38/// Shell metacharacters that indicate potential command injection.
39const FORBIDDEN_CHARS: &[char] = &[';', '|', '&', '>', '<', '`', '$', '(', ')', '\n', '\r'];
40
41/// Forbidden environment variable names that pose security risks.
42const 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", // Block PATH override to prevent binary substitution
49];
50
51/// Upper bound for `connect_timeout`/`discover_timeout`, matching the
52/// 30-second defaults declared in `server_config.rs` with headroom for
53/// slow-starting servers configured via `mcp.json`.
54const MAX_TIMEOUT: Duration = Duration::from_mins(10);
55
56/// Validates a `ServerConfig` for safe subprocess execution.
57///
58/// This function performs comprehensive security validation to prevent
59/// command injection attacks. It validates:
60///
61/// 1. **Command**: Can be absolute path (with existence/permission checks) or binary name
62/// 2. **Arguments**: Each arg checked for shell metacharacters
63/// 3. **Environment**: Variables checked for dangerous names
64/// 4. **Timeouts**: `connect_timeout`/`discover_timeout` checked against bounds
65///
66/// # Security Rules
67///
68/// - **Forbidden chars in command/args**: `;`, `|`, `&`, `>`, `<`, `` ` ``, `$`, `(`, `)`, `\n`, `\r`
69/// - **Forbidden env names**: `LD_PRELOAD`, `LD_LIBRARY_PATH`, `DYLD_*`, `PATH`
70/// - **Absolute paths**: Must exist and be executable
71/// - **Binary names**: Allowed (resolved via PATH at runtime)
72/// - **Timeout bounds**: `connect_timeout`/`discover_timeout` must be greater than zero and at
73///   most `MAX_TIMEOUT` (600s)
74///
75/// # Errors
76///
77/// Returns `Error::SecurityViolation` if:
78/// - Command is empty or whitespace
79/// - Command/args contain shell metacharacters
80/// - Absolute path does not exist or is not executable
81/// - Environment variable name is forbidden
82///
83/// Returns `Error::ValidationError` if:
84/// - `connect_timeout` or `discover_timeout` is zero
85/// - `connect_timeout` or `discover_timeout` exceeds `MAX_TIMEOUT` (600s)
86///
87/// # Examples
88///
89/// ```
90/// use mcp_execution_core::{ServerConfig, validate_server_config};
91///
92/// // Valid: binary name
93/// let config = ServerConfig::builder()
94///     .command("docker".to_string())
95///     .build();
96/// assert!(validate_server_config(&config).is_ok());
97///
98/// // Invalid: forbidden env var
99/// let config = ServerConfig::builder()
100///     .command("docker".to_string())
101///     .env("LD_PRELOAD".to_string(), "/evil.so".to_string())
102///     .build();
103/// assert!(validate_server_config(&config).is_err());
104/// ```
105///
106/// # Security Considerations
107///
108/// - Binary names are allowed and resolved via PATH at runtime
109/// - Absolute paths undergo strict validation (existence, permissions)
110/// - All arguments are validated separately to prevent injection
111/// - Environment variables are checked against forbidden names
112/// - There is no infinite-timeout option: `0` is always rejected, since an
113///   unbounded wait would let a hung server block this non-interactive tool
114///   forever (see the `validate_timeout` design note in this module)
115pub fn validate_server_config(config: &ServerConfig) -> Result<()> {
116    // Validate command
117    validate_command_string(&config.command, "command")?;
118
119    // If command is absolute path, perform additional checks
120    let command_path = Path::new(&config.command);
121    if command_path.is_absolute() {
122        validate_absolute_path(&config.command)?;
123    }
124    // If not absolute, it's a binary name (to be resolved via PATH) - this is OK
125
126    // Validate each argument separately
127    for (idx, arg) in config.args.iter().enumerate() {
128        validate_command_string(arg, &format!("argument {idx}"))?;
129    }
130
131    // Validate environment variable names
132    for env_name in config.env.keys() {
133        validate_env_name(env_name)?;
134    }
135
136    // Validate timeout bounds. Zero fires immediately and breaks all
137    // discovery; an infinite timeout is deliberately unsupported (see
138    // `validate_timeout` doc comment) because it would let a hung or
139    // malicious server block this non-interactive CLI tool forever,
140    // re-opening the DoS window these timeouts were introduced to close.
141    validate_timeout(config.connect_timeout(), "connect_timeout")?;
142    validate_timeout(config.discover_timeout(), "discover_timeout")?;
143
144    Ok(())
145}
146
147/// Validates that a timeout is within `(0, MAX_TIMEOUT]`.
148///
149/// # Design Note: No Infinite Timeout
150///
151/// A timeout of zero is permanently rejected rather than treated as a
152/// sentinel for "no timeout". This tool spawns subprocesses and connects to
153/// servers non-interactively (CLI and MCP-server modes); an unbounded
154/// connect/discover wait would let a hung or malicious server block the
155/// caller indefinitely, which is exactly the denial-of-service exposure
156/// these timeouts were added to close. Callers that need a longer wait
157/// should raise the value up to `MAX_TIMEOUT` (10 minutes) instead.
158fn 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
174/// Validates a command string for forbidden shell metacharacters.
175///
176/// This is an internal helper that checks a string (command or argument)
177/// for dangerous shell metacharacters.
178fn validate_command_string(value: &str, context: &str) -> Result<()> {
179    // Check for empty
180    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    // Check for shell metacharacters
188    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
201/// Validates an absolute path command for existence and executability.
202///
203/// This is an internal helper that performs file system checks on
204/// absolute path commands.
205fn validate_absolute_path(command: &str) -> Result<()> {
206    let path = Path::new(command);
207
208    // Verify file exists
209    if !path.exists() {
210        return Err(Error::SecurityViolation {
211            reason: format!("Command file does not exist: {command}"),
212        });
213    }
214
215    // Verify it's a file (not a directory)
216    if !path.is_file() {
217        return Err(Error::SecurityViolation {
218            reason: format!("Command path is not a file: {command}"),
219        });
220    }
221
222    // Verify executable permissions (Unix only)
223    #[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        // Check if any execute bit is set (owner, group, or other)
233        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
243/// Validates an environment variable name.
244///
245/// This is an internal helper that checks if an environment variable
246/// name is in the forbidden list.
247fn validate_env_name(name: &str) -> Result<()> {
248    // Check for forbidden env names (exact match)
249    if FORBIDDEN_ENV_NAMES.contains(&name) {
250        return Err(Error::SecurityViolation {
251            reason: format!("Forbidden environment variable name: {name}"),
252        });
253    }
254
255    // Check for DYLD_* prefix (macOS dynamic linker variables)
256    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        // Binary names (not absolute paths) should be valid
274        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        // Empty command should fail during build
302        let result = ServerConfig::builder().command(String::new()).try_build();
303        assert!(result.is_err());
304        assert!(result.unwrap_err().contains("empty"));
305
306        // Whitespace-only command should fail during build
307        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        // Create a temporary executable file
470        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        // Set execute permissions
475        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        // Create a temporary non-executable file
497        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        // Remove execute permissions
502        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        // cwd doesn't affect validation (it's not security-critical)
540        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        // Test exact matches and prefix matches
631        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        // These should be OK (not in forbidden list)
636        assert!(validate_env_name("LD_DEBUG").is_ok()); // Not in list
637        assert!(validate_env_name("MY_PATH").is_ok()); // Not exact match
638        assert!(validate_env_name("DYLD").is_ok()); // No underscore, not prefix match
639    }
640}