Skip to content

Commit 8b00146

Browse files
committed
fix(http)!: enforce Origin validation semantics
Return HTTP 403 for malformed Origin headers and treat an empty allowlist as denying every present Origin. Preserve disable_allowed_origins() as the explicit validation opt-out. Signed-off-by: lucarlig <luca.carlig@ibm.com>
1 parent 6f8dcde commit 8b00146

2 files changed

Lines changed: 78 additions & 20 deletions

File tree

crates/rmcp/src/transport/streamable_http_server/tower.rs

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,18 @@ pub struct StreamableHttpServerConfig {
9292
pub allowed_hosts: Vec<String>,
9393
/// Allowed browser origins for inbound `Origin` validation.
9494
///
95-
/// Defaults to an empty list, which disables Origin validation. When
96-
/// non-empty, requests carrying an `Origin` header must match per RFC 6454
97-
/// `(scheme, host, port)`; missing-`Origin` requests still pass. Entries
98-
/// must include a scheme; `"null"` matches the browser's `Origin: null`.
95+
/// Validation is enabled by default. Requests carrying an `Origin` header
96+
/// must match per RFC 6454
97+
/// `(scheme, host, port)`; missing-`Origin` requests still pass. An empty
98+
/// list allows no present Origin values. Entries must include a scheme;
99+
/// `"null"` matches the browser's `Origin: null`.
100+
///
101+
/// Call [`StreamableHttpServerConfig::disable_allowed_origins`] to
102+
/// explicitly disable Origin validation.
99103
/// examples:
100104
/// allowed_origins = ["https://app.example.com", "http://localhost:8080"]
101105
pub allowed_origins: Vec<String>,
106+
origin_validation_enabled: bool,
102107
/// Optional external session store for cross-instance recovery.
103108
///
104109
/// When set, [`SessionState`] (the client's `initialize` parameters) is
@@ -171,6 +176,7 @@ impl Default for StreamableHttpServerConfig {
171176
cancellation_token: CancellationToken::new(),
172177
allowed_hosts: vec!["localhost".into(), "127.0.0.1".into(), "::1".into()],
173178
allowed_origins: vec![],
179+
origin_validation_enabled: true,
174180
session_store: None,
175181
max_request_body_bytes: DEFAULT_MAX_REQUEST_BODY_BYTES,
176182
stateless_protocol_metadata_required: false,
@@ -196,11 +202,13 @@ impl StreamableHttpServerConfig {
196202
allowed_origins: impl IntoIterator<Item = impl Into<String>>,
197203
) -> Self {
198204
self.allowed_origins = allowed_origins.into_iter().map(Into::into).collect();
205+
self.origin_validation_enabled = true;
199206
self
200207
}
201-
/// Disable Origin validation, reverting to the default ignore-Origin behavior.
208+
/// Disable Origin validation, allowing requests with any `Origin` header.
202209
pub fn disable_allowed_origins(mut self) -> Self {
203210
self.allowed_origins.clear();
211+
self.origin_validation_enabled = false;
204212
self
205213
}
206214
pub fn with_sse_keep_alive(mut self, duration: Option<Duration>) -> Self {
@@ -797,9 +805,6 @@ fn parse_origin_value(value: &str) -> Option<NormalizedOrigin> {
797805
}
798806

799807
fn origin_is_allowed(origin: &NormalizedOrigin, allowed_origins: &[String]) -> bool {
800-
if allowed_origins.is_empty() {
801-
return true;
802-
}
803808
allowed_origins
804809
.iter()
805810
.filter_map(|raw| parse_origin_value(raw))
@@ -874,15 +879,15 @@ fn validate_dns_rebinding_headers(
874879
);
875880
return Err(forbidden_response("Forbidden: Host header is not allowed"));
876881
}
877-
validate_origin_header(headers, &config.allowed_origins)?;
882+
validate_origin_header(headers, config)?;
878883
Ok(())
879884
}
880885

881886
fn validate_origin_header(
882887
headers: &HeaderMap,
883-
allowed_origins: &[String],
888+
config: &StreamableHttpServerConfig,
884889
) -> Result<(), BoxResponse> {
885-
if allowed_origins.is_empty() {
890+
if !config.origin_validation_enabled {
886891
return Ok(());
887892
}
888893
let Some(origin_header) = headers.get(http::header::ORIGIN) else {
@@ -893,15 +898,15 @@ fn validate_origin_header(
893898
.inspect_err(|_| {
894899
tracing::warn!(origin = ?origin_header, "rejected request with non-UTF-8 Origin header");
895900
})
896-
.map_err(|_| bad_request_response("Bad Request: Invalid Origin header encoding"))?;
901+
.map_err(|_| forbidden_response("Forbidden: Invalid Origin header encoding"))?;
897902
let origin = parse_origin_value(origin_str).ok_or_else(|| {
898903
tracing::warn!(
899904
origin = origin_str,
900905
"rejected request with malformed Origin header",
901906
);
902-
bad_request_response("Bad Request: Invalid Origin header")
907+
forbidden_response("Forbidden: Invalid Origin header")
903908
})?;
904-
if !origin_is_allowed(&origin, allowed_origins) {
909+
if !origin_is_allowed(&origin, &config.allowed_origins) {
905910
tracing::warn!(
906911
origin = ?origin,
907912
"rejected request with disallowed Origin header (possible cross-origin attack)",

crates/rmcp/tests/test_custom_headers.rs

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -880,10 +880,10 @@ fn test_protocol_version_utilities() {
880880
assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2026_07_28));
881881
}
882882

883-
/// Integration test: Verify server validates only the Host header for DNS rebinding protection
883+
/// Integration test: Verify Host validation remains enabled when Origin validation is disabled
884884
#[tokio::test]
885885
#[cfg(all(feature = "transport-streamable-http-server", feature = "server",))]
886-
async fn test_server_validates_host_header_for_dns_rebinding_protection() {
886+
async fn test_server_validates_host_when_origin_validation_is_disabled() {
887887
use std::sync::Arc;
888888

889889
use bytes::Bytes;
@@ -910,7 +910,7 @@ async fn test_server_validates_host_header_for_dns_rebinding_protection() {
910910
let service = StreamableHttpService::new(
911911
|| Ok(TestHandler),
912912
Arc::new(LocalSessionManager::default()),
913-
StreamableHttpServerConfig::default(),
913+
StreamableHttpServerConfig::default().disable_allowed_origins(),
914914
);
915915

916916
let init_body = json!({
@@ -1127,7 +1127,7 @@ mod origin_validation {
11271127
use std::sync::Arc;
11281128

11291129
use bytes::Bytes;
1130-
use http::{Method, Request, header::CONTENT_TYPE};
1130+
use http::{HeaderValue, Method, Request, header::CONTENT_TYPE};
11311131
use http_body_util::Full;
11321132
use rmcp::{
11331133
handler::server::ServerHandler,
@@ -1147,12 +1147,20 @@ mod origin_validation {
11471147
}
11481148
}
11491149

1150-
fn service_with_allowed_origins(
1151-
origins: &[&str],
1150+
fn service_with_config(
1151+
config: StreamableHttpServerConfig,
11521152
) -> StreamableHttpService<TestHandler, LocalSessionManager> {
11531153
StreamableHttpService::new(
11541154
|| Ok(TestHandler),
11551155
Arc::new(LocalSessionManager::default()),
1156+
config,
1157+
)
1158+
}
1159+
1160+
fn service_with_allowed_origins(
1161+
origins: &[&str],
1162+
) -> StreamableHttpService<TestHandler, LocalSessionManager> {
1163+
service_with_config(
11561164
StreamableHttpServerConfig::default().with_allowed_origins(origins.iter().copied()),
11571165
)
11581166
}
@@ -1199,6 +1207,51 @@ mod origin_validation {
11991207
assert_eq!(response.status(), http::StatusCode::FORBIDDEN);
12001208
}
12011209

1210+
#[tokio::test]
1211+
async fn malformed_origin_is_forbidden() {
1212+
let service = service_with_allowed_origins(&["http://localhost:8080"]);
1213+
let response = service.handle(init_request(Some("not-an-origin"))).await;
1214+
assert_eq!(response.status(), http::StatusCode::FORBIDDEN);
1215+
}
1216+
1217+
#[tokio::test]
1218+
async fn non_utf8_origin_is_forbidden() {
1219+
let service = service_with_allowed_origins(&["http://localhost:8080"]);
1220+
let mut request = init_request(None);
1221+
request.headers_mut().insert(
1222+
http::header::ORIGIN,
1223+
HeaderValue::from_bytes(b"\xff").unwrap(),
1224+
);
1225+
let response = service.handle(request).await;
1226+
assert_eq!(response.status(), http::StatusCode::FORBIDDEN);
1227+
}
1228+
1229+
#[tokio::test]
1230+
async fn empty_allowlist_forbids_present_origin() {
1231+
let service = service_with_config(StreamableHttpServerConfig::default());
1232+
let response = service
1233+
.handle(init_request(Some("http://localhost:8080")))
1234+
.await;
1235+
assert_eq!(response.status(), http::StatusCode::FORBIDDEN);
1236+
}
1237+
1238+
#[tokio::test]
1239+
async fn empty_allowlist_allows_missing_origin() {
1240+
let service = service_with_config(StreamableHttpServerConfig::default());
1241+
let response = service.handle(init_request(None)).await;
1242+
assert_eq!(response.status(), http::StatusCode::OK);
1243+
}
1244+
1245+
#[tokio::test]
1246+
async fn explicitly_disabled_validation_allows_present_origin() {
1247+
let service =
1248+
service_with_config(StreamableHttpServerConfig::default().disable_allowed_origins());
1249+
let response = service
1250+
.handle(init_request(Some("http://attacker.example")))
1251+
.await;
1252+
assert_eq!(response.status(), http::StatusCode::OK);
1253+
}
1254+
12021255
#[tokio::test]
12031256
async fn missing_origin_passes_through() {
12041257
let service = service_with_allowed_origins(&["http://localhost:8080"]);

0 commit comments

Comments
 (0)