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..847a48483 100644 --- a/crates/rmcp/tests/test_custom_headers.rs +++ b/crates/rmcp/tests/test_custom_headers.rs @@ -1311,4 +1311,69 @@ 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); + } }