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/// Matched by [`validate_env_name`] using an ASCII-case-insensitive comparison — Windows
48/// treats environment variable names as case-insensitive at the OS/`CreateProcess` level, so
49/// a config carrying e.g. `Path` or `NODE_options` is rejected exactly like the canonical
50/// spelling shown below.
51///
52/// # Threat Model — What This List Does and Does Not Protect Against
53///
54/// This is an **accidental/indirect-misconfiguration guard, not a sandbox
55/// boundary**. It blocks the well-known names an interpreter or dynamic
56/// linker consults to load extra code or redirect its own search paths —
57/// covering the runtimes this bridge actually spawns (Node.js, Python, Ruby,
58/// Perl, the JVM, and POSIX shells, in addition to the native dynamic
59/// linker) — so that a config sourced from `mcp.json` or CLI flags cannot
60/// silently turn an intended `docker`/`node`/`python` invocation into
61/// arbitrary code execution via one of these documented hijack vectors:
62///
63/// - `LD_PRELOAD` / `LD_LIBRARY_PATH` / `LD_AUDIT`: Linux dynamic linker —
64/// force-load an arbitrary shared object into the child process
65/// - `DYLD_*`: macOS dynamic linker equivalents
66/// - `PATH`: binary substitution for any bare (non-absolute) command
67/// - `NODE_OPTIONS`: Node.js — inject interpreter flags such as `--require`
68/// or `--experimental-loader` into any `node`/`npx` invocation
69/// - `BASH_ENV`: sourced by non-interactive `bash` before running a
70/// script/command, letting a config inject arbitrary shell code
71/// - `PYTHONPATH` / `PYTHONSTARTUP`: Python — module search-path hijacking
72/// and arbitrary code executed at interpreter startup
73/// - `RUBYOPT`: Ruby — inject interpreter flags (`-r`, `-e`) to load
74/// arbitrary code
75/// - `PERL5OPT`: Perl — inject interpreter switches to run arbitrary code
76/// - `JAVA_TOOL_OPTIONS`: JVM — inject arbitrary JVM arguments, including a
77/// `-javaagent` for bytecode instrumentation
78///
79/// What it deliberately does **not** protect against: a command/binary that
80/// is itself malicious, a compromised dependency, arbitrary code the spawned
81/// server executes once running, or forbidden-adjacent variables not on this
82/// exact-match/prefix list (e.g. an interpreter-specific vector this project
83/// does not yet spawn). This list is reviewed and extended as new spawn
84/// targets are added, not treated as exhaustive by construction.
85const FORBIDDEN_ENV_NAMES: &[&str] = &[
86 "LD_PRELOAD",
87 "LD_LIBRARY_PATH",
88 "LD_AUDIT",
89 "DYLD_INSERT_LIBRARIES",
90 "DYLD_LIBRARY_PATH",
91 "DYLD_FRAMEWORK_PATH",
92 "PATH", // Block PATH override to prevent binary substitution
93 "NODE_OPTIONS", // Lets a config inject e.g. `--require /tmp/evil.js` into any Node subprocess
94 "BASH_ENV", // Sourced by non-interactive `bash` before running a script/command
95 "PYTHONPATH",
96 "PYTHONSTARTUP",
97 "RUBYOPT",
98 "PERL5OPT",
99 "JAVA_TOOL_OPTIONS",
100];
101
102/// Environment-variable-name prefix rejected regardless of exact match: macOS's
103/// dynamic-linker variable family (`DYLD_INSERT_LIBRARIES`, `DYLD_LIBRARY_PATH`, ...).
104///
105/// Matched by [`validate_env_name`] case-insensitively, same as [`FORBIDDEN_ENV_NAMES`].
106const FORBIDDEN_ENV_PREFIX: &str = "DYLD_";
107
108/// Upper bound for `connect_timeout`/`discover_timeout`, matching the
109/// 30-second defaults declared in `server_config.rs` with headroom for
110/// slow-starting servers configured via `mcp.json`.
111const MAX_TIMEOUT: Duration = Duration::from_mins(10);
112
113/// Maximum number of positional arguments accepted in a `ServerConfig` (denial-of-service
114/// protection, CWE-400).
115///
116/// An `mcp.json` entry or CLI invocation is expected to pass a short, fixed argv to the
117/// spawned subprocess, so this is generous headroom rather than a realistic expectation.
118///
119/// # Examples
120///
121/// ```
122/// use mcp_execution_core::MAX_ARG_COUNT;
123///
124/// assert!(MAX_ARG_COUNT > 0);
125/// ```
126pub const MAX_ARG_COUNT: usize = 256;
127
128/// Maximum byte length for a single command string, argument, or environment variable name.
129///
130/// A legitimate command/argument/env-name is always a short identifier or path, never
131/// free-form text, so this ceiling exists purely as a resource-exhaustion backstop.
132///
133/// # Examples
134///
135/// ```
136/// use mcp_execution_core::MAX_ARG_LEN;
137///
138/// assert!(MAX_ARG_LEN > 0);
139/// ```
140pub const MAX_ARG_LEN: usize = 4096;
141
142/// Maximum number of environment variables accepted in a `ServerConfig`.
143///
144/// # Examples
145///
146/// ```
147/// use mcp_execution_core::MAX_ENV_COUNT;
148///
149/// assert!(MAX_ENV_COUNT > 0);
150/// ```
151pub const MAX_ENV_COUNT: usize = 256;
152
153/// Maximum byte length for a single environment variable value.
154///
155/// Wider than [`MAX_ARG_LEN`] since env values legitimately carry things like JSON
156/// configuration blobs, not just short identifiers.
157///
158/// # Examples
159///
160/// ```
161/// use mcp_execution_core::MAX_ENV_VALUE_LEN;
162///
163/// assert!(MAX_ENV_VALUE_LEN > 0);
164/// ```
165pub const MAX_ENV_VALUE_LEN: usize = 32 * 1024;
166
167/// Maximum number of HTTP headers accepted for Http/Sse transport.
168///
169/// # Examples
170///
171/// ```
172/// use mcp_execution_core::MAX_HEADER_COUNT;
173///
174/// assert!(MAX_HEADER_COUNT > 0);
175/// ```
176pub const MAX_HEADER_COUNT: usize = 128;
177
178/// Maximum byte length for a single HTTP header value.
179///
180/// Wider than [`MAX_ARG_LEN`] since header values legitimately carry things like long
181/// bearer tokens.
182///
183/// # Examples
184///
185/// ```
186/// use mcp_execution_core::MAX_HEADER_VALUE_LEN;
187///
188/// assert!(MAX_HEADER_VALUE_LEN > 0);
189/// ```
190pub const MAX_HEADER_VALUE_LEN: usize = 8 * 1024;
191
192/// Maximum byte length for the HTTP/Sse transport `url`.
193///
194/// Generous headroom over any realistic endpoint URL (including a long query string), while
195/// still bounding a hostile or hand-edited `mcp.json` entry (denial-of-service protection,
196/// CWE-400).
197///
198/// # Examples
199///
200/// ```
201/// use mcp_execution_core::MAX_URL_LEN;
202///
203/// assert!(MAX_URL_LEN > 0);
204/// ```
205pub const MAX_URL_LEN: usize = 8 * 1024;
206
207/// Returns the shell metacharacters considered forbidden in a command or argument string.
208///
209/// Exposed so downstream consumers that must mirror this exact rule outside this function —
210/// currently, the generated TypeScript runtime bridge
211/// (`crates/mcp-codegen/templates/progressive/runtime-bridge.ts.hbs`) — can render their copy
212/// directly from this constant at code-generation time instead of hand-copying it, which
213/// would otherwise silently drift out of sync.
214///
215/// # Examples
216///
217/// ```
218/// use mcp_execution_core::forbidden_chars;
219///
220/// assert!(forbidden_chars().contains(&';'));
221/// ```
222#[must_use]
223pub const fn forbidden_chars() -> &'static [char] {
224 FORBIDDEN_CHARS
225}
226
227/// Returns the exact-match forbidden environment variable names.
228///
229/// Does not include the `DYLD_` prefix rule — see [`forbidden_env_prefix`] for that. Exposed
230/// for the same drift-elimination reason as [`forbidden_chars`]; see its documentation.
231///
232/// # Examples
233///
234/// ```
235/// use mcp_execution_core::forbidden_env_names;
236///
237/// assert!(forbidden_env_names().contains(&"LD_PRELOAD"));
238/// ```
239#[must_use]
240pub const fn forbidden_env_names() -> &'static [&'static str] {
241 FORBIDDEN_ENV_NAMES
242}
243
244/// Returns the environment-variable-name prefix rejected regardless of exact match
245/// (currently `DYLD_`, macOS's dynamic-linker variable family).
246///
247/// # Examples
248///
249/// ```
250/// use mcp_execution_core::forbidden_env_prefix;
251///
252/// assert_eq!(forbidden_env_prefix(), "DYLD_");
253/// ```
254#[must_use]
255pub const fn forbidden_env_prefix() -> &'static str {
256 FORBIDDEN_ENV_PREFIX
257}
258
259/// Human-readable description of the POSIX/Windows environment-variable-name identifier
260/// charset enforced by [`validate_env_name`]: first character `[A-Za-z_]`, subsequent
261/// characters `[A-Za-z0-9_]*`. Embedded in both this module's own rejection message and
262/// [`env_name_charset_pattern`]'s rendered pattern, so a `test_env_name_charset_pattern_matches_desc`
263/// regression test can catch the two ever describing a different charset.
264const ENV_NAME_CHARSET_DESC: &str = "[A-Za-z_][A-Za-z0-9_]*";
265
266/// Returns the environment-variable-name identifier charset as an anchored, JavaScript
267/// `RegExp`-compatible pattern source.
268///
269/// Matches `validate_env_name`'s charset rule: first character `[A-Za-z_]`, subsequent
270/// characters `[A-Za-z0-9_]*`. Exposed so downstream consumers that must mirror this exact
271/// rule outside this function —
272/// currently, the generated TypeScript runtime bridge
273/// (`crates/mcp-codegen/templates/progressive/runtime-bridge.ts.hbs`) — can render their copy
274/// directly from this constant at code-generation time instead of hand-copying it. Closes the
275/// #467 gap where the bridge's `validateEnvName` had no charset check at all and relied
276/// entirely on `String.prototype.toUpperCase()`'s incidental Unicode case folding to catch a
277/// forbidden-name confusable.
278///
279/// # Examples
280///
281/// ```
282/// use mcp_execution_core::env_name_charset_pattern;
283///
284/// assert_eq!(env_name_charset_pattern(), "^[A-Za-z_][A-Za-z0-9_]*$");
285/// ```
286#[must_use]
287pub const fn env_name_charset_pattern() -> &'static str {
288 "^[A-Za-z_][A-Za-z0-9_]*$"
289}
290
291/// Returns the human-readable description of the environment-variable-name identifier
292/// charset used in this module's own rejection message (`"[A-Za-z_][A-Za-z0-9_]*"`).
293///
294/// Exposed for the same drift-elimination reason as [`env_name_charset_pattern`] — this covers
295/// the *message text* around the rule rather than its enforcement, so a consumer that renders
296/// its own rejection message (currently the generated TypeScript runtime bridge's
297/// `validateEnvName`) can render it from this constant instead of hand-copying the literal a
298/// second time.
299///
300/// # Examples
301///
302/// ```
303/// use mcp_execution_core::env_name_charset_desc;
304///
305/// assert_eq!(env_name_charset_desc(), "[A-Za-z_][A-Za-z0-9_]*");
306/// ```
307#[must_use]
308pub const fn env_name_charset_desc() -> &'static str {
309 ENV_NAME_CHARSET_DESC
310}
311
312/// Validates a `ServerConfig` for safe execution, dispatching on transport type.
313///
314/// This function performs comprehensive security validation before a config is
315/// used to connect to a server. It validates:
316///
317/// 1. **Stdio transport**: command (absolute path or binary name), arguments, and
318/// environment variables.
319/// 2. **Http/Sse transport**: URL presence and scheme, and HTTP header names/values.
320/// 3. **Timeouts**: `connect_timeout`/`discover_timeout` checked against bounds,
321/// for all transports.
322///
323/// # Security Rules
324///
325/// - **Forbidden chars in command/args**: `;`, `|`, `&`, `>`, `<`, `` ` ``, `$`, `(`, `)`, `\n`, `\r`
326/// - **Env name charset**: must match `[A-Za-z_][A-Za-z0-9_]*` (POSIX/Windows environment
327/// variable identifier convention); rejects non-ASCII Unicode confusables (e.g. U+0131 `ı`,
328/// U+017F `ſ`) that could otherwise dodge the ASCII-only case-insensitive comparison below
329/// while still resolving as the forbidden name on Windows
330/// - **Forbidden env names**: dynamic-linker (`LD_PRELOAD`, `LD_LIBRARY_PATH`,
331/// `LD_AUDIT`, `DYLD_*`), `PATH`, and interpreter hijack vectors
332/// (`NODE_OPTIONS`, `BASH_ENV`, `PYTHONPATH`, `PYTHONSTARTUP`, `RUBYOPT`,
333/// `PERL5OPT`, `JAVA_TOOL_OPTIONS`), matched case-insensitively — see the
334/// `FORBIDDEN_ENV_NAMES` constant's doc comment in this module's source for
335/// the full threat-model note
336/// - **Absolute paths**: Must exist and be executable
337/// - **Binary names**: Allowed (resolved via PATH at runtime)
338/// - **URL scheme**: Must be `http://` or `https://`
339/// - **Header names/values**: Must not contain control characters
340/// - **Timeout bounds**: `connect_timeout`/`discover_timeout` must be greater than zero and at
341/// most `MAX_TIMEOUT` (600s)
342/// - **Element counts/lengths** (denial-of-service protection, CWE-400) — see this module's
343/// `validate_stdio_size_bounds`/`validate_network_size_bounds`: at most `MAX_ARG_COUNT`
344/// args, `MAX_ENV_COUNT` env vars, and `MAX_HEADER_COUNT` headers; at most `MAX_ARG_LEN`
345/// bytes per command/argument/env-name/header-name, `MAX_ENV_VALUE_LEN` bytes per env
346/// value, `MAX_HEADER_VALUE_LEN` bytes per header value, and `MAX_URL_LEN` bytes for the
347/// `url` field
348///
349/// # Errors
350///
351/// Returns `Error::SecurityViolation` if:
352/// - Command is empty or whitespace
353/// - Command/args contain shell metacharacters
354/// - Absolute path does not exist or is not executable
355/// - Environment variable name is forbidden, or outside the `[A-Za-z_][A-Za-z0-9_]*` charset
356/// - URL scheme is not `http://`/`https://`, or a header name/value contains control characters
357///
358/// Returns `Error::ValidationError` if:
359/// - URL is missing for Http/Sse transport
360/// - `connect_timeout` or `discover_timeout` is zero
361/// - `connect_timeout` or `discover_timeout` exceeds `MAX_TIMEOUT` (600s)
362///
363/// # Examples
364///
365/// ```
366/// use mcp_execution_core::{ServerConfig, validate_server_config};
367///
368/// // Valid: binary name
369/// let config = ServerConfig::builder()
370/// .command("docker".to_string())
371/// .build()
372/// .unwrap();
373/// assert!(validate_server_config(&config).is_ok());
374///
375/// // Invalid: forbidden env var — `ServerConfigBuilder::build()` already
376/// // rejects this, so no unvalidated `ServerConfig` reaches this function.
377/// let err = ServerConfig::builder()
378/// .command("docker".to_string())
379/// .env("LD_PRELOAD".to_string(), "/evil.so".to_string())
380/// .build()
381/// .unwrap_err();
382/// assert!(err.is_security_error());
383///
384/// // Valid: HTTP transport
385/// let config = ServerConfig::builder()
386/// .http_transport("https://api.example.com/mcp".to_string())
387/// .build()
388/// .unwrap();
389/// assert!(validate_server_config(&config).is_ok());
390/// ```
391///
392/// # Security Considerations
393///
394/// - Binary names are allowed and resolved via PATH at runtime
395/// - Absolute paths undergo strict validation (existence, permissions)
396/// - All arguments are validated separately to prevent injection
397/// - Environment variables are checked against forbidden names
398/// - Header values are never echoed into error messages, since they routinely
399/// carry secrets such as bearer tokens
400/// - Header *names* are never echoed either, once rejected: a `Name=Value` or
401/// `Name: Value` CLI argument can be mis-split on the wrong separator,
402/// leaving a full secret value in the "name" position — the token-charset
403/// error, the duplicate-header-name error, and the header-value
404/// control-character error all omit the name for this reason
405/// - There is no infinite-timeout option: `0` is always rejected, since an
406/// unbounded wait would let a hung server block this non-interactive tool
407/// forever (see the `validate_timeout` design note in this module)
408pub fn validate_server_config(config: &ServerConfig) -> Result<()> {
409 match config.transport() {
410 Transport::Stdio {
411 command, args, env, ..
412 } => {
413 // Element counts/lengths (denial-of-service protection, CWE-400) are bounded
414 // before the command-injection-specific checks below.
415 validate_stdio_size_bounds(command, args, env)?;
416 validate_stdio_config(command, args, env)?;
417 }
418 Transport::Http { url, headers } | Transport::Sse { url, headers } => {
419 validate_network_size_bounds(url, headers)?;
420 validate_network_config(url, headers)?;
421 }
422 }
423
424 // Validate timeout bounds. Zero fires immediately and breaks all
425 // discovery; an infinite timeout is deliberately unsupported (see
426 // `validate_timeout` doc comment) because it would let a hung or
427 // malicious server block this non-interactive CLI tool forever,
428 // re-opening the DoS window these timeouts were introduced to close.
429 validate_timeout(config.connect_timeout(), "connect_timeout")?;
430 validate_timeout(config.discover_timeout(), "discover_timeout")?;
431
432 Ok(())
433}
434
435/// Bounds `command`'s length and `args`'/`env`'s counts/lengths (denial-of-service
436/// protection, CWE-400) for a [`Transport::Stdio`] config.
437///
438/// Since #313, `Transport::Http`/`Transport::Sse` have no `command`/`args`/`env` fields at
439/// all — the cross-transport bypass this once guarded against (issue #198 S2: a hostile
440/// `mcp.json` populating `args`/`env` for a non-stdio transport) is unrepresentable rather
441/// than merely unchecked, so this only needs to run for the `Stdio` variant.
442///
443/// Deliberately does not check for shell metacharacters or forbidden environment variable
444/// names — that remains [`validate_stdio_config`]'s responsibility, since it is only
445/// meaningful for a config that is actually used to spawn a subprocess.
446fn validate_stdio_size_bounds(
447 command: &str,
448 args: &[String],
449 env: &std::collections::HashMap<String, String>,
450) -> Result<()> {
451 if command.len() > MAX_ARG_LEN {
452 return Err(Error::SecurityViolation {
453 reason: format!(
454 "command too long: {} bytes exceeds the {MAX_ARG_LEN} limit",
455 command.len()
456 ),
457 });
458 }
459
460 if args.len() > MAX_ARG_COUNT {
461 return Err(Error::SecurityViolation {
462 reason: format!(
463 "too many arguments: {} exceeds the {MAX_ARG_COUNT} limit",
464 args.len()
465 ),
466 });
467 }
468 for (idx, arg) in args.iter().enumerate() {
469 if arg.len() > MAX_ARG_LEN {
470 return Err(Error::SecurityViolation {
471 reason: format!(
472 "argument {idx} too long: {} bytes exceeds the {MAX_ARG_LEN} limit",
473 arg.len()
474 ),
475 });
476 }
477 }
478
479 if env.len() > MAX_ENV_COUNT {
480 return Err(Error::SecurityViolation {
481 reason: format!(
482 "too many environment variables: {} exceeds the {MAX_ENV_COUNT} limit",
483 env.len()
484 ),
485 });
486 }
487 for (env_name, env_value) in env {
488 if env_name.len() > MAX_ARG_LEN {
489 return Err(Error::SecurityViolation {
490 reason: format!(
491 "environment variable name too long: {} bytes exceeds the {MAX_ARG_LEN} \
492 limit",
493 env_name.len()
494 ),
495 });
496 }
497 if env_value.len() > MAX_ENV_VALUE_LEN {
498 return Err(Error::SecurityViolation {
499 reason: format!(
500 "environment variable '{env_name}' value too long: {} bytes exceeds the \
501 {MAX_ENV_VALUE_LEN} limit",
502 env_value.len()
503 ),
504 });
505 }
506 }
507
508 Ok(())
509}
510
511/// Bounds `url`'s length and `headers`' count/lengths (denial-of-service protection,
512/// CWE-400) for a [`Transport::Http`]/[`Transport::Sse`] config.
513///
514/// See [`validate_stdio_size_bounds`]'s doc comment for why this only needs to run for its
515/// own variant family since #313.
516fn validate_network_size_bounds(
517 url: &str,
518 headers: &std::collections::HashMap<String, String>,
519) -> Result<()> {
520 if url.len() > MAX_URL_LEN {
521 return Err(Error::SecurityViolation {
522 reason: format!(
523 "url too long: {} bytes exceeds the {MAX_URL_LEN} limit",
524 url.len()
525 ),
526 });
527 }
528
529 if headers.len() > MAX_HEADER_COUNT {
530 return Err(Error::SecurityViolation {
531 reason: format!(
532 "too many headers: {} exceeds the {MAX_HEADER_COUNT} limit",
533 headers.len()
534 ),
535 });
536 }
537 for (name, value) in headers {
538 if name.len() > MAX_ARG_LEN {
539 return Err(Error::SecurityViolation {
540 reason: format!(
541 "header name too long: {} bytes exceeds the {MAX_ARG_LEN} limit",
542 name.len()
543 ),
544 });
545 }
546 if value.len() > MAX_HEADER_VALUE_LEN {
547 return Err(Error::SecurityViolation {
548 reason: format!(
549 "header value too long: {} bytes exceeds the {MAX_HEADER_VALUE_LEN} limit",
550 value.len()
551 ),
552 });
553 }
554 }
555
556 Ok(())
557}
558
559/// Validates the stdio-transport-specific fields of a `ServerConfig`.
560///
561/// Checks the command (absolute path or binary name), arguments, and environment variables
562/// for command-injection risks. Element counts/lengths are already bounded unconditionally by
563/// [`validate_stdio_size_bounds`] before this runs; this function only adds the checks that
564/// are meaningful specifically because this config will be used to spawn a subprocess.
565fn validate_stdio_config(
566 command: &str,
567 args: &[String],
568 env: &std::collections::HashMap<String, String>,
569) -> Result<()> {
570 // Validate command
571 validate_command_string(command, "command")?;
572
573 // If command is absolute path, perform additional checks
574 let command_path = Path::new(command);
575 if command_path.is_absolute() {
576 validate_absolute_path(command)?;
577 }
578 // If not absolute, it's a binary name (to be resolved via PATH) - this is OK
579
580 // Validate each argument separately
581 for (idx, arg) in args.iter().enumerate() {
582 validate_command_string(arg, &format!("argument {idx}"))?;
583 }
584
585 // Validate environment variable names
586 for env_name in env.keys() {
587 validate_env_name(env_name)?;
588 }
589
590 Ok(())
591}
592
593/// Validates the Http/Sse-transport-specific fields of a `ServerConfig`.
594///
595/// `url` is a required field of [`Transport::Http`]/[`Transport::Sse`] (see #313), so unlike
596/// before, a config missing it cannot reach this function at all — that gap is now closed at
597/// deserialization/construction time rather than here.
598///
599/// `headers`'/`url`'s element counts/lengths are already bounded unconditionally by
600/// [`validate_network_size_bounds`] before this runs; this function only adds the checks that
601/// are meaningful specifically because this config will be used to send an HTTP request
602/// (header name charset, control characters, scheme, duplicate names).
603fn validate_network_config(
604 url: &str,
605 headers: &std::collections::HashMap<String, String>,
606) -> Result<()> {
607 validate_url_scheme(url)?;
608
609 // `http::HeaderName` lowercases on parse, so two headers that differ only
610 // in case (e.g. "Authorization" and "authorization") collapse into a
611 // single entry with a nondeterministic winner once converted — reject
612 // that here rather than letting it silently drop a header downstream.
613 let mut seen_header_names = std::collections::HashSet::new();
614 for (name, value) in headers {
615 validate_header_name_string(name)?;
616 validate_header_value_string(value)?;
617 if !seen_header_names.insert(name.to_ascii_lowercase()) {
618 return Err(Error::SecurityViolation {
619 reason: "duplicate header name (case-insensitive); name omitted as it may \
620 be secret-shaped"
621 .to_string(),
622 });
623 }
624 }
625
626 Ok(())
627}
628
629/// Validates that a URL uses the `http://` or `https://` scheme.
630///
631/// This is defense in depth: rejects `file://`, `unix://`, and similar
632/// schemes at the `mcp-core` validation boundary rather than relying on the
633/// HTTP client to reject them. The scheme comparison is case-insensitive per
634/// RFC 3986 (`HTTP://host` is a valid URL, not a different scheme).
635///
636/// This is a minimal, string-based scheme check — it does not validate the
637/// rest of the URL's structure (e.g. it does not require a host). It is
638/// exposed publicly so that other crates checking URL validity for the same
639/// http/sse transport (e.g. `mcp-execution-cli`'s server status/validation
640/// commands) can share this exact rule instead of drifting from it with a
641/// second, differently-behaved check.
642///
643/// # Errors
644///
645/// Returns [`Error::SecurityViolation`] if `url` does not start with an
646/// `http://` or `https://` scheme (case-insensitive).
647///
648/// # Examples
649///
650/// ```
651/// use mcp_execution_core::validate_url_scheme;
652///
653/// assert!(validate_url_scheme("https://example.com/mcp").is_ok());
654/// assert!(validate_url_scheme("HTTP://example.com").is_ok());
655/// assert!(validate_url_scheme("ftp://example.com").is_err());
656/// assert!(validate_url_scheme(" https://example.com").is_err());
657/// ```
658pub fn validate_url_scheme(url: &str) -> Result<()> {
659 let is_valid = url.split_once("://").is_some_and(|(scheme, _)| {
660 scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
661 });
662 if is_valid {
663 Ok(())
664 } else {
665 Err(Error::SecurityViolation {
666 reason: "url must use the http:// or https:// scheme".to_string(),
667 })
668 }
669}
670
671/// Returns `true` if `value` contains an ASCII or Unicode control character
672/// (including `\r`, `\n`, and NUL), which could otherwise be used to smuggle
673/// extra header lines into an HTTP request.
674fn contains_control_char(value: &str) -> bool {
675 value.chars().any(char::is_control)
676}
677
678/// Returns `true` if `c` is a valid RFC 7230 `tchar` (the charset allowed in
679/// an HTTP header field name).
680const fn is_header_name_tchar(c: char) -> bool {
681 c.is_ascii_alphanumeric()
682 || matches!(
683 c,
684 '!' | '#'
685 | '$'
686 | '%'
687 | '&'
688 | '\''
689 | '*'
690 | '+'
691 | '-'
692 | '.'
693 | '^'
694 | '_'
695 | '`'
696 | '|'
697 | '~'
698 )
699}
700
701/// Validates an HTTP header name against the RFC 7230 `token` charset.
702///
703/// A plain control-character check is not tight enough: a space, `:`, or `@`
704/// is not a control character but is still an invalid header-name character
705/// that would otherwise pass here and fail later inside `http::HeaderName`
706/// construction with an opaque error.
707///
708/// # Security
709///
710/// The rejected name is never echoed into the error message. A `Name=Value`
711/// or `Name: Value` CLI argument can be mis-split on the wrong separator,
712/// leaving a full secret value in the "name" position; that value only needs
713/// one non-`tchar` byte to reach this branch, so it must be treated the same
714/// as a secret — mirroring the duplicate-header-name check below, which
715/// redacts for the same reason.
716fn validate_header_name_string(name: &str) -> Result<()> {
717 if name.is_empty() {
718 return Err(Error::SecurityViolation {
719 reason: "header name cannot be empty".to_string(),
720 });
721 }
722 if !name.chars().all(is_header_name_tchar) {
723 return Err(Error::SecurityViolation {
724 reason: "header name contains characters outside the allowed HTTP token charset"
725 .to_string(),
726 });
727 }
728 Ok(())
729}
730
731/// Validates an HTTP header value for control characters.
732///
733/// # Security
734///
735/// The header *value* routinely carries secrets (e.g. bearer tokens), so it
736/// must never appear in the returned error's reason string. The header
737/// *name* is not echoed either: this runs after `validate_header_name_string`
738/// has already accepted it as RFC 7230 `token`-charset-only, the same
739/// "may still be secret-shaped input from a misparsed argument" condition
740/// that the tchar-violation and duplicate-header-name errors above already
741/// treat as untrusted.
742fn validate_header_value_string(value: &str) -> Result<()> {
743 if contains_control_char(value) {
744 return Err(Error::SecurityViolation {
745 reason: "header value contains control characters".to_string(),
746 });
747 }
748 Ok(())
749}
750
751/// Validates that a timeout is within `(0, MAX_TIMEOUT]`.
752///
753/// # Design Note: No Infinite Timeout
754///
755/// A timeout of zero is permanently rejected rather than treated as a
756/// sentinel for "no timeout". This tool spawns subprocesses and connects to
757/// servers non-interactively (CLI and MCP-server modes); an unbounded
758/// connect/discover wait would let a hung or malicious server block the
759/// caller indefinitely, which is exactly the denial-of-service exposure
760/// these timeouts were added to close. Callers that need a longer wait
761/// should raise the value up to `MAX_TIMEOUT` (10 minutes) instead.
762fn validate_timeout(timeout: Duration, field: &str) -> Result<()> {
763 if timeout.is_zero() {
764 return Err(Error::ValidationError {
765 field: field.to_string(),
766 reason: "timeout must be greater than zero".to_string(),
767 });
768 }
769 if timeout > MAX_TIMEOUT {
770 return Err(Error::ValidationError {
771 field: field.to_string(),
772 reason: format!("timeout {timeout:?} exceeds maximum allowed {MAX_TIMEOUT:?}"),
773 });
774 }
775 Ok(())
776}
777
778/// Validates a command string for forbidden shell metacharacters.
779///
780/// This is an internal helper that checks a string (command or argument)
781/// for dangerous shell metacharacters. Length is already bounded unconditionally by
782/// [`validate_stdio_size_bounds`] before this runs.
783///
784/// # Security
785///
786/// The offending value is never echoed into the error message. `context` is
787/// `"argument {idx}"` for CLI arguments, which routinely carry secrets in a
788/// `--api-key sk-...`-style value; the same "may be secret-shaped" treatment
789/// as `validate_header_value_string` and the duplicate-header-name check
790/// applies here.
791fn validate_command_string(value: &str, context: &str) -> Result<()> {
792 // Check for empty
793 let value = value.trim();
794 if value.is_empty() {
795 return Err(Error::SecurityViolation {
796 reason: format!("{context} cannot be empty"),
797 });
798 }
799
800 // Check for shell metacharacters
801 for forbidden in FORBIDDEN_CHARS {
802 if value.contains(*forbidden) {
803 return Err(Error::SecurityViolation {
804 reason: format!(
805 "{context} contains forbidden shell metacharacter '{forbidden}'; \
806 value omitted as it may be secret-shaped"
807 ),
808 });
809 }
810 }
811
812 Ok(())
813}
814
815/// Validates an absolute path command for existence and executability.
816///
817/// This is an internal helper that performs file system checks on
818/// absolute path commands.
819fn validate_absolute_path(command: &str) -> Result<()> {
820 let path = Path::new(command);
821
822 // Verify file exists
823 if !path.exists() {
824 return Err(Error::SecurityViolation {
825 reason: format!("Command file does not exist: {command}"),
826 });
827 }
828
829 // Verify it's a file (not a directory)
830 if !path.is_file() {
831 return Err(Error::SecurityViolation {
832 reason: format!("Command path is not a file: {command}"),
833 });
834 }
835
836 // Verify executable permissions (Unix only)
837 #[cfg(unix)]
838 {
839 use std::os::unix::fs::PermissionsExt;
840 let metadata = std::fs::metadata(path).map_err(|e| Error::SecurityViolation {
841 reason: format!("Cannot read command metadata: {e}"),
842 })?;
843 let permissions = metadata.permissions();
844 let mode = permissions.mode();
845
846 // Check if any execute bit is set (owner, group, or other)
847 if mode & 0o111 == 0 {
848 return Err(Error::SecurityViolation {
849 reason: format!("Command file is not executable: {command}"),
850 });
851 }
852 }
853
854 Ok(())
855}
856
857/// Validates an environment variable name.
858///
859/// This is an internal helper that checks if an environment variable name is in the
860/// forbidden list. Length is already bounded unconditionally by [`validate_stdio_size_bounds`]
861/// before this runs.
862///
863/// The comparison is ASCII-case-insensitive: Windows treats environment variable names as
864/// case-insensitive at the OS/`CreateProcess` level (and so does std's `Command` environment
865/// block on that platform), so a case-varied spelling such as `Path` or `path` would otherwise
866/// bypass this list while still functioning as a real override when the subprocess is spawned.
867///
868/// Before that comparison, the name is required to match the conventional POSIX/Windows
869/// environment-variable-name charset `[A-Za-z_][A-Za-z0-9_]*`. Windows' own name comparison
870/// folds case using the OS's Unicode uppercase table, which is broader than the ASCII-only
871/// folding `eq_ignore_ascii_case` performs here — e.g. `ı` (U+0131, Turkish dotless i)
872/// uppercases to `I` and `ſ` (U+017F, long s) uppercases to `S` on Windows, so a forbidden name
873/// spelled with one of these in place of the ASCII letter (e.g. `NODE_OPTıONS`) would pass the
874/// ASCII-only comparison yet still resolve as the forbidden name once handed to the OS
875/// environment block. Rather than chase every such Unicode confusable, any name outside the
876/// conventional identifier charset is rejected outright, since it is not a valid environment
877/// variable name to begin with.
878fn validate_env_name(name: &str) -> Result<()> {
879 let mut chars = name.chars();
880 let is_valid_charset = chars
881 .next()
882 .is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
883 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
884 if !is_valid_charset {
885 return Err(Error::SecurityViolation {
886 reason: format!(
887 "environment variable name is not a valid identifier (expected \
888 {ENV_NAME_CHARSET_DESC}): {name}"
889 ),
890 });
891 }
892
893 // Check for forbidden env names (exact match, case-insensitive)
894 if FORBIDDEN_ENV_NAMES
895 .iter()
896 .any(|forbidden| name.eq_ignore_ascii_case(forbidden))
897 {
898 return Err(Error::SecurityViolation {
899 reason: format!("Forbidden environment variable name: {name}"),
900 });
901 }
902
903 // Check for DYLD_* prefix (macOS dynamic linker variables), case-insensitive.
904 // Compared as bytes (not `str` slicing) so a multi-byte UTF-8 name can never
905 // panic on a non-char-boundary split.
906 if name
907 .as_bytes()
908 .get(..FORBIDDEN_ENV_PREFIX.len())
909 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(FORBIDDEN_ENV_PREFIX.as_bytes()))
910 {
911 return Err(Error::SecurityViolation {
912 reason: format!("Forbidden environment variable prefix DYLD_: {name}"),
913 });
914 }
915
916 Ok(())
917}
918
919#[cfg(test)]
920mod tests {
921 use super::*;
922 use std::collections::HashMap;
923 use std::fs;
924 use std::io::Write;
925
926 #[test]
927 fn test_validate_server_config_binary_name() {
928 // Binary names (not absolute paths) should be valid
929 assert!(
930 ServerConfig::builder()
931 .command("docker".to_string())
932 .build()
933 .is_ok()
934 );
935 assert!(
936 ServerConfig::builder()
937 .command("python".to_string())
938 .build()
939 .is_ok()
940 );
941 assert!(
942 ServerConfig::builder()
943 .command("node".to_string())
944 .build()
945 .is_ok()
946 );
947 }
948
949 #[test]
950 fn test_validate_server_config_binary_with_args() {
951 let result = ServerConfig::builder()
952 .command("docker".to_string())
953 .arg("run".to_string())
954 .arg("--rm".to_string())
955 .arg("mcp-server".to_string())
956 .build();
957 assert!(result.is_ok());
958 }
959
960 #[test]
961 fn test_validate_server_config_empty_command() {
962 // Empty command should fail during build
963 let result = ServerConfig::builder().command(String::new()).build();
964 assert!(result.is_err());
965 assert!(result.unwrap_err().to_string().contains("empty"));
966
967 // Whitespace-only command should fail during build
968 let result = ServerConfig::builder().command(" ".to_string()).build();
969 assert!(result.is_err());
970 assert!(result.unwrap_err().to_string().contains("empty"));
971 }
972
973 #[test]
974 fn test_validate_server_config_command_with_metacharacters() {
975 let dangerous_commands = vec![
976 "docker; rm -rf /",
977 "docker | cat",
978 "docker && echo pwned",
979 "docker > /tmp/out",
980 "docker < /tmp/in",
981 "docker `whoami`",
982 "docker $(whoami)",
983 "docker & background",
984 "docker\nrm -rf /",
985 ];
986
987 for cmd in dangerous_commands {
988 // `build()` now runs security validation internally, so a config
989 // carrying a shell metacharacter is rejected at construction.
990 let result = ServerConfig::builder().command(cmd.to_string()).build();
991 assert!(
992 result.is_err(),
993 "Should reject command with metacharacters: {cmd}"
994 );
995 if let Err(Error::SecurityViolation { reason }) = result {
996 assert!(
997 reason.contains("forbidden") || reason.contains("metacharacter"),
998 "Error should mention forbidden character: {reason}"
999 );
1000 }
1001 }
1002 }
1003
1004 #[test]
1005 fn test_validate_server_config_args_with_metacharacters() {
1006 let dangerous_args = vec![
1007 "run; rm -rf /",
1008 "run | cat",
1009 "run && echo pwned",
1010 "run > /tmp/out",
1011 "run < /tmp/in",
1012 "run `whoami`",
1013 "run $(whoami)",
1014 "run & background",
1015 "run\nrm -rf /",
1016 ];
1017
1018 for arg in dangerous_args {
1019 let result = ServerConfig::builder()
1020 .command("docker".to_string())
1021 .arg(arg.to_string())
1022 .build();
1023 assert!(
1024 result.is_err(),
1025 "Should reject arg with metacharacters: {arg}"
1026 );
1027 if let Err(Error::SecurityViolation { reason }) = result {
1028 assert!(
1029 reason.contains("argument")
1030 && (reason.contains("forbidden") || reason.contains("metacharacter")),
1031 "Error should mention argument and forbidden character: {reason}"
1032 );
1033 }
1034 }
1035 }
1036
1037 #[test]
1038 fn test_validate_server_config_arg_with_metacharacter_does_not_leak_secret() {
1039 // Regression test for #229: a rejected arg is routinely a
1040 // misparsed `--api-key sk-...`-style secret; the metacharacter
1041 // error must never echo the raw value.
1042 let secret_shaped_arg = "--api-key sk-live-supersecretvalue1234567890;whoami";
1043 let result = ServerConfig::builder()
1044 .command("docker".to_string())
1045 .arg(secret_shaped_arg.to_string())
1046 .build();
1047
1048 assert!(result.is_err());
1049 if let Err(Error::SecurityViolation { reason }) = result {
1050 assert!(!reason.contains(secret_shaped_arg));
1051 assert!(!reason.contains("sk-live-supersecretvalue1234567890"));
1052 }
1053 }
1054
1055 #[test]
1056 fn test_validate_server_config_empty_arg() {
1057 let result = ServerConfig::builder()
1058 .command("docker".to_string())
1059 .arg(String::new())
1060 .build();
1061 assert!(result.is_err());
1062 }
1063
1064 #[test]
1065 fn test_validate_server_config_forbidden_env_ld_preload() {
1066 let result = ServerConfig::builder()
1067 .command("docker".to_string())
1068 .env("LD_PRELOAD".to_string(), "/evil.so".to_string())
1069 .build();
1070 assert!(result.is_err());
1071 if let Err(Error::SecurityViolation { reason }) = result {
1072 assert!(reason.contains("LD_PRELOAD"));
1073 }
1074 }
1075
1076 #[test]
1077 fn test_validate_server_config_forbidden_env_ld_library_path() {
1078 let result = ServerConfig::builder()
1079 .command("docker".to_string())
1080 .env("LD_LIBRARY_PATH".to_string(), "/evil".to_string())
1081 .build();
1082 assert!(result.is_err());
1083 if let Err(Error::SecurityViolation { reason }) = result {
1084 assert!(reason.contains("LD_LIBRARY_PATH"));
1085 }
1086 }
1087
1088 #[test]
1089 fn test_validate_server_config_forbidden_env_dyld() {
1090 let dyld_vars = vec![
1091 "DYLD_INSERT_LIBRARIES",
1092 "DYLD_LIBRARY_PATH",
1093 "DYLD_FRAMEWORK_PATH",
1094 "DYLD_PRINT_TO_FILE",
1095 "DYLD_CUSTOM_VAR",
1096 ];
1097
1098 for var in dyld_vars {
1099 let result = ServerConfig::builder()
1100 .command("docker".to_string())
1101 .env(var.to_string(), "/evil".to_string())
1102 .build();
1103 assert!(result.is_err(), "Should reject DYLD_* variable: {var}");
1104 if let Err(Error::SecurityViolation { reason }) = result {
1105 assert!(
1106 reason.contains("DYLD_"),
1107 "Error should mention DYLD_: {reason}"
1108 );
1109 }
1110 }
1111 }
1112
1113 #[test]
1114 fn test_validate_server_config_forbidden_env_path() {
1115 let result = ServerConfig::builder()
1116 .command("docker".to_string())
1117 .env("PATH".to_string(), "/evil:/usr/bin".to_string())
1118 .build();
1119 assert!(result.is_err());
1120 if let Err(Error::SecurityViolation { reason }) = result {
1121 assert!(reason.contains("PATH"));
1122 }
1123 }
1124
1125 /// #221.1 — the interpreter hijack vectors added alongside the original
1126 /// dynamic-linker/`PATH` entries must also be rejected.
1127 #[test]
1128 fn test_validate_server_config_forbidden_env_interpreter_hijack_vectors() {
1129 // NODE_OPTIONS and BASH_ENV have their own dedicated tests above.
1130 let interpreter_vars = vec![
1131 "PYTHONPATH",
1132 "PYTHONSTARTUP",
1133 "RUBYOPT",
1134 "PERL5OPT",
1135 "JAVA_TOOL_OPTIONS",
1136 "LD_AUDIT",
1137 ];
1138
1139 for var in interpreter_vars {
1140 let result = ServerConfig::builder()
1141 .command("docker".to_string())
1142 .env(var.to_string(), "evil".to_string())
1143 .build();
1144 assert!(result.is_err(), "Should reject variable: {var}");
1145 if let Err(Error::SecurityViolation { reason }) = result {
1146 assert!(reason.contains(var), "Error should mention {var}: {reason}");
1147 }
1148 }
1149 }
1150
1151 #[test]
1152 fn test_validate_server_config_forbidden_env_node_options() {
1153 // NODE_OPTIONS lets a config inject e.g. `--require /tmp/evil.js` into any Node
1154 // subprocess the server itself spawns.
1155 let result = ServerConfig::builder()
1156 .command("node".to_string())
1157 .env(
1158 "NODE_OPTIONS".to_string(),
1159 "--require /tmp/evil.js".to_string(),
1160 )
1161 .build();
1162 assert!(result.is_err());
1163 if let Err(Error::SecurityViolation { reason }) = result {
1164 assert!(reason.contains("NODE_OPTIONS"));
1165 }
1166 }
1167
1168 #[test]
1169 fn test_validate_server_config_forbidden_env_bash_env() {
1170 // BASH_ENV is sourced by non-interactive `bash` before running a script or command.
1171 let result = ServerConfig::builder()
1172 .command("bash".to_string())
1173 .env("BASH_ENV".to_string(), "/tmp/evil.sh".to_string())
1174 .build();
1175 assert!(result.is_err());
1176 if let Err(Error::SecurityViolation { reason }) = result {
1177 assert!(reason.contains("BASH_ENV"));
1178 }
1179 }
1180
1181 #[test]
1182 fn test_validate_server_config_safe_env() {
1183 let result = ServerConfig::builder()
1184 .command("docker".to_string())
1185 .env("LOG_LEVEL".to_string(), "debug".to_string())
1186 .env("DEBUG".to_string(), "1".to_string())
1187 .env("HOME".to_string(), "/home/user".to_string())
1188 .env("MY_CUSTOM_VAR".to_string(), "value".to_string())
1189 .build();
1190 assert!(result.is_ok());
1191 }
1192
1193 #[test]
1194 #[cfg(unix)]
1195 fn test_validate_server_config_absolute_path_valid() {
1196 use std::os::unix::fs::PermissionsExt;
1197
1198 // Create a temporary executable file
1199 let temp_file = "/tmp/test-mcp-server-config";
1200 let mut file = fs::File::create(temp_file).unwrap();
1201 writeln!(file, "#!/bin/sh").unwrap();
1202
1203 // Set execute permissions
1204 let mut perms = fs::metadata(temp_file).unwrap().permissions();
1205 perms.set_mode(0o755);
1206 fs::set_permissions(temp_file, perms).unwrap();
1207
1208 let result = ServerConfig::builder()
1209 .command(temp_file.to_string())
1210 .arg("--port".to_string())
1211 .arg("8080".to_string())
1212 .build();
1213
1214 fs::remove_file(temp_file).ok();
1215
1216 assert!(result.is_ok());
1217 }
1218
1219 #[test]
1220 #[cfg(unix)]
1221 fn test_validate_server_config_absolute_path_not_executable() {
1222 use std::os::unix::fs::PermissionsExt;
1223
1224 // Create a temporary non-executable file
1225 let temp_file = "/tmp/test-mcp-server-config-noexec";
1226 let mut file = fs::File::create(temp_file).unwrap();
1227 writeln!(file, "#!/bin/sh").unwrap();
1228
1229 // Remove execute permissions
1230 let mut perms = fs::metadata(temp_file).unwrap().permissions();
1231 perms.set_mode(0o644);
1232 fs::set_permissions(temp_file, perms).unwrap();
1233
1234 let result = ServerConfig::builder()
1235 .command(temp_file.to_string())
1236 .build();
1237
1238 fs::remove_file(temp_file).ok();
1239
1240 assert!(result.is_err());
1241 if let Err(Error::SecurityViolation { reason }) = result {
1242 assert!(reason.contains("not executable"));
1243 }
1244 }
1245
1246 #[test]
1247 fn test_validate_server_config_absolute_path_nonexistent() {
1248 #[cfg(unix)]
1249 let nonexistent = "/absolutely/nonexistent/path/to/server";
1250 #[cfg(windows)]
1251 let nonexistent = "C:\\absolutely\\nonexistent\\path\\to\\server.exe";
1252
1253 let result = ServerConfig::builder()
1254 .command(nonexistent.to_string())
1255 .build();
1256
1257 assert!(result.is_err());
1258 if let Err(Error::SecurityViolation { reason }) = result {
1259 assert!(reason.contains("does not exist"));
1260 }
1261 }
1262
1263 #[test]
1264 fn test_validate_server_config_with_cwd() {
1265 // cwd doesn't affect validation (it's not security-critical)
1266 let result = ServerConfig::builder()
1267 .command("docker".to_string())
1268 .cwd(std::path::PathBuf::from("/tmp"))
1269 .build();
1270 assert!(result.is_ok());
1271 }
1272
1273 #[test]
1274 fn test_validate_server_config_complex_valid() {
1275 let result = ServerConfig::builder()
1276 .command("docker".to_string())
1277 .arg("run".to_string())
1278 .arg("--rm".to_string())
1279 .arg("-e".to_string())
1280 .arg("DEBUG=1".to_string())
1281 .arg("mcp-server".to_string())
1282 .env("LOG_LEVEL".to_string(), "info".to_string())
1283 .env("CACHE_DIR".to_string(), "/var/cache".to_string())
1284 .cwd(std::path::PathBuf::from("/opt/app"))
1285 .build();
1286 assert!(result.is_ok());
1287 }
1288
1289 #[test]
1290 fn test_validate_server_config_default_timeouts_pass() {
1291 let result = ServerConfig::builder()
1292 .command("docker".to_string())
1293 .build();
1294 assert!(result.is_ok());
1295 }
1296
1297 #[test]
1298 fn test_validate_server_config_zero_connect_timeout_rejected() {
1299 let result = ServerConfig::builder()
1300 .command("docker".to_string())
1301 .connect_timeout(std::time::Duration::ZERO)
1302 .build();
1303 assert!(result.is_err());
1304 if let Err(Error::ValidationError { field, reason }) = result {
1305 assert_eq!(field, "connect_timeout");
1306 assert!(reason.contains("greater than zero"));
1307 } else {
1308 panic!("expected ValidationError");
1309 }
1310 }
1311
1312 #[test]
1313 fn test_validate_server_config_zero_discover_timeout_rejected() {
1314 let result = ServerConfig::builder()
1315 .command("docker".to_string())
1316 .discover_timeout(std::time::Duration::ZERO)
1317 .build();
1318 assert!(result.is_err());
1319 if let Err(Error::ValidationError { field, .. }) = result {
1320 assert_eq!(field, "discover_timeout");
1321 } else {
1322 panic!("expected ValidationError");
1323 }
1324 }
1325
1326 #[test]
1327 fn test_validate_server_config_above_max_timeout_rejected() {
1328 let result = ServerConfig::builder()
1329 .command("docker".to_string())
1330 .connect_timeout(std::time::Duration::from_secs(601))
1331 .build();
1332 assert!(result.is_err());
1333 if let Err(Error::ValidationError { field, reason }) = result {
1334 assert_eq!(field, "connect_timeout");
1335 assert!(reason.contains("exceeds maximum"));
1336 } else {
1337 panic!("expected ValidationError");
1338 }
1339 }
1340
1341 #[test]
1342 fn test_validate_server_config_in_bounds_timeout_accepted() {
1343 let result = ServerConfig::builder()
1344 .command("docker".to_string())
1345 .connect_timeout(std::time::Duration::from_mins(1))
1346 .discover_timeout(std::time::Duration::from_mins(10))
1347 .build();
1348 assert!(result.is_ok());
1349 }
1350
1351 #[test]
1352 fn test_validate_env_name_edge_cases() {
1353 // Test exact matches and prefix matches
1354 assert!(validate_env_name("LD_PRELOAD").is_err());
1355 assert!(validate_env_name("DYLD_TEST").is_err());
1356 assert!(validate_env_name("PATH").is_err());
1357
1358 // These should be OK (not in forbidden list)
1359 assert!(validate_env_name("LD_DEBUG").is_ok()); // Not in list
1360 assert!(validate_env_name("MY_PATH").is_ok()); // Not exact match
1361 assert!(validate_env_name("DYLD").is_ok()); // No underscore, not prefix match
1362 }
1363
1364 #[test]
1365 fn test_validate_env_name_case_insensitive() {
1366 // Windows treats env var names as case-insensitive, so any casing of an
1367 // exact-match forbidden name must be rejected, not just the canonical spelling.
1368 assert!(validate_env_name("Path").is_err());
1369 assert!(validate_env_name("path").is_err());
1370 assert!(validate_env_name("PATH").is_err());
1371 assert!(validate_env_name("PaTh").is_err());
1372
1373 assert!(validate_env_name("Ld_Preload").is_err());
1374 assert!(validate_env_name("ld_preload").is_err());
1375
1376 assert!(validate_env_name("Node_Options").is_err());
1377 assert!(validate_env_name("node_options").is_err());
1378
1379 // Prefix match must also be case-insensitive.
1380 assert!(validate_env_name("dyld_insert_libraries").is_err());
1381 assert!(validate_env_name("Dyld_Insert_Libraries").is_err());
1382 assert!(validate_env_name("dYlD_anything").is_err());
1383
1384 // Sanity: names that are not case variants of a forbidden entry stay allowed.
1385 assert!(validate_env_name("MyPath").is_ok());
1386 assert!(validate_env_name("dyl").is_ok()); // too short for DYLD_ prefix, not a match
1387 }
1388
1389 #[test]
1390 fn test_validate_env_name_prefix_check_does_not_panic_on_utf8_boundary() {
1391 // "DYL€_REST": the euro sign ('\u{20AC}') is a 3-byte UTF-8 sequence occupying byte
1392 // indices 3..6, so byte index `FORBIDDEN_ENV_PREFIX.len()` (5) falls strictly inside
1393 // it and is not a char boundary. A naive `name[..5]` slice would panic here. The
1394 // charset check now rejects this name (non-ASCII characters are never valid identifier
1395 // characters) before the byte-slice `eq_ignore_ascii_case` prefix comparison even runs,
1396 // but that comparison must still not panic if reached directly.
1397 let name = "DYL\u{20AC}_REST";
1398 assert_eq!(name.len(), 3 + 3 + 5);
1399 assert!(!name.is_char_boundary(5));
1400 assert!(validate_env_name(name).is_err());
1401 }
1402
1403 /// #438 — Windows' own environment-name comparison folds case using the OS's Unicode
1404 /// uppercase table, which is broader than `eq_ignore_ascii_case`. A forbidden name spelled
1405 /// with a Unicode confusable in place of an ASCII letter must be rejected outright by the
1406 /// charset check, since it is not a valid `[A-Za-z_][A-Za-z0-9_]*` identifier to begin with.
1407 #[test]
1408 fn test_validate_env_name_rejects_unicode_case_confusables() {
1409 // U+0131 LATIN SMALL LETTER DOTLESS I uppercases to 'I' on Windows.
1410 assert!(validate_env_name("NODE_OPT\u{0131}ONS").is_err());
1411 // U+017F LATIN SMALL LETTER LONG S uppercases to 'S' on Windows.
1412 assert!(validate_env_name("JAVA_TOOL_OPTION\u{017F}").is_err());
1413 }
1414
1415 #[test]
1416 fn test_validate_env_name_charset_accepts_valid_ascii_identifiers() {
1417 assert!(validate_env_name("MY_VAR_1").is_ok());
1418 assert!(validate_env_name("_LEADING_UNDERSCORE").is_ok());
1419 assert!(validate_env_name("A").is_ok());
1420 assert!(validate_env_name("a1").is_ok());
1421 }
1422
1423 #[test]
1424 fn test_validate_env_name_charset_rejects_empty_and_leading_digit() {
1425 assert!(validate_env_name("").is_err());
1426 assert!(validate_env_name("1FOO").is_err());
1427 assert!(validate_env_name("9").is_err());
1428 }
1429
1430 #[test]
1431 fn test_env_name_charset_pattern_matches_desc() {
1432 // Guards against `ENV_NAME_CHARSET_DESC` (used in the rejection message) and
1433 // `env_name_charset_pattern()` (rendered into the generated TS bridge) ever
1434 // describing a different charset.
1435 assert_eq!(
1436 env_name_charset_pattern(),
1437 format!("^{ENV_NAME_CHARSET_DESC}$")
1438 );
1439 }
1440
1441 #[test]
1442 fn test_env_name_charset_desc_matches_pattern() {
1443 assert_eq!(
1444 env_name_charset_desc(),
1445 format!("^{ENV_NAME_CHARSET_DESC}$")
1446 .trim_start_matches('^')
1447 .trim_end_matches('$')
1448 );
1449 assert_eq!(env_name_charset_desc(), ENV_NAME_CHARSET_DESC);
1450 }
1451
1452 #[test]
1453 fn test_env_name_charset_pattern_has_no_ts_string_literal_metacharacters() {
1454 // The generated TS bridge embeds this pattern inside a single-quoted string literal
1455 // (`new RegExp('...')`), passed through `sanitize_ts_string_literal` for defense in
1456 // depth (see `BridgeContext::default` in `mcp-codegen`). This test guards the
1457 // assumption that currently makes that escaping a no-op: if a future edit introduces a
1458 // `'` or `\` into this pattern, the escaping stops being a no-op and becomes the only
1459 // thing standing between this constant and a broken (or silently different) regex in
1460 // the rendered bridge — see #471/#467 critique S2.
1461 let pattern = env_name_charset_pattern();
1462 assert!(
1463 !pattern.contains('\''),
1464 "pattern must not contain a quote: {pattern}"
1465 );
1466 assert!(
1467 !pattern.contains('\\'),
1468 "pattern must not contain a backslash: {pattern}"
1469 );
1470 }
1471
1472 #[test]
1473 fn test_forbidden_env_constants_are_already_ascii_uppercase() {
1474 // The generated TS runtime bridge (`runtime-bridge.ts.hbs`) renders these constants
1475 // verbatim and upper-cases only the *input* name before comparing, relying on
1476 // `FORBIDDEN_ENV_NAMES`/`FORBIDDEN_ENV_PREFIX` already being upper-case. This invariant
1477 // protects that render-from-Rust drift guarantee.
1478 for forbidden in FORBIDDEN_ENV_NAMES {
1479 assert_eq!(*forbidden, forbidden.to_ascii_uppercase());
1480 }
1481 assert_eq!(
1482 FORBIDDEN_ENV_PREFIX,
1483 FORBIDDEN_ENV_PREFIX.to_ascii_uppercase()
1484 );
1485 }
1486
1487 // ── Http/Sse transport validation ────────────────────────────────────────
1488
1489 #[test]
1490 fn test_validate_server_config_http_valid() {
1491 let result = ServerConfig::builder()
1492 .http_transport("https://api.example.com/mcp".to_string())
1493 .build();
1494 assert!(result.is_ok());
1495 }
1496
1497 #[test]
1498 fn test_validate_server_config_sse_valid() {
1499 let result = ServerConfig::builder()
1500 .sse_transport("https://api.example.com/sse".to_string())
1501 .build();
1502 assert!(result.is_ok());
1503 }
1504
1505 #[test]
1506 fn test_validate_server_config_http_with_valid_headers() {
1507 let result = ServerConfig::builder()
1508 .http_transport("https://api.example.com/mcp".to_string())
1509 .header("Authorization".to_string(), "Bearer token123".to_string())
1510 .build();
1511 assert!(result.is_ok());
1512 }
1513
1514 /// #313 — `url` is a required (non-`#[serde(default)]`) field of `Transport::Http`, so a
1515 /// hand-edited `mcp.json` with `"transport": "http"` and no `url` key now fails to
1516 /// deserialize at all, rather than producing an incomplete `ServerConfig` that only
1517 /// `validate_server_config` would have caught downstream (see also
1518 /// `server_config::tests::test_deserialize_http_config_missing_url_is_rejected`).
1519 #[test]
1520 fn test_validate_server_config_http_missing_url_rejected() {
1521 let result: std::result::Result<ServerConfig, _> =
1522 serde_json::from_str(r#"{"transport": "http"}"#);
1523 assert!(result.is_err());
1524 }
1525
1526 #[test]
1527 fn test_validate_server_config_sse_missing_url_rejected() {
1528 let result: std::result::Result<ServerConfig, _> =
1529 serde_json::from_str(r#"{"transport": "sse"}"#);
1530 assert!(result.is_err());
1531 }
1532
1533 #[test]
1534 fn test_validate_server_config_http_rejects_non_http_scheme() {
1535 for url in [
1536 "file:///etc/passwd",
1537 "unix:///tmp/socket",
1538 "ftp://host/path",
1539 ] {
1540 let result = ServerConfig::builder()
1541 .http_transport(url.to_string())
1542 .build();
1543 assert!(result.is_err(), "should reject scheme: {url}");
1544 if let Err(Error::SecurityViolation { reason }) = result {
1545 assert!(reason.contains("http://") || reason.contains("https://"));
1546 } else {
1547 panic!("expected SecurityViolation for url: {url}");
1548 }
1549 }
1550 }
1551
1552 #[test]
1553 fn test_validate_server_config_http_accepts_case_insensitive_scheme() {
1554 for url in ["HTTP://api.example.com/mcp", "HTTPS://api.example.com/mcp"] {
1555 let result = ServerConfig::builder()
1556 .http_transport(url.to_string())
1557 .build();
1558 assert!(
1559 result.is_ok(),
1560 "should accept case-insensitive scheme: {url}"
1561 );
1562 }
1563 }
1564
1565 #[test]
1566 fn test_validate_server_config_http_rejects_scheme_lookalike() {
1567 // "httpsomething" must not be accepted as a loose prefix match of "http".
1568 let result = ServerConfig::builder()
1569 .http_transport("httpsomething://api.example.com/mcp".to_string())
1570 .build();
1571 assert!(result.is_err());
1572 }
1573
1574 #[test]
1575 fn test_validate_server_config_http_rejects_duplicate_header_case_insensitive() {
1576 let result = ServerConfig::builder()
1577 .http_transport("https://api.example.com/mcp".to_string())
1578 .header("Authorization".to_string(), "Bearer one".to_string())
1579 .header("authorization".to_string(), "Bearer two".to_string())
1580 .build();
1581
1582 assert!(result.is_err());
1583 if let Err(Error::SecurityViolation { reason }) = result {
1584 assert!(reason.contains("duplicate header"));
1585 assert!(!reason.contains("Authorization"));
1586 assert!(!reason.to_ascii_lowercase().contains("authorization"));
1587 } else {
1588 panic!("expected SecurityViolation for duplicate header name");
1589 }
1590 }
1591
1592 #[test]
1593 fn test_validate_server_config_http_rejects_duplicate_header_secret_shaped_name() {
1594 // A misparsed `Name: Value`-style CLI argument can leave a "key" that
1595 // is entirely RFC 7230 token-charset (alphanumerics plus
1596 // `!#$%&'*+-.^_`|~`), e.g. a hex-encoded key or JWT-like value using
1597 // only `A-Za-z0-9-_.`. Such a name passes `validate_header_name_string`
1598 // and must not be echoed if it collides case-insensitively.
1599 let secret_name = "eyJhbGciOiJIUzI1NiJ9.super-secret-token-material";
1600 let result = ServerConfig::builder()
1601 .http_transport("https://api.example.com/mcp".to_string())
1602 .header(secret_name.to_string(), "value one".to_string())
1603 .header(secret_name.to_ascii_uppercase(), "value two".to_string())
1604 .build();
1605
1606 assert!(result.is_err());
1607 if let Err(Error::SecurityViolation { reason }) = result {
1608 assert!(reason.contains("duplicate header"));
1609 assert!(!reason.contains(secret_name));
1610 assert!(
1611 !reason
1612 .to_ascii_lowercase()
1613 .contains(&secret_name.to_ascii_lowercase())
1614 );
1615 } else {
1616 panic!("expected SecurityViolation for duplicate header name");
1617 }
1618 }
1619
1620 #[test]
1621 fn test_validate_server_config_http_rejects_header_name_with_invalid_tchar() {
1622 // Space, ':', and '@' are not control characters but are still
1623 // invalid HTTP header-name characters (outside RFC 7230's `token`).
1624 for bad_name in ["X Bad Header", "X:Bad", "X@Bad"] {
1625 let result = ServerConfig::builder()
1626 .http_transport("https://api.example.com/mcp".to_string())
1627 .header(bad_name.to_string(), "value".to_string())
1628 .build();
1629
1630 assert!(result.is_err(), "should reject header name: {bad_name}");
1631 if let Err(Error::SecurityViolation { reason }) = result {
1632 assert!(reason.contains("header name"));
1633 assert!(!reason.contains(bad_name));
1634 } else {
1635 panic!("expected SecurityViolation for header name: {bad_name}");
1636 }
1637 }
1638 }
1639
1640 #[test]
1641 fn test_validate_server_config_http_rejects_secret_shaped_header_name_without_leaking_it() {
1642 // Reproduces the #215 leak vector: a `Name=Value` CLI argument
1643 // mis-split on the wrong `=` leaves a base64-encoded secret in the
1644 // "name" position. It only needs one non-tchar byte (here `/`) to
1645 // reach `validate_header_name_string`'s tchar-violation branch,
1646 // which must not echo it back.
1647 let secret_name = "aGVsbG8/d29ybGQK=supersecretpayload";
1648 let result = ServerConfig::builder()
1649 .http_transport("https://api.example.com/mcp".to_string())
1650 .header(secret_name.to_string(), "value".to_string())
1651 .build();
1652
1653 assert!(result.is_err());
1654 if let Err(Error::SecurityViolation { reason }) = result {
1655 assert!(reason.contains("header name"));
1656 assert!(!reason.contains(secret_name));
1657 assert!(!reason.contains("aGVsbG8"));
1658 } else {
1659 panic!("expected SecurityViolation for header name");
1660 }
1661 }
1662
1663 #[test]
1664 fn test_validate_server_config_http_rejects_control_char_in_header_name() {
1665 let result = ServerConfig::builder()
1666 .http_transport("https://api.example.com/mcp".to_string())
1667 .header("X-Bad\r\nHeader".to_string(), "value".to_string())
1668 .build();
1669
1670 assert!(result.is_err());
1671 if let Err(Error::SecurityViolation { reason }) = result {
1672 assert!(reason.contains("header name"));
1673 assert!(!reason.contains("X-Bad"));
1674 } else {
1675 panic!("expected SecurityViolation for header name");
1676 }
1677 }
1678
1679 #[test]
1680 fn test_validate_server_config_http_rejects_control_char_in_header_value() {
1681 let result = ServerConfig::builder()
1682 .http_transport("https://api.example.com/mcp".to_string())
1683 .header(
1684 "Authorization".to_string(),
1685 "Bearer sekrit\r\nX-Injected: evil".to_string(),
1686 )
1687 .build();
1688
1689 assert!(result.is_err());
1690 if let Err(Error::SecurityViolation { reason }) = result {
1691 assert!(reason.contains("header value"));
1692 // Neither the header value nor its (ordinary, non-secret-shaped
1693 // here) name need to appear — the name is withheld unconditionally
1694 // since this path cannot distinguish an ordinary name from a
1695 // secret-shaped one.
1696 assert!(!reason.contains("Authorization"));
1697 assert!(!reason.contains("sekrit"));
1698 assert!(!reason.contains("X-Injected"));
1699 } else {
1700 panic!("expected SecurityViolation for header value");
1701 }
1702 }
1703
1704 #[test]
1705 fn test_validate_server_config_http_rejects_control_char_in_value_with_secret_shaped_name() {
1706 // Reproduces the critic's S5 repro: a JWT-shaped header *name* (fully
1707 // RFC 7230 token-charset, so it clears `validate_header_name_string`)
1708 // paired with a control character in the *value*. Both the name and
1709 // the control-char-bearing value must be absent from the error.
1710 let secret_name = "eyJhbGciOiJIUzI1NiJ9.abc-secret_material";
1711 let result = ServerConfig::builder()
1712 .http_transport("https://api.example.com/mcp".to_string())
1713 .header(secret_name.to_string(), "x\ry".to_string())
1714 .build();
1715
1716 assert!(result.is_err());
1717 if let Err(Error::SecurityViolation { reason }) = result {
1718 assert!(reason.contains("header value"));
1719 assert!(!reason.contains(secret_name));
1720 assert!(!reason.contains("eyJhbGciOiJIUzI1NiJ9"));
1721 } else {
1722 panic!("expected SecurityViolation for header value");
1723 }
1724 }
1725
1726 // ── Resource-exhaustion bounds (issue #198) ──────────────────────────────
1727
1728 #[test]
1729 fn test_validate_server_config_rejects_too_many_args() {
1730 let args = (0..=MAX_ARG_COUNT).map(|i| format!("a{i}")).collect();
1731 let result = ServerConfig::builder()
1732 .command("docker".to_string())
1733 .args(args)
1734 .build();
1735 assert!(result.is_err());
1736 if let Err(Error::SecurityViolation { reason }) = result {
1737 assert!(reason.contains("too many arguments"));
1738 } else {
1739 panic!("expected SecurityViolation for too many arguments");
1740 }
1741 }
1742
1743 #[test]
1744 fn test_validate_server_config_accepts_max_arg_count() {
1745 let args = (0..MAX_ARG_COUNT).map(|i| format!("a{i}")).collect();
1746 let result = ServerConfig::builder()
1747 .command("docker".to_string())
1748 .args(args)
1749 .build();
1750 assert!(result.is_ok());
1751 }
1752
1753 #[test]
1754 fn test_validate_server_config_rejects_oversized_arg() {
1755 let long_arg = "a".repeat(MAX_ARG_LEN + 1);
1756 let result = ServerConfig::builder()
1757 .command("docker".to_string())
1758 .arg(long_arg)
1759 .build();
1760 assert!(result.is_err());
1761 if let Err(Error::SecurityViolation { reason }) = result {
1762 assert!(reason.contains("too long"));
1763 } else {
1764 panic!("expected SecurityViolation for oversized argument");
1765 }
1766 }
1767
1768 #[test]
1769 fn test_validate_server_config_accepts_arg_at_max_len() {
1770 let arg_at_cap = "a".repeat(MAX_ARG_LEN);
1771 let result = ServerConfig::builder()
1772 .command("docker".to_string())
1773 .arg(arg_at_cap)
1774 .build();
1775 assert!(result.is_ok());
1776 }
1777
1778 #[test]
1779 fn test_validate_server_config_rejects_oversized_command() {
1780 let long_command = "a".repeat(MAX_ARG_LEN + 1);
1781 let result = ServerConfig::builder().command(long_command).build();
1782 assert!(result.is_err());
1783 if let Err(Error::SecurityViolation { reason }) = result {
1784 assert!(reason.contains("too long"));
1785 } else {
1786 panic!("expected SecurityViolation for oversized command");
1787 }
1788 }
1789
1790 #[test]
1791 fn test_validate_server_config_rejects_too_many_env_vars() {
1792 let env: HashMap<String, String> = (0..=MAX_ENV_COUNT)
1793 .map(|i| (format!("VAR_{i}"), "value".to_string()))
1794 .collect();
1795 let result = ServerConfig::builder()
1796 .command("docker".to_string())
1797 .environment(env)
1798 .build();
1799 assert!(result.is_err());
1800 if let Err(Error::SecurityViolation { reason }) = result {
1801 assert!(reason.contains("too many environment variables"));
1802 } else {
1803 panic!("expected SecurityViolation for too many env vars");
1804 }
1805 }
1806
1807 #[test]
1808 fn test_validate_server_config_accepts_max_env_count() {
1809 let env: HashMap<String, String> = (0..MAX_ENV_COUNT)
1810 .map(|i| (format!("VAR_{i}"), "value".to_string()))
1811 .collect();
1812 let result = ServerConfig::builder()
1813 .command("docker".to_string())
1814 .environment(env)
1815 .build();
1816 assert!(result.is_ok());
1817 }
1818
1819 #[test]
1820 fn test_validate_server_config_rejects_oversized_env_value() {
1821 let long_value = "v".repeat(MAX_ENV_VALUE_LEN + 1);
1822 let result = ServerConfig::builder()
1823 .command("docker".to_string())
1824 .env("MY_VAR".to_string(), long_value)
1825 .build();
1826 assert!(result.is_err());
1827 if let Err(Error::SecurityViolation { reason }) = result {
1828 assert!(reason.contains("too long"));
1829 } else {
1830 panic!("expected SecurityViolation for oversized env value");
1831 }
1832 }
1833
1834 #[test]
1835 fn test_validate_server_config_accepts_env_value_at_max_len() {
1836 let value_at_cap = "v".repeat(MAX_ENV_VALUE_LEN);
1837 let result = ServerConfig::builder()
1838 .command("docker".to_string())
1839 .env("MY_VAR".to_string(), value_at_cap)
1840 .build();
1841 assert!(result.is_ok());
1842 }
1843
1844 #[test]
1845 fn test_validate_server_config_rejects_oversized_env_name() {
1846 let long_name = "V".repeat(MAX_ARG_LEN + 1);
1847 let result = ServerConfig::builder()
1848 .command("docker".to_string())
1849 .env(long_name, "value".to_string())
1850 .build();
1851 assert!(result.is_err());
1852 if let Err(Error::SecurityViolation { reason }) = result {
1853 assert!(reason.contains("too long"));
1854 } else {
1855 panic!("expected SecurityViolation for oversized env name");
1856 }
1857 }
1858
1859 #[test]
1860 fn test_validate_server_config_rejects_too_many_headers() {
1861 let headers: HashMap<String, String> = (0..=MAX_HEADER_COUNT)
1862 .map(|i| (format!("X-Header-{i}"), "value".to_string()))
1863 .collect();
1864 let result = ServerConfig::builder()
1865 .http_transport("https://api.example.com/mcp".to_string())
1866 .headers(headers)
1867 .build();
1868 assert!(result.is_err());
1869 if let Err(Error::SecurityViolation { reason }) = result {
1870 assert!(reason.contains("too many headers"));
1871 } else {
1872 panic!("expected SecurityViolation for too many headers");
1873 }
1874 }
1875
1876 #[test]
1877 fn test_validate_server_config_accepts_max_header_count() {
1878 let headers: HashMap<String, String> = (0..MAX_HEADER_COUNT)
1879 .map(|i| (format!("X-Header-{i}"), "value".to_string()))
1880 .collect();
1881 let result = ServerConfig::builder()
1882 .http_transport("https://api.example.com/mcp".to_string())
1883 .headers(headers)
1884 .build();
1885 assert!(result.is_ok());
1886 }
1887
1888 #[test]
1889 fn test_validate_server_config_rejects_oversized_header_value() {
1890 let long_value = "v".repeat(MAX_HEADER_VALUE_LEN + 1);
1891 let result = ServerConfig::builder()
1892 .http_transport("https://api.example.com/mcp".to_string())
1893 .header("Authorization".to_string(), long_value)
1894 .build();
1895 assert!(result.is_err());
1896 if let Err(Error::SecurityViolation { reason }) = result {
1897 assert!(reason.contains("too long"));
1898 } else {
1899 panic!("expected SecurityViolation for oversized header value");
1900 }
1901 }
1902
1903 #[test]
1904 fn test_validate_server_config_accepts_header_value_at_max_len() {
1905 let value_at_cap = "v".repeat(MAX_HEADER_VALUE_LEN);
1906 let result = ServerConfig::builder()
1907 .http_transport("https://api.example.com/mcp".to_string())
1908 .header("Authorization".to_string(), value_at_cap)
1909 .build();
1910 assert!(result.is_ok());
1911 }
1912
1913 // ── #313: cross-transport fields are now unrepresentable ────────────────────
1914 //
1915 // The S2/N1 bypass this section used to guard against (a hand-edited `mcp.json`
1916 // populating `args`/`env`/`headers`/`url`/`command` for the "wrong" transport, since every
1917 // field used to exist unconditionally at the type level) is closed by construction as of
1918 // #313: `Transport::Http`/`Transport::Sse` have no `command`/`args`/`env`/`cwd` fields, and
1919 // `Transport::Stdio` has no `url`/`headers` fields. A JSON key that doesn't belong to the
1920 // deserialized variant has no field to populate, so `serde` simply ignores it — the same as
1921 // any other unrecognized key — rather than it being a bypass.
1922
1923 #[test]
1924 fn test_deserialize_ignores_cross_transport_command_field() {
1925 let json = serde_json::json!({
1926 "transport": "http",
1927 "url": "https://api.example.com/mcp",
1928 "command": "a".repeat(MAX_ARG_LEN + 1),
1929 });
1930 let config: ServerConfig = serde_json::from_value(json).expect("valid ServerConfig JSON");
1931
1932 // An Http config has no `command` field to populate, so the oversized value was never
1933 // stored anywhere and is not a resource-exhaustion vector.
1934 assert!(config.command().is_none());
1935 assert!(validate_server_config(&config).is_ok());
1936 }
1937
1938 #[test]
1939 fn test_deserialize_ignores_cross_transport_headers_field() {
1940 let headers: HashMap<String, String> = (0..=MAX_HEADER_COUNT)
1941 .map(|i| (format!("X-Header-{i}"), "value".to_string()))
1942 .collect();
1943 let json = serde_json::json!({
1944 "transport": "stdio",
1945 "command": "docker",
1946 "headers": headers,
1947 });
1948 let config: ServerConfig = serde_json::from_value(json).expect("valid ServerConfig JSON");
1949
1950 assert!(config.headers().is_empty());
1951 assert!(validate_server_config(&config).is_ok());
1952 }
1953
1954 #[test]
1955 fn test_validate_server_config_http_rejects_url_too_long() {
1956 let long_url = format!("https://example.com/{}", "a".repeat(MAX_URL_LEN));
1957 let result = ServerConfig::builder().http_transport(long_url).build();
1958
1959 assert!(result.is_err());
1960 if let Err(Error::SecurityViolation { reason }) = result {
1961 assert!(reason.contains("url too long"));
1962 } else {
1963 panic!("expected SecurityViolation for oversized url");
1964 }
1965 }
1966
1967 #[test]
1968 fn test_validate_server_config_http_accepts_url_at_max_len() {
1969 let prefix = "https://example.com/";
1970 let padding_len = MAX_URL_LEN - prefix.len();
1971 let url_at_cap = format!("{prefix}{}", "a".repeat(padding_len));
1972 assert_eq!(url_at_cap.len(), MAX_URL_LEN);
1973
1974 let result = ServerConfig::builder().http_transport(url_at_cap).build();
1975 assert!(result.is_ok());
1976 }
1977
1978 #[test]
1979 fn test_validate_server_config_http_rejects_header_name_too_long() {
1980 let long_name = format!("X-{}", "a".repeat(MAX_ARG_LEN));
1981 let result = ServerConfig::builder()
1982 .http_transport("https://api.example.com/mcp".to_string())
1983 .header(long_name, "value".to_string())
1984 .build();
1985
1986 assert!(result.is_err());
1987 if let Err(Error::SecurityViolation { reason }) = result {
1988 assert!(reason.contains("header name too long"));
1989 } else {
1990 panic!("expected SecurityViolation for oversized header name");
1991 }
1992 }
1993
1994 #[test]
1995 fn test_validate_server_config_http_accepts_header_name_at_max_len() {
1996 let name_at_cap = "a".repeat(MAX_ARG_LEN);
1997 let result = ServerConfig::builder()
1998 .http_transport("https://api.example.com/mcp".to_string())
1999 .header(name_at_cap, "value".to_string())
2000 .build();
2001
2002 assert!(result.is_ok());
2003 }
2004
2005 #[test]
2006 fn test_validate_server_config_http_timeout_bounds_still_enforced() {
2007 let result = ServerConfig::builder()
2008 .http_transport("https://api.example.com/mcp".to_string())
2009 .connect_timeout(std::time::Duration::ZERO)
2010 .build();
2011
2012 assert!(result.is_err());
2013 if let Err(Error::ValidationError { field, .. }) = result {
2014 assert_eq!(field, "connect_timeout");
2015 } else {
2016 panic!("expected ValidationError for connect_timeout");
2017 }
2018 }
2019}