Skip to main content

oxios_mcp/
validation.rs

1//! MCP server spawn validation — the single chokepoint that prevents
2//! arbitrary command execution via MCP server configuration.
3//!
4//! `validate_mcp_command` + `sanitize_env` are enforced inside
5//! [`crate::client::McpClient::initialize`] (the spawn site), so EVERY
6//! MCP server spawn — whether the `McpServer` came from the HTTP API,
7//! the `[[mcp.servers]]` config, or an `OXIOS_MCP_*` env var — passes
8//! through this gate. No caller can bypass it.
9//!
10//! The HTTP layer (`src/api/routes/infra.rs`) re-uses these functions
11//! to give users a friendly 400 before registration; the boot path
12//! (`init_mcp_bridge`) re-uses them to reject bad config at startup.
13//! But the authoritative enforcement is here, at spawn.
14
15use std::collections::HashMap;
16
17/// Shell interpreters that must never be spawned directly as an MCP
18/// server — they would allow `args = ["-c", "<arbitrary>"]` code
19pub const BLOCKED_MCP_SHELLS: &[&str] = &[
20    "sh",
21    "bash",
22    "dash",
23    "zsh",
24    "ksh",
25    "csh",
26    "tcsh",
27    "fish",
28    "ash",
29    "busybox",
30    // Windows interpreters — include the `.exe` variants because the
31    // basename match is exact; `cmd` would not catch `cmd.exe`.
32    "cmd",
33    "cmd.exe",
34    "powershell",
35    "powershell.exe",
36    "pwsh",
37    "pwsh.exe",
38    // Scripting interpreters that can eval arbitrary code from args.
39    "python",
40    "python2",
41    "python3",
42    "perl",
43    "ruby",
44    "node",
45    "nodejs",
46    "deno",
47    "bun",
48    "env",
49];
50
51/// Characters that must never appear in an MCP server command token.
52/// MCP commands are a single token (e.g. `npx`, `uvx`); any of these
53/// indicates an attempt to chain, inject, or shell-out.
54const FORBIDDEN_CHARS: &[char] = &[
55    ' ', '\t', ';', '|', '&', '>', '<', '`', '$', '(', ')', '{', '}', '\n', '\r', '*', '?', '\\',
56    '"', '\'',
57];
58
59/// Validate an MCP server command before spawning it.
60///
61/// Rejects control bytes, shell metacharacters, path traversal (`..`),
62/// and shell-interpreter basenames. Returns `Ok(())` if the command is
63/// a safe single token that is not a known shell.
64///
65/// # Errors
66/// Returns a human-readable reason string describing why the command
67/// was rejected.
68pub fn validate_mcp_command(command: &str) -> Result<(), String> {
69    if command.is_empty() {
70        return Err("command must not be empty".into());
71    }
72    // Reject control / NUL bytes outright.
73    if command.chars().any(|c| c.is_control() || c == '\u{0}') {
74        return Err("command contains control characters".into());
75    }
76    // Reject shell metacharacters and whitespace — MCP commands are a
77    // single token. Any of these would indicate an attempt to chain or
78    // inject.
79    if command.contains(FORBIDDEN_CHARS) {
80        return Err(format!(
81            "command contains forbidden characters (shell metacharacters or whitespace): {command:?}"
82        ));
83    }
84    // Reject path traversal in case the command is a path.
85    if command.contains("..") {
86        return Err("command must not contain path traversal (..)".into());
87    }
88    // Basename of the command for the shell blocklist check.
89    let basename = command.rsplit('/').next().unwrap_or(command);
90    let basename_lower = basename.to_ascii_lowercase();
91    if BLOCKED_MCP_SHELLS.iter().any(|s| *s == basename_lower) {
92        return Err(format!(
93            "refusing to spawn shell interpreter '{basename}' as an MCP server \
94             (would allow arbitrary command execution)"
95        ));
96    }
97    Ok(())
98}
99
100/// Environment-variable prefixes that must never be inherited by an MCP
101/// server child process — they allow a malicious server config to load
102/// arbitrary shared libraries or modules into the child.
103const BLOCKED_ENV_PREFIXES: &[&str] = &[
104    "LD_PRELOAD",
105    "LD_LIBRARY_PATH",
106    "DYLD_",
107    "PYTHONPATH",
108    "PYTHONHOME",
109    "SHLIB_PATH",
110    "LIBPATH",
111    "PERL5OPT",
112    "PERLLIB",
113    "NODE_OPTIONS",
114    "ELECTRON_RUN_AS_NODE",
115    "NODE_PATH",
116];
117
118/// Return a copy of `env` with security-sensitive variables removed.
119///
120/// Strips dynamic-loader paths (`LD_PRELOAD`, `LD_LIBRARY_PATH`,
121/// `DYLD_*`), interpreter paths (`PYTHONPATH`, `PERLLIB`, `NODE_PATH`),
122/// and a few interpreter option vectors (`NODE_OPTIONS`, `PERL5OPT`)
123/// that would let a malicious MCP config inject code into the child.
124///
125/// Used at spawn so no caller can smuggle these in via the server's
126/// `env` map.
127pub fn sanitize_env(env: &HashMap<String, String>) -> HashMap<String, String> {
128    env.iter()
129        .filter(|(k, _)| {
130            let key = k.as_str();
131            !BLOCKED_ENV_PREFIXES
132                .iter()
133                .any(|prefix| key.starts_with(prefix))
134        })
135        .map(|(k, v)| (k.clone(), v.clone()))
136        .collect()
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn accepts_safe_commands() {
145        assert!(validate_mcp_command("npx").is_ok());
146        assert!(validate_mcp_command("uvx").is_ok());
147        assert!(validate_mcp_command("/usr/local/bin/mcp-server").is_ok());
148    }
149
150    #[test]
151    fn rejects_shells() {
152        for shell in ["sh", "bash", "zsh", "python", "python3", "node", "env"] {
153            assert!(
154                validate_mcp_command(shell).is_err(),
155                "{shell} should be blocked"
156            );
157            // Path-qualified shell is also blocked (basename match).
158            assert!(
159                validate_mcp_command(&format!("/usr/bin/{shell}")).is_err(),
160                "/usr/bin/{shell} should be blocked"
161            );
162        }
163    }
164
165    #[test]
166    fn rejects_metacharacters_and_traversal() {
167        assert!(validate_mcp_command("npx; rm -rf /").is_err());
168        assert!(validate_mcp_command("npx && evil").is_err());
169        assert!(validate_mcp_command("npx $(cat /etc/passwd)").is_err());
170        assert!(validate_mcp_command("../escape").is_err());
171        assert!(validate_mcp_command("").is_err());
172    }
173
174    #[test]
175    fn sanitize_strips_loader_and_interp_paths() {
176        let mut env = HashMap::new();
177        env.insert("LD_PRELOAD".into(), "/tmp/x.so".into());
178        env.insert("DYLD_INSERT_LIBRARIES".into(), "/tmp/y.dylib".into());
179        env.insert("PYTHONPATH".into(), "/tmp".into());
180        env.insert("NODE_OPTIONS".into(), "--require /tmp/z".into());
181        env.insert("PATH".into(), "/usr/bin".into());
182        env.insert("HOME".into(), "/root".into());
183
184        let clean = sanitize_env(&env);
185        assert!(!clean.contains_key("LD_PRELOAD"));
186        assert!(!clean.contains_key("DYLD_INSERT_LIBRARIES"));
187        assert!(!clean.contains_key("PYTHONPATH"));
188        assert!(!clean.contains_key("NODE_OPTIONS"));
189        // Benign vars survive.
190        assert_eq!(clean.get("PATH").map(String::as_str), Some("/usr/bin"));
191        assert_eq!(clean.get("HOME").map(String::as_str), Some("/root"));
192    }
193}