From 44cf2eb006921af4e6b4208898aa748cb6ca8758 Mon Sep 17 00:00:00 2001 From: root <4505225@example.com> Date: Thu, 17 Sep 2026 21:25:00 +0800 Subject: [PATCH 1/2] fix(streamable-http): match Origin's omitted default port against explicit allowlist entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browsers omit the port from the serialized Origin header when it equals the scheme default (RFC 6454 §6.2), so a deployment that configures allowed_origins = ["https://example.com:443"] never matches the browser-sent "https://example.com" — the raw Option comparison sees Some(443) vs None and rejects with 403. Resolve the incoming origin's effective port (443 for https/wss, 80 for http/ws) before comparing, so an explicitly configured port accepts both spellings of the same effective port while still rejecting genuinely different ports (8443 etc). The omitted-port entry form keeps its any-port wildcard semantics, which deployments behind multiple TLS terminators rely on; the rustdoc now states both behaviors. Fixes #1268 Co-Authored-By: Claude --- .../transport/streamable_http_server/tower.rs | 28 +++++++++- crates/rmcp/tests/test_custom_headers.rs | 53 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/crates/rmcp/src/transport/streamable_http_server/tower.rs b/crates/rmcp/src/transport/streamable_http_server/tower.rs index 1b5382015..274c6972b 100644 --- a/crates/rmcp/src/transport/streamable_http_server/tower.rs +++ b/crates/rmcp/src/transport/streamable_http_server/tower.rs @@ -116,6 +116,14 @@ pub struct StreamableHttpServerConfig { /// missing-`Origin` requests still pass. Entries must include a scheme; /// `"null"` matches the browser's `Origin: null`. /// + /// Port matching follows RFC 6454 §4/§6.2: browsers omit the port from the + /// serialized `Origin` header when it equals the scheme default (443 for + /// https, 80 for http), so an incoming portless origin carries the scheme + /// default implicitly. An entry with an explicit port therefore matches + /// both spellings (`https://example.com:443` matches an incoming + /// `https://example.com`), while an entry with an *omitted* port permits + /// ANY port for that scheme+host — use the explicit form to restrict. + /// /// Call [`StreamableHttpServerConfig::enforce_origin_validation`] to enable /// validation with an empty list, rejecting every present Origin value. /// examples: @@ -853,6 +861,20 @@ fn parse_origin_value(value: &str) -> Option { }) } +/// RFC 6454 §4: an origin tuple with an omitted port carries the scheme's +/// default port implicitly (443 for https/wss, 80 for http/ws) — browsers +/// omit the port in the serialized `Origin` header when it equals the +/// default (RFC 6454 §6.2). Resolve the incoming origin's effective port so +/// an explicitly configured `https://example.com:443` matches a browser-sent +/// `https://example.com`. +fn effective_origin_port(port: Option, scheme: &str) -> Option { + port.or(match scheme { + "https" | "wss" => Some(443), + "http" | "ws" => Some(80), + _ => None, + }) +} + fn origin_is_allowed(origin: &NormalizedOrigin, allowed_origins: &[String]) -> bool { allowed_origins .iter() @@ -870,7 +892,11 @@ fn origin_is_allowed(origin: &NormalizedOrigin, allowed_origins: &[String]) -> b host: o_host, port: o_port, }, - ) => a_scheme == o_scheme && a_host == o_host && (a_port.is_none() || a_port == o_port), + ) => { + a_scheme == o_scheme + && a_host == o_host + && (a_port.is_none() || a_port == &effective_origin_port(*o_port, o_scheme)) + } _ => false, }) } diff --git a/crates/rmcp/tests/test_custom_headers.rs b/crates/rmcp/tests/test_custom_headers.rs index b01223a6f..eb96f6072 100644 --- a/crates/rmcp/tests/test_custom_headers.rs +++ b/crates/rmcp/tests/test_custom_headers.rs @@ -1311,4 +1311,57 @@ mod origin_validation { let response = service.handle(init_request(Some("null"))).await; assert_eq!(response.status(), http::StatusCode::FORBIDDEN); } + + // RFC 6454 §4/§6.2: browsers omit the port from the serialized Origin + // header when it equals the scheme default, so a portless incoming + // origin carries the default port implicitly. An allowlist entry with + // an explicit port must match both spellings of the SAME effective + // port — and still reject a genuinely different port. + + #[tokio::test] + async fn explicit_https_443_entry_allows_portless_origin() { + let service = service_with_allowed_origins(&["https://example.com:443"]); + let response = service.handle(init_request(Some("https://example.com"))).await; + assert_eq!(response.status(), http::StatusCode::OK); + } + + #[tokio::test] + async fn explicit_https_443_entry_allows_explicit_443_origin() { + let service = service_with_allowed_origins(&["https://example.com:443"]); + let response = service.handle(init_request(Some("https://example.com:443"))).await; + assert_eq!(response.status(), http::StatusCode::OK); + } + + #[tokio::test] + async fn explicit_https_443_entry_forbids_8443_origin() { + let service = service_with_allowed_origins(&["https://example.com:443"]); + let response = service.handle(init_request(Some("https://example.com:8443"))).await; + assert_eq!(response.status(), http::StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn explicit_http_80_entry_allows_portless_origin() { + let service = service_with_allowed_origins(&["http://example.com:80"]); + let response = service.handle(init_request(Some("http://example.com"))).await; + assert_eq!(response.status(), http::StatusCode::OK); + } + + #[tokio::test] + async fn explicit_https_443_entry_forbids_portless_http_origin() { + // The effective port resolves per-scheme: an https:443 entry must + // not match an http origin whose implicit port is 80. + let service = service_with_allowed_origins(&["https://example.com:443"]); + let response = service.handle(init_request(Some("http://example.com"))).await; + assert_eq!(response.status(), http::StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn omitted_port_entry_still_matches_any_port() { + // Preserved wildcard: an entry with NO port permits any port for + // that scheme+host (relied upon by deployments that front the + // server with different TLS terminators). + let service = service_with_allowed_origins(&["https://example.com"]); + let response = service.handle(init_request(Some("https://example.com:8443"))).await; + assert_eq!(response.status(), http::StatusCode::OK); + } } From cf6882c9a0638b8ebf706ec024e2791ef1c9cfea Mon Sep 17 00:00:00 2001 From: root <4505225@example.com> Date: Sat, 19 Sep 2026 05:26:37 +0800 Subject: [PATCH 2/2] style: cargo fmt for test_custom_headers.rs --- crates/rmcp/tests/test_custom_headers.rs | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/crates/rmcp/tests/test_custom_headers.rs b/crates/rmcp/tests/test_custom_headers.rs index eb96f6072..847a48483 100644 --- a/crates/rmcp/tests/test_custom_headers.rs +++ b/crates/rmcp/tests/test_custom_headers.rs @@ -1321,28 +1321,36 @@ mod origin_validation { #[tokio::test] async fn explicit_https_443_entry_allows_portless_origin() { let service = service_with_allowed_origins(&["https://example.com:443"]); - let response = service.handle(init_request(Some("https://example.com"))).await; + let response = service + .handle(init_request(Some("https://example.com"))) + .await; assert_eq!(response.status(), http::StatusCode::OK); } #[tokio::test] async fn explicit_https_443_entry_allows_explicit_443_origin() { let service = service_with_allowed_origins(&["https://example.com:443"]); - let response = service.handle(init_request(Some("https://example.com:443"))).await; + let response = service + .handle(init_request(Some("https://example.com:443"))) + .await; assert_eq!(response.status(), http::StatusCode::OK); } #[tokio::test] async fn explicit_https_443_entry_forbids_8443_origin() { let service = service_with_allowed_origins(&["https://example.com:443"]); - let response = service.handle(init_request(Some("https://example.com:8443"))).await; + let response = service + .handle(init_request(Some("https://example.com:8443"))) + .await; assert_eq!(response.status(), http::StatusCode::FORBIDDEN); } #[tokio::test] async fn explicit_http_80_entry_allows_portless_origin() { let service = service_with_allowed_origins(&["http://example.com:80"]); - let response = service.handle(init_request(Some("http://example.com"))).await; + let response = service + .handle(init_request(Some("http://example.com"))) + .await; assert_eq!(response.status(), http::StatusCode::OK); } @@ -1351,7 +1359,9 @@ mod origin_validation { // The effective port resolves per-scheme: an https:443 entry must // not match an http origin whose implicit port is 80. let service = service_with_allowed_origins(&["https://example.com:443"]); - let response = service.handle(init_request(Some("http://example.com"))).await; + let response = service + .handle(init_request(Some("http://example.com"))) + .await; assert_eq!(response.status(), http::StatusCode::FORBIDDEN); } @@ -1361,7 +1371,9 @@ mod origin_validation { // that scheme+host (relied upon by deployments that front the // server with different TLS terminators). let service = service_with_allowed_origins(&["https://example.com"]); - let response = service.handle(init_request(Some("https://example.com:8443"))).await; + let response = service + .handle(init_request(Some("https://example.com:8443"))) + .await; assert_eq!(response.status(), http::StatusCode::OK); } }