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) — `build()` validates internally,
20//! // so `validate_server_config` is redundant here; shown for clarity.
21//! let config = ServerConfig::builder()
22//!     .command("docker".to_string())
23//!     .arg("run".to_string())
24//!     .build()
25//!     .unwrap();
26//! assert!(validate_server_config(&config).is_ok());
27//!
28//! // Invalid: shell metacharacters in arg — rejected by `build()` itself,
29//! // so no `ServerConfig` carrying this arg can ever exist.
30//! let err = ServerConfig::builder()
31//!     .command("docker".to_string())
32//!     .arg("run; rm -rf /".to_string())
33//!     .build()
34//!     .unwrap_err();
35//! assert!(err.is_security_error());
36//! ```
37
38use crate::{Error, Result, ServerConfig, Transport};
39use std::path::Path;
40use std::time::Duration;
41
42/// Shell metacharacters that indicate potential command injection.
43const FORBIDDEN_CHARS: &[char] = &[';', '|', '&', '>', '<', '`', '$', '(', ')', '\n', '\r'];
44
45/// Forbidden environment variable names that pose security risks.
46///
47/// # Threat Model — What This List Does and Does Not Protect Against
48///
49/// This is an **accidental/indirect-misconfiguration guard, not a sandbox
50/// boundary**. It blocks the well-known names an interpreter or dynamic
51/// linker consults to load extra code or redirect its own search paths —
52/// covering the runtimes this bridge actually spawns (Node.js, Python, Ruby,
53/// Perl, the JVM, and POSIX shells, in addition to the native dynamic
54/// linker) — so that a config sourced from `mcp.json` or CLI flags cannot
55/// silently turn an intended `docker`/`node`/`python` invocation into
56/// arbitrary code execution via one of these documented hijack vectors:
57///
58/// - `LD_PRELOAD` / `LD_LIBRARY_PATH` / `LD_AUDIT`: Linux dynamic linker —
59///   force-load an arbitrary shared object into the child process
60/// - `DYLD_*`: macOS dynamic linker equivalents
61/// - `PATH`: binary substitution for any bare (non-absolute) command
62/// - `NODE_OPTIONS`: Node.js — inject interpreter flags such as `--require`
63///   or `--experimental-loader` into any `node`/`npx` invocation
64/// - `BASH_ENV`: sourced by non-interactive `bash` before running a
65///   script/command, letting a config inject arbitrary shell code
66/// - `PYTHONPATH` / `PYTHONSTARTUP`: Python — module search-path hijacking
67///   and arbitrary code executed at interpreter startup
68/// - `RUBYOPT`: Ruby — inject interpreter flags (`-r`, `-e`) to load
69///   arbitrary code
70/// - `PERL5OPT`: Perl — inject interpreter switches to run arbitrary code
71/// - `JAVA_TOOL_OPTIONS`: JVM — inject arbitrary JVM arguments, including a
72///   `-javaagent` for bytecode instrumentation
73///
74/// What it deliberately does **not** protect against: a command/binary that
75/// is itself malicious, a compromised dependency, arbitrary code the spawned
76/// server executes once running, or forbidden-adjacent variables not on this
77/// exact-match/prefix list (e.g. an interpreter-specific vector this project
78/// does not yet spawn). This list is reviewed and extended as new spawn
79/// targets are added, not treated as exhaustive by construction.
80const 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",         // Block PATH override to prevent binary substitution
88    "NODE_OPTIONS", // Lets a config inject e.g. `--require /tmp/evil.js` into any Node subprocess
89    "BASH_ENV",     // Sourced by non-interactive `bash` before running a script/command
90    "PYTHONPATH",
91    "PYTHONSTARTUP",
92    "RUBYOPT",
93    "PERL5OPT",
94    "JAVA_TOOL_OPTIONS",
95];
96
97/// Environment-variable-name prefix rejected regardless of exact match: macOS's
98/// dynamic-linker variable family (`DYLD_INSERT_LIBRARIES`, `DYLD_LIBRARY_PATH`, ...).
99const FORBIDDEN_ENV_PREFIX: &str = "DYLD_";
100
101/// Upper bound for `connect_timeout`/`discover_timeout`, matching the
102/// 30-second defaults declared in `server_config.rs` with headroom for
103/// slow-starting servers configured via `mcp.json`.
104const MAX_TIMEOUT: Duration = Duration::from_mins(10);
105
106/// Maximum number of positional arguments accepted in a `ServerConfig` (denial-of-service
107/// protection, CWE-400).
108///
109/// An `mcp.json` entry or CLI invocation is expected to pass a short, fixed argv to the
110/// spawned subprocess, so this is generous headroom rather than a realistic expectation.
111///
112/// # Examples
113///
114/// ```
115/// use mcp_execution_core::MAX_ARG_COUNT;
116///
117/// assert!(MAX_ARG_COUNT > 0);
118/// ```
119pub const MAX_ARG_COUNT: usize = 256;
120
121/// Maximum byte length for a single command string, argument, or environment variable name.
122///
123/// A legitimate command/argument/env-name is always a short identifier or path, never
124/// free-form text, so this ceiling exists purely as a resource-exhaustion backstop.
125///
126/// # Examples
127///
128/// ```
129/// use mcp_execution_core::MAX_ARG_LEN;
130///
131/// assert!(MAX_ARG_LEN > 0);
132/// ```
133pub const MAX_ARG_LEN: usize = 4096;
134
135/// Maximum number of environment variables accepted in a `ServerConfig`.
136///
137/// # Examples
138///
139/// ```
140/// use mcp_execution_core::MAX_ENV_COUNT;
141///
142/// assert!(MAX_ENV_COUNT > 0);
143/// ```
144pub const MAX_ENV_COUNT: usize = 256;
145
146/// Maximum byte length for a single environment variable value.
147///
148/// Wider than [`MAX_ARG_LEN`] since env values legitimately carry things like JSON
149/// configuration blobs, not just short identifiers.
150///
151/// # Examples
152///
153/// ```
154/// use mcp_execution_core::MAX_ENV_VALUE_LEN;
155///
156/// assert!(MAX_ENV_VALUE_LEN > 0);
157/// ```
158pub const MAX_ENV_VALUE_LEN: usize = 32 * 1024;
159
160/// Maximum number of HTTP headers accepted for Http/Sse transport.
161///
162/// # Examples
163///
164/// ```
165/// use mcp_execution_core::MAX_HEADER_COUNT;
166///
167/// assert!(MAX_HEADER_COUNT > 0);
168/// ```
169pub const MAX_HEADER_COUNT: usize = 128;
170
171/// Maximum byte length for a single HTTP header value.
172///
173/// Wider than [`MAX_ARG_LEN`] since header values legitimately carry things like long
174/// bearer tokens.
175///
176/// # Examples
177///
178/// ```
179/// use mcp_execution_core::MAX_HEADER_VALUE_LEN;
180///
181/// assert!(MAX_HEADER_VALUE_LEN > 0);
182/// ```
183pub const MAX_HEADER_VALUE_LEN: usize = 8 * 1024;
184
185/// Maximum byte length for the HTTP/Sse transport `url`.
186///
187/// Generous headroom over any realistic endpoint URL (including a long query string), while
188/// still bounding a hostile or hand-edited `mcp.json` entry (denial-of-service protection,
189/// CWE-400).
190///
191/// # Examples
192///
193/// ```
194/// use mcp_execution_core::MAX_URL_LEN;
195///
196/// assert!(MAX_URL_LEN > 0);
197/// ```
198pub const MAX_URL_LEN: usize = 8 * 1024;
199
200/// Returns the shell metacharacters considered forbidden in a command or argument string.
201///
202/// Exposed so downstream consumers that must mirror this exact rule outside this function —
203/// currently, the generated TypeScript runtime bridge
204/// (`crates/mcp-codegen/templates/progressive/runtime-bridge.ts.hbs`) — can render their copy
205/// directly from this constant at code-generation time instead of hand-copying it, which
206/// would otherwise silently drift out of sync.
207///
208/// # Examples
209///
210/// ```
211/// use mcp_execution_core::forbidden_chars;
212///
213/// assert!(forbidden_chars().contains(&';'));
214/// ```
215#[must_use]
216pub const fn forbidden_chars() -> &'static [char] {
217    FORBIDDEN_CHARS
218}
219
220/// Returns the exact-match forbidden environment variable names.
221///
222/// Does not include the `DYLD_` prefix rule — see [`forbidden_env_prefix`] for that. Exposed
223/// for the same drift-elimination reason as [`forbidden_chars`]; see its documentation.
224///
225/// # Examples
226///
227/// ```
228/// use mcp_execution_core::forbidden_env_names;
229///
230/// assert!(forbidden_env_names().contains(&"LD_PRELOAD"));
231/// ```
232#[must_use]
233pub const fn forbidden_env_names() -> &'static [&'static str] {
234    FORBIDDEN_ENV_NAMES
235}
236
237/// Returns the environment-variable-name prefix rejected regardless of exact match
238/// (currently `DYLD_`, macOS's dynamic-linker variable family).
239///
240/// # Examples
241///
242/// ```
243/// use mcp_execution_core::forbidden_env_prefix;
244///
245/// assert_eq!(forbidden_env_prefix(), "DYLD_");
246/// ```
247#[must_use]
248pub const fn forbidden_env_prefix() -> &'static str {
249    FORBIDDEN_ENV_PREFIX
250}
251
252/// Validates a `ServerConfig` for safe execution, dispatching on transport type.
253///
254/// This function performs comprehensive security validation before a config is
255/// used to connect to a server. It validates:
256///
257/// 1. **Stdio transport**: command (absolute path or binary name), arguments, and
258///    environment variables.
259/// 2. **Http/Sse transport**: URL presence and scheme, and HTTP header names/values.
260/// 3. **Timeouts**: `connect_timeout`/`discover_timeout` checked against bounds,
261///    for all transports.
262///
263/// # Security Rules
264///
265/// - **Forbidden chars in command/args**: `;`, `|`, `&`, `>`, `<`, `` ` ``, `$`, `(`, `)`, `\n`, `\r`
266/// - **Forbidden env names**: dynamic-linker (`LD_PRELOAD`, `LD_LIBRARY_PATH`,
267///   `LD_AUDIT`, `DYLD_*`), `PATH`, and interpreter hijack vectors
268///   (`NODE_OPTIONS`, `BASH_ENV`, `PYTHONPATH`, `PYTHONSTARTUP`, `RUBYOPT`,
269///   `PERL5OPT`, `JAVA_TOOL_OPTIONS`) — see the `FORBIDDEN_ENV_NAMES` constant's
270///   doc comment in this module's source for the full threat-model note
271/// - **Absolute paths**: Must exist and be executable
272/// - **Binary names**: Allowed (resolved via PATH at runtime)
273/// - **URL scheme**: Must be `http://` or `https://`
274/// - **Header names/values**: Must not contain control characters
275/// - **Timeout bounds**: `connect_timeout`/`discover_timeout` must be greater than zero and at
276///   most `MAX_TIMEOUT` (600s)
277/// - **Element counts/lengths** (denial-of-service protection, CWE-400) — see this module's
278///   `validate_stdio_size_bounds`/`validate_network_size_bounds`: at most `MAX_ARG_COUNT`
279///   args, `MAX_ENV_COUNT` env vars, and `MAX_HEADER_COUNT` headers; at most `MAX_ARG_LEN`
280///   bytes per command/argument/env-name/header-name, `MAX_ENV_VALUE_LEN` bytes per env
281///   value, `MAX_HEADER_VALUE_LEN` bytes per header value, and `MAX_URL_LEN` bytes for the
282///   `url` field
283///
284/// # Errors
285///
286/// Returns `Error::SecurityViolation` if:
287/// - Command is empty or whitespace
288/// - Command/args contain shell metacharacters
289/// - Absolute path does not exist or is not executable
290/// - Environment variable name is forbidden
291/// - URL scheme is not `http://`/`https://`, or a header name/value contains control characters
292///
293/// Returns `Error::ValidationError` if:
294/// - URL is missing for Http/Sse transport
295/// - `connect_timeout` or `discover_timeout` is zero
296/// - `connect_timeout` or `discover_timeout` exceeds `MAX_TIMEOUT` (600s)
297///
298/// # Examples
299///
300/// ```
301/// use mcp_execution_core::{ServerConfig, validate_server_config};
302///
303/// // Valid: binary name
304/// let config = ServerConfig::builder()
305///     .command("docker".to_string())
306///     .build()
307///     .unwrap();
308/// assert!(validate_server_config(&config).is_ok());
309///
310/// // Invalid: forbidden env var — `ServerConfigBuilder::build()` already
311/// // rejects this, so no unvalidated `ServerConfig` reaches this function.
312/// let err = ServerConfig::builder()
313///     .command("docker".to_string())
314///     .env("LD_PRELOAD".to_string(), "/evil.so".to_string())
315///     .build()
316///     .unwrap_err();
317/// assert!(err.is_security_error());
318///
319/// // Valid: HTTP transport
320/// let config = ServerConfig::builder()
321///     .http_transport("https://api.example.com/mcp".to_string())
322///     .build()
323///     .unwrap();
324/// assert!(validate_server_config(&config).is_ok());
325/// ```
326///
327/// # Security Considerations
328///
329/// - Binary names are allowed and resolved via PATH at runtime
330/// - Absolute paths undergo strict validation (existence, permissions)
331/// - All arguments are validated separately to prevent injection
332/// - Environment variables are checked against forbidden names
333/// - Header values are never echoed into error messages, since they routinely
334///   carry secrets such as bearer tokens
335/// - Header *names* are never echoed either, once rejected: a `Name=Value` or
336///   `Name: Value` CLI argument can be mis-split on the wrong separator,
337///   leaving a full secret value in the "name" position — the token-charset
338///   error, the duplicate-header-name error, and the header-value
339///   control-character error all omit the name for this reason
340/// - There is no infinite-timeout option: `0` is always rejected, since an
341///   unbounded wait would let a hung server block this non-interactive tool
342///   forever (see the `validate_timeout` design note in this module)
343pub fn validate_server_config(config: &ServerConfig) -> Result<()> {
344    match config.transport() {
345        Transport::Stdio {
346            command, args, env, ..
347        } => {
348            // Element counts/lengths (denial-of-service protection, CWE-400) are bounded
349            // before the command-injection-specific checks below.
350            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 bounds. Zero fires immediately and breaks all
360    // discovery; an infinite timeout is deliberately unsupported (see
361    // `validate_timeout` doc comment) because it would let a hung or
362    // malicious server block this non-interactive CLI tool forever,
363    // re-opening the DoS window these timeouts were introduced to close.
364    validate_timeout(config.connect_timeout(), "connect_timeout")?;
365    validate_timeout(config.discover_timeout(), "discover_timeout")?;
366
367    Ok(())
368}
369
370/// Bounds `command`'s length and `args`'/`env`'s counts/lengths (denial-of-service
371/// protection, CWE-400) for a [`Transport::Stdio`] config.
372///
373/// Since #313, `Transport::Http`/`Transport::Sse` have no `command`/`args`/`env` fields at
374/// all — the cross-transport bypass this once guarded against (issue #198 S2: a hostile
375/// `mcp.json` populating `args`/`env` for a non-stdio transport) is unrepresentable rather
376/// than merely unchecked, so this only needs to run for the `Stdio` variant.
377///
378/// Deliberately does not check for shell metacharacters or forbidden environment variable
379/// names — that remains [`validate_stdio_config`]'s responsibility, since it is only
380/// meaningful for a config that is actually used to spawn a subprocess.
381fn 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
446/// Bounds `url`'s length and `headers`' count/lengths (denial-of-service protection,
447/// CWE-400) for a [`Transport::Http`]/[`Transport::Sse`] config.
448///
449/// See [`validate_stdio_size_bounds`]'s doc comment for why this only needs to run for its
450/// own variant family since #313.
451fn 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
494/// Validates the stdio-transport-specific fields of a `ServerConfig`.
495///
496/// Checks the command (absolute path or binary name), arguments, and environment variables
497/// for command-injection risks. Element counts/lengths are already bounded unconditionally by
498/// [`validate_stdio_size_bounds`] before this runs; this function only adds the checks that
499/// are meaningful specifically because this config will be used to spawn a subprocess.
500fn validate_stdio_config(
501    command: &str,
502    args: &[String],
503    env: &std::collections::HashMap<String, String>,
504) -> Result<()> {
505    // Validate command
506    validate_command_string(command, "command")?;
507
508    // If command is absolute path, perform additional checks
509    let command_path = Path::new(command);
510    if command_path.is_absolute() {
511        validate_absolute_path(command)?;
512    }
513    // If not absolute, it's a binary name (to be resolved via PATH) - this is OK
514
515    // Validate each argument separately
516    for (idx, arg) in args.iter().enumerate() {
517        validate_command_string(arg, &format!("argument {idx}"))?;
518    }
519
520    // Validate environment variable names
521    for env_name in env.keys() {
522        validate_env_name(env_name)?;
523    }
524
525    Ok(())
526}
527
528/// Validates the Http/Sse-transport-specific fields of a `ServerConfig`.
529///
530/// `url` is a required field of [`Transport::Http`]/[`Transport::Sse`] (see #313), so unlike
531/// before, a config missing it cannot reach this function at all — that gap is now closed at
532/// deserialization/construction time rather than here.
533///
534/// `headers`'/`url`'s element counts/lengths are already bounded unconditionally by
535/// [`validate_network_size_bounds`] before this runs; this function only adds the checks that
536/// are meaningful specifically because this config will be used to send an HTTP request
537/// (header name charset, control characters, scheme, duplicate names).
538fn validate_network_config(
539    url: &str,
540    headers: &std::collections::HashMap<String, String>,
541) -> Result<()> {
542    validate_url_scheme(url)?;
543
544    // `http::HeaderName` lowercases on parse, so two headers that differ only
545    // in case (e.g. "Authorization" and "authorization") collapse into a
546    // single entry with a nondeterministic winner once converted — reject
547    // that here rather than letting it silently drop a header downstream.
548    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
564/// Validates that a URL uses the `http://` or `https://` scheme.
565///
566/// This is defense in depth: rejects `file://`, `unix://`, and similar
567/// schemes at the `mcp-core` validation boundary rather than relying on the
568/// HTTP client to reject them. The scheme comparison is case-insensitive per
569/// RFC 3986 (`HTTP://host` is a valid URL, not a different scheme).
570///
571/// This is a minimal, string-based scheme check — it does not validate the
572/// rest of the URL's structure (e.g. it does not require a host). It is
573/// exposed publicly so that other crates checking URL validity for the same
574/// http/sse transport (e.g. `mcp-execution-cli`'s server status/validation
575/// commands) can share this exact rule instead of drifting from it with a
576/// second, differently-behaved check.
577///
578/// # Errors
579///
580/// Returns [`Error::SecurityViolation`] if `url` does not start with an
581/// `http://` or `https://` scheme (case-insensitive).
582///
583/// # Examples
584///
585/// ```
586/// use mcp_execution_core::validate_url_scheme;
587///
588/// assert!(validate_url_scheme("https://example.com/mcp").is_ok());
589/// assert!(validate_url_scheme("HTTP://example.com").is_ok());
590/// assert!(validate_url_scheme("ftp://example.com").is_err());
591/// assert!(validate_url_scheme("  https://example.com").is_err());
592/// ```
593pub 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
606/// Returns `true` if `value` contains an ASCII or Unicode control character
607/// (including `\r`, `\n`, and NUL), which could otherwise be used to smuggle
608/// extra header lines into an HTTP request.
609fn contains_control_char(value: &str) -> bool {
610    value.chars().any(char::is_control)
611}
612
613/// Returns `true` if `c` is a valid RFC 7230 `tchar` (the charset allowed in
614/// an HTTP header field name).
615const 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
636/// Validates an HTTP header name against the RFC 7230 `token` charset.
637///
638/// A plain control-character check is not tight enough: a space, `:`, or `@`
639/// is not a control character but is still an invalid header-name character
640/// that would otherwise pass here and fail later inside `http::HeaderName`
641/// construction with an opaque error.
642///
643/// # Security
644///
645/// The rejected name is never echoed into the error message. A `Name=Value`
646/// or `Name: Value` CLI argument can be mis-split on the wrong separator,
647/// leaving a full secret value in the "name" position; that value only needs
648/// one non-`tchar` byte to reach this branch, so it must be treated the same
649/// as a secret — mirroring the duplicate-header-name check below, which
650/// redacts for the same reason.
651fn 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
666/// Validates an HTTP header value for control characters.
667///
668/// # Security
669///
670/// The header *value* routinely carries secrets (e.g. bearer tokens), so it
671/// must never appear in the returned error's reason string. The header
672/// *name* is not echoed either: this runs after `validate_header_name_string`
673/// has already accepted it as RFC 7230 `token`-charset-only, the same
674/// "may still be secret-shaped input from a misparsed argument" condition
675/// that the tchar-violation and duplicate-header-name errors above already
676/// treat as untrusted.
677fn 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
686/// Validates that a timeout is within `(0, MAX_TIMEOUT]`.
687///
688/// # Design Note: No Infinite Timeout
689///
690/// A timeout of zero is permanently rejected rather than treated as a
691/// sentinel for "no timeout". This tool spawns subprocesses and connects to
692/// servers non-interactively (CLI and MCP-server modes); an unbounded
693/// connect/discover wait would let a hung or malicious server block the
694/// caller indefinitely, which is exactly the denial-of-service exposure
695/// these timeouts were added to close. Callers that need a longer wait
696/// should raise the value up to `MAX_TIMEOUT` (10 minutes) instead.
697fn 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
713/// Validates a command string for forbidden shell metacharacters.
714///
715/// This is an internal helper that checks a string (command or argument)
716/// for dangerous shell metacharacters. Length is already bounded unconditionally by
717/// [`validate_stdio_size_bounds`] before this runs.
718///
719/// # Security
720///
721/// The offending value is never echoed into the error message. `context` is
722/// `"argument {idx}"` for CLI arguments, which routinely carry secrets in a
723/// `--api-key sk-...`-style value; the same "may be secret-shaped" treatment
724/// as `validate_header_value_string` and the duplicate-header-name check
725/// applies here.
726fn validate_command_string(value: &str, context: &str) -> Result<()> {
727    // Check for empty
728    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    // Check for shell metacharacters
736    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
750/// Validates an absolute path command for existence and executability.
751///
752/// This is an internal helper that performs file system checks on
753/// absolute path commands.
754fn validate_absolute_path(command: &str) -> Result<()> {
755    let path = Path::new(command);
756
757    // Verify file exists
758    if !path.exists() {
759        return Err(Error::SecurityViolation {
760            reason: format!("Command file does not exist: {command}"),
761        });
762    }
763
764    // Verify it's a file (not a directory)
765    if !path.is_file() {
766        return Err(Error::SecurityViolation {
767            reason: format!("Command path is not a file: {command}"),
768        });
769    }
770
771    // Verify executable permissions (Unix only)
772    #[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        // Check if any execute bit is set (owner, group, or other)
782        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
792/// Validates an environment variable name.
793///
794/// This is an internal helper that checks if an environment variable name is in the
795/// forbidden list. Length is already bounded unconditionally by [`validate_stdio_size_bounds`]
796/// before this runs.
797fn validate_env_name(name: &str) -> Result<()> {
798    // Check for forbidden env names (exact match)
799    if FORBIDDEN_ENV_NAMES.contains(&name) {
800        return Err(Error::SecurityViolation {
801            reason: format!("Forbidden environment variable name: {name}"),
802        });
803    }
804
805    // Check for DYLD_* prefix (macOS dynamic linker variables)
806    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        // Binary names (not absolute paths) should be valid
825        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        // Empty command should fail during build
859        let result = ServerConfig::builder().command(String::new()).build();
860        assert!(result.is_err());
861        assert!(result.unwrap_err().to_string().contains("empty"));
862
863        // Whitespace-only command should fail during build
864        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            // `build()` now runs security validation internally, so a config
885            // carrying a shell metacharacter is rejected at construction.
886            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        // Regression test for #229: a rejected arg is routinely a
936        // misparsed `--api-key sk-...`-style secret; the metacharacter
937        // error must never echo the raw value.
938        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    /// #221.1 — the interpreter hijack vectors added alongside the original
1022    /// dynamic-linker/`PATH` entries must also be rejected.
1023    #[test]
1024    fn test_validate_server_config_forbidden_env_interpreter_hijack_vectors() {
1025        // NODE_OPTIONS and BASH_ENV have their own dedicated tests above.
1026        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        // NODE_OPTIONS lets a config inject e.g. `--require /tmp/evil.js` into any Node
1050        // subprocess the server itself spawns.
1051        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        // BASH_ENV is sourced by non-interactive `bash` before running a script or command.
1067        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        // Create a temporary executable file
1095        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        // Set execute permissions
1100        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        // Create a temporary non-executable file
1121        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        // Remove execute permissions
1126        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        // cwd doesn't affect validation (it's not security-critical)
1162        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        // Test exact matches and prefix matches
1250        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        // These should be OK (not in forbidden list)
1255        assert!(validate_env_name("LD_DEBUG").is_ok()); // Not in list
1256        assert!(validate_env_name("MY_PATH").is_ok()); // Not exact match
1257        assert!(validate_env_name("DYLD").is_ok()); // No underscore, not prefix match
1258    }
1259
1260    // ── Http/Sse transport validation ────────────────────────────────────────
1261
1262    #[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    /// #313 — `url` is a required (non-`#[serde(default)]`) field of `Transport::Http`, so a
1288    /// hand-edited `mcp.json` with `"transport": "http"` and no `url` key now fails to
1289    /// deserialize at all, rather than producing an incomplete `ServerConfig` that only
1290    /// `validate_server_config` would have caught downstream (see also
1291    /// `server_config::tests::test_deserialize_http_config_missing_url_is_rejected`).
1292    #[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        // "httpsomething" must not be accepted as a loose prefix match of "http".
1341        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        // A misparsed `Name: Value`-style CLI argument can leave a "key" that
1368        // is entirely RFC 7230 token-charset (alphanumerics plus
1369        // `!#$%&'*+-.^_`|~`), e.g. a hex-encoded key or JWT-like value using
1370        // only `A-Za-z0-9-_.`. Such a name passes `validate_header_name_string`
1371        // and must not be echoed if it collides case-insensitively.
1372        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        // Space, ':', and '@' are not control characters but are still
1396        // invalid HTTP header-name characters (outside RFC 7230's `token`).
1397        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        // Reproduces the #215 leak vector: a `Name=Value` CLI argument
1416        // mis-split on the wrong `=` leaves a base64-encoded secret in the
1417        // "name" position. It only needs one non-tchar byte (here `/`) to
1418        // reach `validate_header_name_string`'s tchar-violation branch,
1419        // which must not echo it back.
1420        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            // Neither the header value nor its (ordinary, non-secret-shaped
1466            // here) name need to appear — the name is withheld unconditionally
1467            // since this path cannot distinguish an ordinary name from a
1468            // secret-shaped one.
1469            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        // Reproduces the critic's S5 repro: a JWT-shaped header *name* (fully
1480        // RFC 7230 token-charset, so it clears `validate_header_name_string`)
1481        // paired with a control character in the *value*. Both the name and
1482        // the control-char-bearing value must be absent from the error.
1483        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    // ── Resource-exhaustion bounds (issue #198) ──────────────────────────────
1500
1501    #[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    // ── #313: cross-transport fields are now unrepresentable ────────────────────
1687    //
1688    // The S2/N1 bypass this section used to guard against (a hand-edited `mcp.json`
1689    // populating `args`/`env`/`headers`/`url`/`command` for the "wrong" transport, since every
1690    // field used to exist unconditionally at the type level) is closed by construction as of
1691    // #313: `Transport::Http`/`Transport::Sse` have no `command`/`args`/`env`/`cwd` fields, and
1692    // `Transport::Stdio` has no `url`/`headers` fields. A JSON key that doesn't belong to the
1693    // deserialized variant has no field to populate, so `serde` simply ignores it — the same as
1694    // any other unrecognized key — rather than it being a bypass.
1695
1696    #[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        // An Http config has no `command` field to populate, so the oversized value was never
1706        // stored anywhere and is not a resource-exhaustion vector.
1707        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}