diff --git a/Cargo.lock b/Cargo.lock index b8b2b397..f4977621 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8283,6 +8283,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", + "url", "uuid", "zeph-llm", "zeph-tools", diff --git a/crates/zeph-mcp/Cargo.toml b/crates/zeph-mcp/Cargo.toml index 0a2ac662..590e1757 100644 --- a/crates/zeph-mcp/Cargo.toml +++ b/crates/zeph-mcp/Cargo.toml @@ -19,6 +19,7 @@ serde_json.workspace = true thiserror.workspace = true tokio = { workspace = true, features = ["process", "sync", "time", "rt"] } tracing.workspace = true +url.workspace = true uuid = { workspace = true, optional = true, features = ["v5"] } zeph-llm.workspace = true zeph-tools.workspace = true diff --git a/crates/zeph-mcp/src/client.rs b/crates/zeph-mcp/src/client.rs index 0ddd50a9..bb4e1429 100644 --- a/crates/zeph-mcp/src/client.rs +++ b/crates/zeph-mcp/src/client.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::net::{IpAddr, ToSocketAddrs}; use std::sync::Arc; use std::time::Duration; @@ -8,6 +9,7 @@ use rmcp::service::RunningService; use rmcp::transport::TokioChildProcess; use rmcp::transport::streamable_http_client::StreamableHttpClientTransport; use tokio::process::Command; +use url::Url; use crate::error::McpError; use crate::tool::McpTool; @@ -70,14 +72,21 @@ impl McpClient { /// Connect to a remote MCP server over Streamable HTTP. /// + /// Performs SSRF validation before connecting — blocks URLs that resolve + /// to private, loopback, or link-local IP ranges. + /// /// # Errors /// - /// Returns `McpError::Connection` if the HTTP connection or handshake fails. + /// Returns `McpError::SsrfBlocked` if the URL resolves to a private IP, + /// `McpError::InvalidUrl` if the URL cannot be parsed, or + /// `McpError::Connection` if the HTTP connection or handshake fails. pub async fn connect_url( server_id: &str, url: &str, timeout: Duration, ) -> Result { + validate_url_ssrf(url)?; + let transport = StreamableHttpClientTransport::from_uri(url.to_owned()); let service = @@ -174,3 +183,112 @@ impl McpClient { } } } + +fn is_private_ip(addr: IpAddr) -> bool { + match addr { + IpAddr::V4(ip) => { + ip.is_loopback() // 127.0.0.0/8 + || ip.is_private() // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 + || ip.is_link_local() // 169.254.0.0/16 + || ip.is_unspecified() // 0.0.0.0 + || ip.is_broadcast() // 255.255.255.255 + } + IpAddr::V6(ip) => ip.is_loopback() || ip.is_unspecified(), + } +} + +fn validate_url_ssrf(url: &str) -> Result<(), McpError> { + let parsed = Url::parse(url).map_err(|e| McpError::InvalidUrl { + url: url.into(), + message: e.to_string(), + })?; + + let host = parsed.host_str().ok_or_else(|| McpError::InvalidUrl { + url: url.into(), + message: "missing host".into(), + })?; + + let port = parsed.port_or_known_default().unwrap_or(443); + let addr_str = format!("{host}:{port}"); + + // DNS resolution to catch hostnames pointing to private IPs + let addrs = addr_str + .to_socket_addrs() + .map_err(|e| McpError::InvalidUrl { + url: url.into(), + message: format!("DNS resolution failed: {e}"), + })?; + + for sock_addr in addrs { + if is_private_ip(sock_addr.ip()) { + return Err(McpError::SsrfBlocked { + url: url.into(), + addr: sock_addr.ip().to_string(), + }); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ssrf_blocks_localhost() { + let err = validate_url_ssrf("http://127.0.0.1:8080/mcp").unwrap_err(); + assert!(matches!(err, McpError::SsrfBlocked { .. })); + } + + #[test] + fn ssrf_blocks_private_10() { + let err = validate_url_ssrf("http://10.0.0.1/mcp").unwrap_err(); + assert!(matches!(err, McpError::SsrfBlocked { .. })); + } + + #[test] + fn ssrf_blocks_private_172() { + let err = validate_url_ssrf("http://172.16.0.1/mcp").unwrap_err(); + assert!(matches!(err, McpError::SsrfBlocked { .. })); + } + + #[test] + fn ssrf_blocks_private_192() { + let err = validate_url_ssrf("http://192.168.1.1/mcp").unwrap_err(); + assert!(matches!(err, McpError::SsrfBlocked { .. })); + } + + #[test] + fn ssrf_blocks_link_local() { + let err = validate_url_ssrf("http://169.254.1.1/mcp").unwrap_err(); + assert!(matches!(err, McpError::SsrfBlocked { .. })); + } + + #[test] + fn ssrf_blocks_zero() { + let err = validate_url_ssrf("http://0.0.0.0/mcp").unwrap_err(); + assert!(matches!(err, McpError::SsrfBlocked { .. })); + } + + #[test] + fn ssrf_blocks_ipv6_loopback() { + let err = validate_url_ssrf("http://[::1]:8080/mcp").unwrap_err(); + assert!(matches!(err, McpError::SsrfBlocked { .. })); + } + + #[test] + fn ssrf_rejects_invalid_url() { + let err = validate_url_ssrf("not-a-url").unwrap_err(); + assert!(matches!(err, McpError::InvalidUrl { .. })); + } + + #[test] + fn ssrf_error_display() { + let err = McpError::SsrfBlocked { + url: "http://127.0.0.1/mcp".into(), + addr: "127.0.0.1".into(), + }; + assert!(err.to_string().contains("SSRF blocked")); + } +} diff --git a/crates/zeph-mcp/src/error.rs b/crates/zeph-mcp/src/error.rs index 6af5b082..4970b8b5 100644 --- a/crates/zeph-mcp/src/error.rs +++ b/crates/zeph-mcp/src/error.rs @@ -39,6 +39,12 @@ pub enum McpError { #[error("integer conversion: {0}")] IntConversion(#[from] std::num::TryFromIntError), + #[error("SSRF blocked: URL '{url}' resolves to private/reserved IP {addr}")] + SsrfBlocked { url: String, addr: String }, + + #[error("invalid URL '{url}': {message}")] + InvalidUrl { url: String, message: String }, + #[error("embedding error: {0}")] Embedding(String), } diff --git a/crates/zeph-mcp/src/manager.rs b/crates/zeph-mcp/src/manager.rs index 5fce0946..7b1c7482 100644 --- a/crates/zeph-mcp/src/manager.rs +++ b/crates/zeph-mcp/src/manager.rs @@ -395,7 +395,10 @@ mod tests { let mgr = McpManager::new(vec![]); let entry = make_http_entry("http-test"); let err = mgr.add_server(&entry).await.unwrap_err(); - assert!(matches!(err, McpError::Connection { .. })); + assert!(matches!( + err, + McpError::SsrfBlocked { .. } | McpError::Connection { .. } + )); } #[test] diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index a89a97c7..ed7ce579 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -36,6 +36,7 @@ --- - [Security](security.md) + - [MCP Security](security/mcp.md) - [Feature Flags](feature-flags.md) - [Contributing](contributing.md) - [Changelog](changelog.md) diff --git a/docs/src/security/mcp.md b/docs/src/security/mcp.md new file mode 100644 index 00000000..7bfc3cc8 --- /dev/null +++ b/docs/src/security/mcp.md @@ -0,0 +1,78 @@ +# MCP Security + +## Overview + +The Model Context Protocol (MCP) allows Zeph to connect to external tool servers via child processes or HTTP endpoints. Because MCP servers can execute arbitrary commands and access network resources, proper configuration is critical. + +## SSRF Protection + +Zeph blocks URL-based MCP connections (`url` transport) that resolve to private or reserved IP ranges: + +| Range | Description | +|-------|-------------| +| `127.0.0.0/8` | Loopback | +| `10.0.0.0/8` | Private (Class A) | +| `172.16.0.0/12` | Private (Class B) | +| `192.168.0.0/16` | Private (Class C) | +| `169.254.0.0/16` | Link-local | +| `0.0.0.0` | Unspecified | +| `::1` | IPv6 loopback | + +DNS resolution is performed before connecting, so hostnames pointing to private IPs (DNS rebinding) are also blocked. + +## Safe Server Configuration + +### Command-Based Servers + +When configuring `command` transport servers, restrict the allowed executables: + +```toml +[[mcp.servers]] +id = "filesystem" +command = "npx" +args = ["-y", "@modelcontextprotocol/server-filesystem", "/allowed/path"] +``` + +**Recommendations:** + +- Only allow known, trusted executables +- Use absolute paths for commands when possible +- Restrict filesystem server paths to specific directories +- Avoid passing user-controlled input directly as command arguments +- Review server source code before adding to configuration + +### URL-Based Servers + +```toml +[[mcp.servers]] +id = "remote-tools" +url = "https://trusted-server.example.com/mcp" +``` + +**Recommendations:** + +- Only connect to servers you control or explicitly trust +- Always use HTTPS — never plain HTTP in production +- Verify the server's TLS certificate chain +- Monitor server logs for unexpected tool invocations + +## Command Allowlists + +For production deployments, consider restricting which MCP tools can be invoked. While Zeph does not yet enforce tool-level allowlists, you can limit exposure by: + +1. Running only the MCP servers you need +2. Configuring each server with minimal permissions +3. Using filesystem servers with read-only access where possible +4. Auditing tool calls via Zeph's tracing output (`RUST_LOG=zeph_mcp=debug`) + +## Environment Variables + +MCP servers inherit environment variables from their configuration. Never store secrets directly in `config.toml` — use the [Vault](../guide/vault.md) integration instead: + +```toml +[[mcp.servers]] +id = "github" +command = "npx" +args = ["-y", "@modelcontextprotocol/server-github"] +env = { GITHUB_TOKEN = "vault:github_token" } +```