diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index ab541ff31..e07aaa7e5 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -223,6 +223,36 @@ impl StreamableHttpServerConfig { self.allowed_hosts.clear(); self } + + /// Check whether a request's host passes this configuration's Host validation. + /// + /// Middleware can call this before authentication. It reuses + /// [`StreamableHttpService`]'s Host parsing and allowlist check, giving the + /// same Host decision without checking `Origin`. The `Host` header takes + /// precedence over URI authority (the HTTP/2 `:authority` fallback). + /// + /// Hostnames are compared case-insensitively. An allowed entry with a port + /// parsed as `u16` requires the same parsed request port; no parsed port + /// (absent or unparseable) matches any port. An empty + /// [`allowed_hosts`](Self::allowed_hosts) list allows any host rmcp can parse. + /// + /// Returns `false` for a missing `Host` with no URI authority, a non-UTF-8 + /// `Host`, a host rmcp cannot parse, or an allowlist mismatch. This is not a + /// full RFC 9110 Host syntax validator. Rejections emit a warning; callers + /// should immediately reject requests when this returns `false`. + /// + /// ``` + /// use http::{HeaderMap, Uri}; + /// use rmcp::transport::streamable_http_server::StreamableHttpServerConfig; + /// + /// let config = StreamableHttpServerConfig::default(); + /// let uri: Uri = "http://localhost:8080/mcp".parse().unwrap(); + /// assert!(config.is_host_allowed(&uri, &HeaderMap::new())); + /// ``` + pub fn is_host_allowed(&self, uri: &http::Uri, headers: &HeaderMap) -> bool { + validate_host_header(uri, headers, &self.allowed_hosts).is_ok() + } + pub fn with_allowed_origins( mut self, allowed_origins: impl IntoIterator>, @@ -1196,19 +1226,28 @@ fn parse_host_header(uri: &http::Uri, headers: &HeaderMap) -> HttpResult HttpResult<()> { let host = parse_host_header(uri, headers)?; - if !host_is_allowed(&host, &config.allowed_hosts) { + if !host_is_allowed(&host, allowed_hosts) { tracing::warn!( host = ?host, "rejected request with disallowed Host header (possible DNS rebinding attempt)", ); return Err(forbidden_response("Forbidden: Host header is not allowed").into()); } + Ok(()) +} + +fn validate_dns_rebinding_headers( + uri: &http::Uri, + headers: &HeaderMap, + config: &StreamableHttpServerConfig, +) -> HttpResult<()> { + validate_host_header(uri, headers, &config.allowed_hosts)?; validate_origin_header(headers, config)?; Ok(()) } diff --git a/crates/rmcp/tests/test_custom_headers.rs b/crates/rmcp/tests/test_custom_headers.rs index 5d4419e5c..16c71e773 100644 --- a/crates/rmcp/tests/test_custom_headers.rs +++ b/crates/rmcp/tests/test_custom_headers.rs @@ -964,6 +964,175 @@ async fn test_server_validates_host_when_origin_validation_is_disabled_by_defaul assert_eq!(response.status(), http::StatusCode::OK); } +/// The public check must agree with the service before middleware authenticates. +#[tokio::test] +async fn test_public_host_check_matches_service_validation() { + use std::sync::Arc; + + use bytes::Bytes; + use http::{Request, StatusCode, header::HOST}; + use http_body_util::{BodyExt, Full}; + use rmcp::{ + ServerHandler, + model::{ServerCapabilities, ServerConfig}, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }, + }; + + #[derive(Clone)] + struct TestHandler; + + impl ServerHandler for TestHandler { + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().build()) + } + } + + let cases: &[(&[&str], &str, Option<&str>, StatusCode)] = &[ + (&["localhost"], "/", Some("localhost"), StatusCode::OK), + (&["localhost"], "/", Some("localhost:8080"), StatusCode::OK), + ( + &["localhost:8080"], + "/", + Some("localhost:8080"), + StatusCode::OK, + ), + ( + &["localhost:8080"], + "/", + Some("localhost:3000"), + StatusCode::FORBIDDEN, + ), + ( + &["localhost:8080"], + "/", + Some("localhost"), + StatusCode::FORBIDDEN, + ), + ( + &["localhost"], + "/", + Some("attacker.example"), + StatusCode::FORBIDDEN, + ), + (&[], "/", Some("attacker.example:8080"), StatusCode::OK), + (&[], "http://attacker.example:8080/", None, StatusCode::OK), + ( + &["localhost"], + "http://localhost:8080/", + None, + StatusCode::OK, + ), + ( + &["localhost:8080"], + "http://localhost:8080/", + None, + StatusCode::OK, + ), + ( + &["localhost:8080"], + "http://localhost:3000/", + None, + StatusCode::FORBIDDEN, + ), + ( + &["localhost"], + "http://attacker.example/", + None, + StatusCode::FORBIDDEN, + ), + (&["localhost"], "/", None, StatusCode::BAD_REQUEST), + (&[], "/", None, StatusCode::BAD_REQUEST), + ( + &["localhost"], + "/", + Some("invalid host"), + StatusCode::BAD_REQUEST, + ), + (&[], "/", Some("invalid host"), StatusCode::BAD_REQUEST), + ( + &["localhost"], + "http://attacker.example/", + Some("localhost"), + StatusCode::OK, + ), + ( + &["localhost"], + "http://localhost/", + Some("attacker.example"), + StatusCode::FORBIDDEN, + ), + ( + &["localhost"], + "http://localhost/", + Some("invalid host"), + StatusCode::BAD_REQUEST, + ), + ( + &[" LocalHost "], + "/", + Some("LOCALHOST:8080"), + StatusCode::OK, + ), + (&["::1"], "/", Some("[::1]:8080"), StatusCode::OK), + (&["[::1]:8080"], "/", Some("[::1]:8080"), StatusCode::OK), + ( + &["[::1]:8080"], + "/", + Some("[::1]:3000"), + StatusCode::FORBIDDEN, + ), + ]; + + for (allowed_hosts, uri, host, expected_status) in cases { + let config = + StreamableHttpServerConfig::default().with_allowed_hosts(allowed_hosts.iter().copied()); + let mut request = Request::builder() + .method("POST") + .uri(*uri) + .header("Accept", "application/json, text/event-stream") + .header("Content-Type", "application/json") + .body(Full::new(Bytes::from_static( + br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0.0"}}}"#, + ))) + .unwrap(); + if let Some(host) = host { + request.headers_mut().insert(HOST, host.parse().unwrap()); + } + + let allowed = config.is_host_allowed(request.uri(), request.headers()); + assert_eq!( + allowed, + *expected_status == StatusCode::OK, + "{allowed_hosts:?}, {uri}, {host:?}" + ); + + // This executes the service's private validation path without a listener. + let service = StreamableHttpService::new( + || Ok(TestHandler), + Arc::new(LocalSessionManager::default()), + config, + ); + let response = service.handle(request).await; + assert_eq!( + response.status(), + *expected_status, + "{allowed_hosts:?}, {uri}, {host:?}" + ); + assert_eq!(allowed, response.status() == StatusCode::OK); + if *expected_status != StatusCode::OK { + let body = response.into_body().collect().await.unwrap().to_bytes(); + let expected_body = match expected_status { + &StatusCode::FORBIDDEN => "Forbidden: Host header is not allowed", + _ if host.is_none() => "Bad Request: missing Host header", + _ => "Bad Request: Invalid Host header", + }; + assert_eq!(body.as_ref(), expected_body.as_bytes()); + } + } +} + /// Integration test: Verify server can enforce an allowed Host port when configured #[tokio::test] #[cfg(all(feature = "transport-streamable-http-server", feature = "server",))]