fix(http): add host check (#764)
Signed-off-by: jokemanfire <hu.dingyang@zte.com.cn> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
parent
012210baae
commit
8e22aa2de2
3 changed files with 279 additions and 2 deletions
|
|
@ -7,6 +7,7 @@ pub const JSON_MIME_TYPE: &str = "application/json";
|
||||||
/// Reserved headers that must not be overridden by user-supplied custom headers.
|
/// Reserved headers that must not be overridden by user-supplied custom headers.
|
||||||
/// `MCP-Protocol-Version` is in this list but is allowed through because the worker
|
/// `MCP-Protocol-Version` is in this list but is allowed through because the worker
|
||||||
/// injects it after initialization.
|
/// injects it after initialization.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub(crate) const RESERVED_HEADERS: &[&str] = &[
|
pub(crate) const RESERVED_HEADERS: &[&str] = &[
|
||||||
"accept",
|
"accept",
|
||||||
HEADER_SESSION_ID,
|
HEADER_SESSION_ID,
|
||||||
|
|
@ -36,6 +37,7 @@ pub(crate) fn validate_custom_header(name: &http::HeaderName) -> Result<(), Stri
|
||||||
|
|
||||||
/// Extracts the `scope=` parameter from a `WWW-Authenticate` header value.
|
/// Extracts the `scope=` parameter from a `WWW-Authenticate` header value.
|
||||||
/// Handles both quoted (`scope="files:read files:write"`) and unquoted (`scope=read:data`) forms.
|
/// Handles both quoted (`scope="files:read files:write"`) and unquoted (`scope=read:data`) forms.
|
||||||
|
#[cfg(feature = "client-side-sse")]
|
||||||
pub(crate) fn extract_scope_from_header(header: &str) -> Option<String> {
|
pub(crate) fn extract_scope_from_header(header: &str) -> Option<String> {
|
||||||
let header_lowercase = header.to_ascii_lowercase();
|
let header_lowercase = header.to_ascii_lowercase();
|
||||||
let scope_key = "scope=";
|
let scope_key = "scope=";
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ use std::{convert::Infallible, fmt::Display, sync::Arc, time::Duration};
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use futures::{StreamExt, future::BoxFuture};
|
use futures::{StreamExt, future::BoxFuture};
|
||||||
use http::{Method, Request, Response, header::ALLOW};
|
use http::{HeaderMap, Method, Request, Response, header::ALLOW};
|
||||||
use http_body::Body;
|
use http_body::Body;
|
||||||
use http_body_util::{BodyExt, Full, combinators::BoxBody};
|
use http_body_util::{BodyExt, Full, combinators::BoxBody};
|
||||||
use tokio_stream::wrappers::ReceiverStream;
|
use tokio_stream::wrappers::ReceiverStream;
|
||||||
|
|
@ -29,8 +29,8 @@ use crate::{
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
pub struct StreamableHttpServerConfig {
|
pub struct StreamableHttpServerConfig {
|
||||||
/// The ping message duration for SSE connections.
|
/// The ping message duration for SSE connections.
|
||||||
pub sse_keep_alive: Option<Duration>,
|
pub sse_keep_alive: Option<Duration>,
|
||||||
|
|
@ -49,6 +49,16 @@ pub struct StreamableHttpServerConfig {
|
||||||
/// When this token is cancelled, all active sessions are terminated and
|
/// When this token is cancelled, all active sessions are terminated and
|
||||||
/// the server stops accepting new requests.
|
/// the server stops accepting new requests.
|
||||||
pub cancellation_token: CancellationToken,
|
pub cancellation_token: CancellationToken,
|
||||||
|
/// Allowed hostnames or `host:port` authorities for inbound `Host` validation.
|
||||||
|
///
|
||||||
|
/// By default, Streamable HTTP servers only accept loopback hosts to
|
||||||
|
/// prevent DNS rebinding attacks against locally running servers. Public
|
||||||
|
/// deployments should override this list with their own hostnames.
|
||||||
|
/// examples:
|
||||||
|
/// allowed_hosts = ["localhost", "127.0.0.1", "0.0.0.0"]
|
||||||
|
/// or with ports:
|
||||||
|
/// allowed_hosts = ["example.com", "example.com:8080"]
|
||||||
|
pub allowed_hosts: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for StreamableHttpServerConfig {
|
impl Default for StreamableHttpServerConfig {
|
||||||
|
|
@ -59,11 +69,24 @@ impl Default for StreamableHttpServerConfig {
|
||||||
stateful_mode: true,
|
stateful_mode: true,
|
||||||
json_response: false,
|
json_response: false,
|
||||||
cancellation_token: CancellationToken::new(),
|
cancellation_token: CancellationToken::new(),
|
||||||
|
allowed_hosts: vec!["localhost".into(), "127.0.0.1".into(), "::1".into()],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StreamableHttpServerConfig {
|
impl StreamableHttpServerConfig {
|
||||||
|
pub fn with_allowed_hosts(
|
||||||
|
mut self,
|
||||||
|
allowed_hosts: impl IntoIterator<Item = impl Into<String>>,
|
||||||
|
) -> Self {
|
||||||
|
self.allowed_hosts = allowed_hosts.into_iter().map(Into::into).collect();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
/// Disable allowed hosts. This will allow requests with any `Host` header, which is NOT recommended for public deployments.
|
||||||
|
pub fn disable_allowed_hosts(mut self) -> Self {
|
||||||
|
self.allowed_hosts.clear();
|
||||||
|
self
|
||||||
|
}
|
||||||
pub fn with_sse_keep_alive(mut self, duration: Option<Duration>) -> Self {
|
pub fn with_sse_keep_alive(mut self, duration: Option<Duration>) -> Self {
|
||||||
self.sse_keep_alive = duration;
|
self.sse_keep_alive = duration;
|
||||||
self
|
self
|
||||||
|
|
@ -130,6 +153,97 @@ fn validate_protocol_version_header(headers: &http::HeaderMap) -> Result<(), Box
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn forbidden_response(message: impl Into<String>) -> BoxResponse {
|
||||||
|
Response::builder()
|
||||||
|
.status(http::StatusCode::FORBIDDEN)
|
||||||
|
.body(Full::new(Bytes::from(message.into())).boxed())
|
||||||
|
.expect("valid response")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_host(host: &str) -> String {
|
||||||
|
host.trim_matches('[')
|
||||||
|
.trim_matches(']')
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct NormalizedAuthority {
|
||||||
|
host: String,
|
||||||
|
port: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_authority(host: &str, port: Option<u16>) -> NormalizedAuthority {
|
||||||
|
NormalizedAuthority {
|
||||||
|
host: normalize_host(host),
|
||||||
|
port,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_allowed_authority(allowed: &str) -> Option<NormalizedAuthority> {
|
||||||
|
let allowed = allowed.trim();
|
||||||
|
if allowed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(authority) = http::uri::Authority::try_from(allowed) {
|
||||||
|
return Some(normalize_authority(authority.host(), authority.port_u16()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(normalize_authority(allowed, None))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn host_is_allowed(host: &NormalizedAuthority, allowed_hosts: &[String]) -> bool {
|
||||||
|
if allowed_hosts.is_empty() {
|
||||||
|
// If the allowed hosts list is empty, allow all hosts (not recommended).
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
allowed_hosts
|
||||||
|
.iter()
|
||||||
|
.filter_map(|allowed| parse_allowed_authority(allowed))
|
||||||
|
.any(|allowed| {
|
||||||
|
allowed.host == host.host
|
||||||
|
&& match allowed.port {
|
||||||
|
Some(port) => host.port == Some(port),
|
||||||
|
None => true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bad_request_response(message: &str) -> BoxResponse {
|
||||||
|
let body = Full::from(message.to_string()).boxed();
|
||||||
|
|
||||||
|
http::Response::builder()
|
||||||
|
.status(http::StatusCode::BAD_REQUEST)
|
||||||
|
.header(http::header::CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||||
|
.body(body)
|
||||||
|
.expect("failed to build bad request response")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_host_header(headers: &HeaderMap) -> Result<NormalizedAuthority, BoxResponse> {
|
||||||
|
let Some(host) = headers.get(http::header::HOST) else {
|
||||||
|
return Err(bad_request_response("Bad Request: missing Host header"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let host = host
|
||||||
|
.to_str()
|
||||||
|
.map_err(|_| bad_request_response("Bad Request: Invalid Host header encoding"))?;
|
||||||
|
let authority = http::uri::Authority::try_from(host)
|
||||||
|
.map_err(|_| bad_request_response("Bad Request: Invalid Host header"))?;
|
||||||
|
Ok(normalize_authority(authority.host(), authority.port_u16()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_dns_rebinding_headers(
|
||||||
|
headers: &HeaderMap,
|
||||||
|
config: &StreamableHttpServerConfig,
|
||||||
|
) -> Result<(), BoxResponse> {
|
||||||
|
let host = parse_host_header(headers)?;
|
||||||
|
if !host_is_allowed(&host, &config.allowed_hosts) {
|
||||||
|
return Err(forbidden_response("Forbidden: Host header is not allowed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// # Streamable HTTP server
|
/// # Streamable HTTP server
|
||||||
///
|
///
|
||||||
/// An HTTP service that implements the
|
/// An HTTP service that implements the
|
||||||
|
|
@ -279,6 +393,9 @@ where
|
||||||
B: Body + Send + 'static,
|
B: Body + Send + 'static,
|
||||||
B::Error: Display,
|
B::Error: Display,
|
||||||
{
|
{
|
||||||
|
if let Err(response) = validate_dns_rebinding_headers(request.headers(), &self.config) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
let method = request.method().clone();
|
let method = request.method().clone();
|
||||||
let allowed_methods = match self.config.stateful_mode {
|
let allowed_methods = match self.config.stateful_mode {
|
||||||
true => "GET, POST, DELETE",
|
true => "GET, POST, DELETE",
|
||||||
|
|
|
||||||
|
|
@ -761,6 +761,7 @@ async fn test_server_rejects_unsupported_protocol_version() {
|
||||||
.method(Method::POST)
|
.method(Method::POST)
|
||||||
.header("Accept", "application/json, text/event-stream")
|
.header("Accept", "application/json, text/event-stream")
|
||||||
.header(CONTENT_TYPE, "application/json")
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.header("Host", "localhost:8080")
|
||||||
.body(Full::new(Bytes::from(init_body.to_string())))
|
.body(Full::new(Bytes::from(init_body.to_string())))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|
@ -785,6 +786,7 @@ async fn test_server_rejects_unsupported_protocol_version() {
|
||||||
.method(Method::POST)
|
.method(Method::POST)
|
||||||
.header("Accept", "application/json, text/event-stream")
|
.header("Accept", "application/json, text/event-stream")
|
||||||
.header(CONTENT_TYPE, "application/json")
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.header("Host", "localhost:8080")
|
||||||
.header("mcp-session-id", &session_id)
|
.header("mcp-session-id", &session_id)
|
||||||
.header("mcp-protocol-version", "2025-03-26")
|
.header("mcp-protocol-version", "2025-03-26")
|
||||||
.body(Full::new(Bytes::from(initialized_body.to_string())))
|
.body(Full::new(Bytes::from(initialized_body.to_string())))
|
||||||
|
|
@ -802,6 +804,7 @@ async fn test_server_rejects_unsupported_protocol_version() {
|
||||||
.method(Method::POST)
|
.method(Method::POST)
|
||||||
.header("Accept", "application/json, text/event-stream")
|
.header("Accept", "application/json, text/event-stream")
|
||||||
.header(CONTENT_TYPE, "application/json")
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.header("Host", "localhost:8080")
|
||||||
.header("mcp-session-id", &session_id)
|
.header("mcp-session-id", &session_id)
|
||||||
.header("mcp-protocol-version", "2025-03-26")
|
.header("mcp-protocol-version", "2025-03-26")
|
||||||
.body(Full::new(Bytes::from(valid_body.to_string())))
|
.body(Full::new(Bytes::from(valid_body.to_string())))
|
||||||
|
|
@ -823,6 +826,7 @@ async fn test_server_rejects_unsupported_protocol_version() {
|
||||||
.method(Method::POST)
|
.method(Method::POST)
|
||||||
.header("Accept", "application/json, text/event-stream")
|
.header("Accept", "application/json, text/event-stream")
|
||||||
.header(CONTENT_TYPE, "application/json")
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.header("Host", "localhost:8080")
|
||||||
.header("mcp-session-id", &session_id)
|
.header("mcp-session-id", &session_id)
|
||||||
.header("mcp-protocol-version", "9999-01-01")
|
.header("mcp-protocol-version", "9999-01-01")
|
||||||
.body(Full::new(Bytes::from(invalid_body.to_string())))
|
.body(Full::new(Bytes::from(invalid_body.to_string())))
|
||||||
|
|
@ -844,6 +848,7 @@ async fn test_server_rejects_unsupported_protocol_version() {
|
||||||
.method(Method::POST)
|
.method(Method::POST)
|
||||||
.header("Accept", "application/json, text/event-stream")
|
.header("Accept", "application/json, text/event-stream")
|
||||||
.header(CONTENT_TYPE, "application/json")
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.header("Host", "localhost:8080")
|
||||||
.header("mcp-session-id", &session_id)
|
.header("mcp-session-id", &session_id)
|
||||||
.body(Full::new(Bytes::from(no_version_body.to_string())))
|
.body(Full::new(Bytes::from(no_version_body.to_string())))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -870,3 +875,156 @@ fn test_protocol_version_utilities() {
|
||||||
assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_03_26));
|
assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_03_26));
|
||||||
assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_06_18));
|
assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_06_18));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Integration test: Verify server validates only the Host header for DNS rebinding protection
|
||||||
|
#[tokio::test]
|
||||||
|
#[cfg(all(feature = "transport-streamable-http-server", feature = "server",))]
|
||||||
|
async fn test_server_validates_host_header_for_dns_rebinding_protection() {
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use http::{Method, Request, header::CONTENT_TYPE};
|
||||||
|
use http_body_util::Full;
|
||||||
|
use rmcp::{
|
||||||
|
handler::server::ServerHandler,
|
||||||
|
model::{ServerCapabilities, ServerInfo},
|
||||||
|
transport::streamable_http_server::{
|
||||||
|
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct TestHandler;
|
||||||
|
|
||||||
|
impl ServerHandler for TestHandler {
|
||||||
|
fn get_info(&self) -> ServerInfo {
|
||||||
|
ServerInfo::new(ServerCapabilities::builder().build())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let service = StreamableHttpService::new(
|
||||||
|
|| Ok(TestHandler),
|
||||||
|
Arc::new(LocalSessionManager::default()),
|
||||||
|
StreamableHttpServerConfig::default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let init_body = json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "initialize",
|
||||||
|
"params": {
|
||||||
|
"protocolVersion": "2025-03-26",
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {
|
||||||
|
"name": "test-client",
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let allowed_request = Request::builder()
|
||||||
|
.method(Method::POST)
|
||||||
|
.header("Accept", "application/json, text/event-stream")
|
||||||
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.header("Host", "localhost:8080")
|
||||||
|
.header("Origin", "http://localhost:8080")
|
||||||
|
.body(Full::new(Bytes::from(init_body.to_string())))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let response = service.handle(allowed_request).await;
|
||||||
|
assert_eq!(response.status(), http::StatusCode::OK);
|
||||||
|
|
||||||
|
let bad_host_request = Request::builder()
|
||||||
|
.method(Method::POST)
|
||||||
|
.header("Accept", "application/json, text/event-stream")
|
||||||
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.header("Host", "attacker.example")
|
||||||
|
.body(Full::new(Bytes::from(init_body.to_string())))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let response = service.handle(bad_host_request).await;
|
||||||
|
assert_eq!(response.status(), http::StatusCode::FORBIDDEN);
|
||||||
|
|
||||||
|
let ignored_origin_request = Request::builder()
|
||||||
|
.method(Method::POST)
|
||||||
|
.header("Accept", "application/json, text/event-stream")
|
||||||
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.header("Host", "localhost:8080")
|
||||||
|
.header("Origin", "http://attacker.example")
|
||||||
|
.body(Full::new(Bytes::from(init_body.to_string())))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let response = service.handle(ignored_origin_request).await;
|
||||||
|
assert_eq!(response.status(), http::StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Integration test: Verify server can enforce an allowed Host port when configured
|
||||||
|
#[tokio::test]
|
||||||
|
#[cfg(all(feature = "transport-streamable-http-server", feature = "server",))]
|
||||||
|
async fn test_server_validates_host_header_port_for_dns_rebinding_protection() {
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use http::{Method, Request, header::CONTENT_TYPE};
|
||||||
|
use http_body_util::Full;
|
||||||
|
use rmcp::{
|
||||||
|
handler::server::ServerHandler,
|
||||||
|
model::{ServerCapabilities, ServerInfo},
|
||||||
|
transport::streamable_http_server::{
|
||||||
|
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct TestHandler;
|
||||||
|
|
||||||
|
impl ServerHandler for TestHandler {
|
||||||
|
fn get_info(&self) -> ServerInfo {
|
||||||
|
ServerInfo::new(ServerCapabilities::builder().build())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let service = StreamableHttpService::new(
|
||||||
|
|| Ok(TestHandler),
|
||||||
|
Arc::new(LocalSessionManager::default()),
|
||||||
|
StreamableHttpServerConfig::default().with_allowed_hosts(["localhost:8080"]),
|
||||||
|
);
|
||||||
|
|
||||||
|
let init_body = json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "initialize",
|
||||||
|
"params": {
|
||||||
|
"protocolVersion": "2025-03-26",
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {
|
||||||
|
"name": "test-client",
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let allowed_request = Request::builder()
|
||||||
|
.method(Method::POST)
|
||||||
|
.header("Accept", "application/json, text/event-stream")
|
||||||
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.header("Host", "localhost:8080")
|
||||||
|
.body(Full::new(Bytes::from(init_body.to_string())))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let response = service.handle(allowed_request).await;
|
||||||
|
assert_eq!(response.status(), http::StatusCode::OK);
|
||||||
|
|
||||||
|
let wrong_port_request = Request::builder()
|
||||||
|
.method(Method::POST)
|
||||||
|
.header("Accept", "application/json, text/event-stream")
|
||||||
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.header("Host", "localhost:3000")
|
||||||
|
.body(Full::new(Bytes::from(init_body.to_string())))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let response = service.handle(wrong_port_request).await;
|
||||||
|
assert_eq!(response.status(), http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue