diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15788bd..4985a4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,8 @@ jobs: - uses: Swatinem/rust-cache@v2 with: workspaces: mcp-server-rs - - run: cargo clippy --manifest-path mcp-server-rs/Cargo.toml -- -D warnings + # --all-targets so test code is linted too, not just the binary. + - run: cargo clippy --manifest-path mcp-server-rs/Cargo.toml --all-targets -- -D warnings build: name: Build & Test diff --git a/README.md b/README.md index 729a489..b3d7fbe 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,20 @@ Flow services are created via `wm.server.ns/putNode` which accepts the full flow - **stdio** (default): Standard MCP stdio transport, works with all MCP clients - **HTTP**: `wm-mcp-server --http 8080` starts a Streamable HTTP server at `/mcp` for MCP gateways +### Allowed hosts (HTTP mode) + +To block DNS rebinding attacks, the HTTP transport validates the inbound `Host` +header and accepts **loopback only** (`localhost`, `127.0.0.1`, `::1`) by +default. Requests carrying any other `Host` get `403`. + +If the server is reached under a real hostname, list it in `WM_ALLOWED_HOSTS` +(comma-separated). Entries are *added* to the loopback defaults: + +```bash +WM_ALLOWED_HOSTS=mcp.example.com,mcp.example.com:8080 +WM_ALLOWED_HOSTS='*' # disables Host validation entirely -- not recommended +``` + ### Tool scoping Set `WM_SCOPES` to restrict which tools are exposed (useful for MCP gateways): diff --git a/mcp-server-rs/Cargo.lock b/mcp-server-rs/Cargo.lock index 09f3951..6f94a90 100644 --- a/mcp-server-rs/Cargo.lock +++ b/mcp-server-rs/Cargo.lock @@ -28,9 +28,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arbitrary" @@ -170,7 +170,7 @@ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", "cpufeatures", - "rand_core 0.10.0", + "rand_core", ] [[package]] @@ -180,10 +180,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", - "js-sys", "num-traits", "serde", - "wasm-bindgen", "windows-link", ] @@ -292,7 +290,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -427,20 +425,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", -] - [[package]] name = "getrandom" version = "0.4.2" @@ -448,11 +432,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", - "r-efi 6.0.0", - "rand_core 0.10.0", + "r-efi", + "rand_core", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -906,15 +892,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "prettyplease" version = "0.2.37" @@ -956,14 +933,15 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "bytes", - "getrandom 0.3.4", + "getrandom 0.4.2", "lru-slab", - "rand 0.9.2", + "rand", + "rand_pcg", "ring", "rustc-hash", "rustls", @@ -998,12 +976,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" @@ -1012,50 +984,30 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.2", - "rand_core 0.10.0", + "rand_core", ] [[package]] -name = "rand_chacha" -version = "0.9.0" +name = "rand_core" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" [[package]] -name = "rand_core" -version = "0.9.5" +name = "rand_pcg" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "getrandom 0.3.4", + "rand_core", ] -[[package]] -name = "rand_core" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" - [[package]] name = "ref-cast" version = "1.0.25" @@ -1147,9 +1099,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "1.2.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba6b9d2f0efe2258b23767f1f9e0054cfbcac9c2d6f81a031214143096d7864f" +checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59" dependencies = [ "async-trait", "base64", @@ -1161,7 +1113,7 @@ dependencies = [ "http-body-util", "pastey", "pin-project-lite", - "rand 0.10.0", + "rand", "rmcp-macros", "schemars", "serde", @@ -1178,9 +1130,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "1.2.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab9d95d7ed26ad8306352b0d5f05b593222b272790564589790d210aa15caa9e" +checksum = "1aad0035b69380782d78ea95b508327e6deaa2235909053e596eea8f27b5e1d5" dependencies = [ "darling", "proc-macro2", @@ -2262,26 +2214,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zerofrom" version = "0.1.6" diff --git a/mcp-server-rs/Cargo.toml b/mcp-server-rs/Cargo.toml index 5b83075..a1822f5 100644 --- a/mcp-server-rs/Cargo.toml +++ b/mcp-server-rs/Cargo.toml @@ -13,7 +13,9 @@ name = "wm-mcp-server" path = "src/main.rs" [dependencies] -rmcp = { version = "1.2", features = ["server", "transport-io", "transport-streamable-http-server", "macros"] } +# >=1.4 required: patches RUSTSEC-2026-0189 (DNS rebinding in the Streamable +# HTTP server transport) and provides the Host-allowlist builder API. +rmcp = { version = "1.8", features = ["server", "transport-io", "transport-streamable-http-server", "macros"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std", "net", "signal"] } axum = "0.8" tokio-util = { version = "0.7", features = ["rt"] } diff --git a/mcp-server-rs/src/config.rs b/mcp-server-rs/src/config.rs index 9695d45..801de61 100644 --- a/mcp-server-rs/src/config.rs +++ b/mcp-server-rs/src/config.rs @@ -41,6 +41,11 @@ pub struct AppConfig { /// Tool scopes to expose. Empty = all tools. Set via WM_SCOPES env var (comma-separated). /// Valid scopes: admin, develop, adapters, messaging, monitor, deploy, network, readonly pub scopes: Vec, + /// Extra `Host` authorities accepted in HTTP mode, on top of the loopback + /// defaults. Set via WM_ALLOWED_HOSTS (comma-separated), e.g. + /// "mcp.example.com,mcp.example.com:8080". Empty = loopback only. + /// A single `*` disables Host validation entirely. + pub allowed_hosts: Vec, } impl AppConfig { @@ -79,18 +84,22 @@ impl AppConfig { instances: file.instances, default_instance, scopes: Self::load_scopes(), + allowed_hosts: Self::load_allowed_hosts(), }) } fn load_scopes() -> Vec { - std::env::var("WM_SCOPES") + Self::load_csv_env("WM_SCOPES") + } + + fn load_allowed_hosts() -> Vec { + Self::load_csv_env("WM_ALLOWED_HOSTS") + } + + fn load_csv_env(var: &str) -> Vec { + std::env::var(var) .ok() - .map(|s| { - s.split(',') - .map(|s| s.trim().to_lowercase()) - .filter(|s| !s.is_empty()) - .collect() - }) + .map(|s| parse_csv(&s)) .unwrap_or_default() } @@ -113,6 +122,104 @@ impl AppConfig { instances, default_instance: name, scopes: Self::load_scopes(), + allowed_hosts: Self::load_allowed_hosts(), + } + } +} + +/// Split a comma-separated env var into trimmed, lowercased, non-empty entries. +pub fn parse_csv(raw: &str) -> Vec { + raw.split(',') + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()) + .collect() +} + +/// Merge operator-supplied `Host` authorities into the transport's loopback +/// defaults, preserving order and dropping duplicates. +/// +/// Returns `None` when the operator asked for validation to be switched off +/// entirely (a single `*` entry), which reopens the DNS-rebinding hole patched +/// by RUSTSEC-2026-0189 and is therefore logged as a warning by the caller. +/// +/// Extra hosts are *added* to the defaults rather than replacing them, so +/// loopback health checks keep working once a public hostname is configured. +pub fn resolve_allowed_hosts(defaults: &[String], configured: &[String]) -> Option> { + if configured.iter().any(|h| h == "*") { + return None; + } + let mut hosts: Vec = Vec::with_capacity(defaults.len() + configured.len()); + for host in defaults.iter().chain(configured) { + if !hosts.iter().any(|seen| seen == host) { + hosts.push(host.clone()); + } + } + Some(hosts) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn defaults() -> Vec { + ["localhost", "127.0.0.1", "::1"] + .iter() + .map(|s| s.to_string()) + .collect() + } + + #[test] + fn parse_csv_trims_lowercases_and_drops_empties() { + assert_eq!( + parse_csv(" Develop , MONITOR ,, deploy,"), + vec!["develop", "monitor", "deploy"] + ); + } + + #[test] + fn parse_csv_of_blank_input_is_empty() { + assert!(parse_csv("").is_empty()); + assert!(parse_csv(" , , ").is_empty()); + } + + #[test] + fn no_configured_hosts_keeps_loopback_defaults() { + assert_eq!(resolve_allowed_hosts(&defaults(), &[]), Some(defaults())); + } + + #[test] + fn configured_hosts_are_added_not_substituted() { + // Regression guard: replacing the defaults would break loopback health + // checks the moment an operator sets a public hostname. + let configured = vec!["mcp.example.com".to_string()]; + let merged = resolve_allowed_hosts(&defaults(), &configured).unwrap(); + for host in defaults() { + assert!(merged.contains(&host), "lost loopback default {host}"); } + assert!(merged.contains(&"mcp.example.com".to_string())); + } + + #[test] + fn overlapping_hosts_are_deduplicated_in_order() { + let configured = vec!["localhost".to_string(), "mcp.example.com".to_string()]; + assert_eq!( + resolve_allowed_hosts(&defaults(), &configured).unwrap(), + vec!["localhost", "127.0.0.1", "::1", "mcp.example.com"] + ); + } + + #[test] + fn wildcard_disables_validation_even_alongside_other_entries() { + assert_eq!(resolve_allowed_hosts(&defaults(), &["*".to_string()]), None); + let mixed = vec!["mcp.example.com".to_string(), "*".to_string()]; + assert_eq!(resolve_allowed_hosts(&defaults(), &mixed), None); + } + + #[test] + fn wildcard_is_only_honoured_as_a_whole_entry() { + // "*.example.com" is not a supported wildcard syntax; it must not be + // mistaken for the disable-everything switch. + let hosts = vec!["*.example.com".to_string()]; + assert!(resolve_allowed_hosts(&defaults(), &hosts).is_some()); } } diff --git a/mcp-server-rs/src/main.rs b/mcp-server-rs/src/main.rs index 02544cd..d86fb27 100644 --- a/mcp-server-rs/src/main.rs +++ b/mcp-server-rs/src/main.rs @@ -47,7 +47,14 @@ async fn main() -> anyhow::Result<()> { } if let Some(port) = http_port { - run_http(clients, config.default_instance, config.scopes, port).await + run_http( + clients, + config.default_instance, + config.scopes, + config.allowed_hosts, + port, + ) + .await } else { run_stdio(clients, config.default_instance, config.scopes).await } @@ -76,6 +83,7 @@ async fn run_http( clients: HashMap>, default_instance: String, scopes: Vec, + allowed_hosts: Vec, port: u16, ) -> anyhow::Result<()> { use rmcp::transport::streamable_http_server::{ @@ -92,12 +100,37 @@ async fn run_http( let ct = CancellationToken::new(); - let config = StreamableHttpServerConfig { - stateful_mode: true, - json_response: false, - sse_keep_alive: Some(std::time::Duration::from_secs(30)), - cancellation_token: ct.child_token(), - ..Default::default() + let config = StreamableHttpServerConfig::default() + .with_stateful_mode(true) + .with_json_response(false) + .with_sse_keep_alive(Some(std::time::Duration::from_secs(30))) + .with_cancellation_token(ct.child_token()); + + // The transport validates the inbound `Host` header to block DNS rebinding + // (RUSTSEC-2026-0189) and ships a loopback-only allowlist. That default is + // right for a local stdio-style deployment but rejects gateway deployments + // reached under a real hostname, so WM_ALLOWED_HOSTS opts those in. Entries + // are added to the loopback defaults rather than replacing them, keeping + // local health checks working. + let config = match config::resolve_allowed_hosts(&config.allowed_hosts, &allowed_hosts) { + None => { + tracing::warn!( + "WM_ALLOWED_HOSTS=* -- Host validation disabled; the server is exposed to \ + DNS rebinding attacks. Prefer listing explicit hostnames." + ); + config.disable_allowed_hosts() + } + Some(hosts) => { + if allowed_hosts.is_empty() { + tracing::info!( + "Accepting loopback Hosts only ({hosts:?}); \ + set WM_ALLOWED_HOSTS to add hostnames" + ); + } else { + tracing::info!("Accepting Hosts: {hosts:?}"); + } + config.with_allowed_hosts(hosts) + } }; let service: StreamableHttpService = diff --git a/mcp-server-rs/src/scopes.rs b/mcp-server-rs/src/scopes.rs index fb16adf..8f21859 100644 --- a/mcp-server-rs/src/scopes.rs +++ b/mcp-server-rs/src/scopes.rs @@ -118,3 +118,128 @@ fn is_readonly_tool(name: &str) -> bool { || name.ends_with("_categories") || name.ends_with("_registries") } + +#[cfg(test)] +mod tests { + use super::*; + use crate::server::WmServer; + + /// Scopes an operator may put in WM_SCOPES. Keep in sync with the README + /// ("Available scopes") and the doc comment on `AppConfig::scopes`. + const DOCUMENTED_SCOPES: &[&str] = &[ + "admin", + "develop", + "deploy", + "adapters", + "messaging", + "monitor", + "network", + "readonly", + ]; + + /// Every tool the server actually registers, read from the live router so + /// tools added later are covered without touching these tests. + fn registered_tools() -> Vec { + WmServer::registered_tool_names() + } + + #[test] + fn router_exposes_tools() { + // Guards the tests below against silently passing on an empty list. + assert!(registered_tools().len() > 300); + } + + #[test] + fn every_package_tool_stays_in_the_deploy_scope() { + // Package management is what the "deploy" scope exists for. A new match + // arm listing individual package_* tools ahead of the generic one drops + // them out of "deploy" silently -- nothing fails at startup. + for tool in registered_tools() { + if let Some(rest) = tool.strip_prefix("package_") { + let scopes = tool_scope(&tool); + assert!( + scopes.contains(&"deploy"), + "{tool} lost the \"deploy\" scope (got {scopes:?}); \ + a package_* tool must stay reachable for deployment \ + (offending suffix: {rest})" + ); + } + } + } + + #[test] + fn every_registered_tool_has_at_least_one_scope() { + for tool in registered_tools() { + assert!( + !tool_scope(&tool).is_empty(), + "{tool} maps to no scope, so WM_SCOPES can never expose it" + ); + } + } + + #[test] + fn every_scope_in_use_is_documented() { + for tool in registered_tools() { + for scope in tool_scope(&tool) { + assert!( + DOCUMENTED_SCOPES.contains(scope), + "{tool} uses undocumented scope {scope:?}; add it to the \ + README scope list, the AppConfig::scopes doc comment and \ + DOCUMENTED_SCOPES" + ); + } + } + } + + #[test] + fn admin_tools_never_leak_into_the_develop_scope() { + for tool in registered_tools() { + let scopes = tool_scope(&tool); + if scopes.contains(&"admin") { + assert!( + !scopes.contains(&"develop"), + "{tool} is admin-scoped but also reachable from \"develop\"" + ); + } + } + } + + #[test] + fn unset_scopes_expose_every_tool() { + for tool in registered_tools() { + assert!(is_tool_allowed(&tool, &[]), "{tool} hidden with no filter"); + } + } + + #[test] + fn readonly_scope_hides_mutating_tools() { + let readonly = vec!["readonly".to_string()]; + for tool in [ + "put_node", + "node_delete", + "package_delete", + "user_add", + "is_shutdown", + "jdbc_pool_delete", + ] { + assert!( + !is_tool_allowed(tool, &readonly), + "{tool} mutates state but is exposed under the readonly scope" + ); + } + for tool in ["node_list", "node_get", "package_info", "server_stats"] { + assert!( + is_tool_allowed(tool, &readonly), + "{tool} only reads state but is hidden under the readonly scope" + ); + } + } + + #[test] + fn a_scope_only_matches_its_own_tools() { + assert!(is_tool_allowed("put_node", &["develop".to_string()])); + assert!(!is_tool_allowed("put_node", &["monitor".to_string()])); + assert!(is_tool_allowed("user_add", &["admin".to_string()])); + assert!(!is_tool_allowed("user_add", &["develop".to_string()])); + } +} diff --git a/mcp-server-rs/src/server.rs b/mcp-server-rs/src/server.rs index 50d0be5..5896c7e 100644 --- a/mcp-server-rs/src/server.rs +++ b/mcp-server-rs/src/server.rs @@ -68,6 +68,20 @@ impl WmServer { } } +#[cfg(test)] +impl WmServer { + /// Names of every tool the router registers. Lets the scope invariants in + /// [`crate::scopes`] run against the real tool set instead of a hand-kept + /// list; the generated `tool_router()` is private to this module. + pub(crate) fn registered_tool_names() -> Vec { + Self::tool_router() + .list_all() + .into_iter() + .map(|t| t.name.to_string()) + .collect() + } +} + #[tool_router] impl WmServer { pub fn new(clients: HashMap>, default_instance: String) -> Self {