fix(http): enforce Origin validation semantics (#1192)
Return HTTP 403 for malformed and non-UTF-8 Origin headers when validation is enabled. Add enforce_origin_validation() for strict empty-allowlist validation while preserving the legacy default and non-empty allowlist behavior. Configure the conformance server to opt in explicitly. Signed-off-by: lucarlig <luca.carlig@ibm.com>
This commit is contained in:
parent
3e636cab26
commit
e27c5d1c24
3 changed files with 117 additions and 20 deletions
|
|
@ -1744,7 +1744,9 @@ async fn main() -> anyhow::Result<()> {
|
|||
tracing::info!("Starting conformance server on {}", bind_addr);
|
||||
|
||||
let server = ConformanceServer::new();
|
||||
let config = StreamableHttpServerConfig::default();
|
||||
let config = StreamableHttpServerConfig::default()
|
||||
.with_allowed_origins([format!("http://{bind_addr}")])
|
||||
.enforce_origin_validation();
|
||||
let service = StreamableHttpService::new(
|
||||
move || Ok(server.clone()),
|
||||
LocalSessionManager::default().into(),
|
||||
|
|
|
|||
|
|
@ -110,13 +110,18 @@ pub struct StreamableHttpServerConfig {
|
|||
pub allowed_hosts: Vec<String>,
|
||||
/// Allowed browser origins for inbound `Origin` validation.
|
||||
///
|
||||
/// Defaults to an empty list, which disables Origin validation. When
|
||||
/// non-empty, requests carrying an `Origin` header must match per RFC 6454
|
||||
/// `(scheme, host, port)`; missing-`Origin` requests still pass. Entries
|
||||
/// must include a scheme; `"null"` matches the browser's `Origin: null`.
|
||||
/// Defaults to an empty list, which disables Origin validation for backward
|
||||
/// compatibility. A non-empty list enables validation. Requests carrying
|
||||
/// an `Origin` header must match per RFC 6454 `(scheme, host, port)`;
|
||||
/// missing-`Origin` requests still pass. Entries must include a scheme;
|
||||
/// `"null"` matches the browser's `Origin: null`.
|
||||
///
|
||||
/// Call [`StreamableHttpServerConfig::enforce_origin_validation`] to enable
|
||||
/// validation with an empty list, rejecting every present Origin value.
|
||||
/// examples:
|
||||
/// allowed_origins = ["https://app.example.com", "http://localhost:8080"]
|
||||
pub allowed_origins: Vec<String>,
|
||||
validate_empty_origin_allowlist: bool,
|
||||
/// Optional external session store for cross-instance recovery.
|
||||
///
|
||||
/// When set, [`SessionState`] (the client's `initialize` parameters) is
|
||||
|
|
@ -189,6 +194,7 @@ impl Default for StreamableHttpServerConfig {
|
|||
cancellation_token: CancellationToken::new(),
|
||||
allowed_hosts: vec!["localhost".into(), "127.0.0.1".into(), "::1".into()],
|
||||
allowed_origins: vec![],
|
||||
validate_empty_origin_allowlist: false,
|
||||
session_store: None,
|
||||
max_request_body_bytes: DEFAULT_MAX_REQUEST_BODY_BYTES,
|
||||
stateless_protocol_metadata_required: false,
|
||||
|
|
@ -216,9 +222,15 @@ impl StreamableHttpServerConfig {
|
|||
self.allowed_origins = allowed_origins.into_iter().map(Into::into).collect();
|
||||
self
|
||||
}
|
||||
/// Disable Origin validation, reverting to the default ignore-Origin behavior.
|
||||
/// Enable Origin validation, including when the allowed Origins list is empty.
|
||||
pub fn enforce_origin_validation(mut self) -> Self {
|
||||
self.validate_empty_origin_allowlist = true;
|
||||
self
|
||||
}
|
||||
/// Disable Origin validation, allowing requests with any `Origin` header.
|
||||
pub fn disable_allowed_origins(mut self) -> Self {
|
||||
self.allowed_origins.clear();
|
||||
self.validate_empty_origin_allowlist = false;
|
||||
self
|
||||
}
|
||||
pub fn with_sse_keep_alive(mut self, duration: Option<Duration>) -> Self {
|
||||
|
|
@ -802,9 +814,6 @@ fn parse_origin_value(value: &str) -> Option<NormalizedOrigin> {
|
|||
}
|
||||
|
||||
fn origin_is_allowed(origin: &NormalizedOrigin, allowed_origins: &[String]) -> bool {
|
||||
if allowed_origins.is_empty() {
|
||||
return true;
|
||||
}
|
||||
allowed_origins
|
||||
.iter()
|
||||
.filter_map(|raw| parse_origin_value(raw))
|
||||
|
|
@ -876,12 +885,15 @@ fn validate_dns_rebinding_headers(
|
|||
);
|
||||
return Err(forbidden_response("Forbidden: Host header is not allowed").into());
|
||||
}
|
||||
validate_origin_header(headers, &config.allowed_origins)?;
|
||||
validate_origin_header(headers, config)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_origin_header(headers: &HeaderMap, allowed_origins: &[String]) -> HttpResult<()> {
|
||||
if allowed_origins.is_empty() {
|
||||
fn validate_origin_header(
|
||||
headers: &HeaderMap,
|
||||
config: &StreamableHttpServerConfig,
|
||||
) -> HttpResult<()> {
|
||||
if !config.validate_empty_origin_allowlist && config.allowed_origins.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(origin_header) = headers.get(http::header::ORIGIN) else {
|
||||
|
|
@ -892,15 +904,15 @@ fn validate_origin_header(headers: &HeaderMap, allowed_origins: &[String]) -> Ht
|
|||
.inspect_err(|_| {
|
||||
tracing::warn!(origin = ?origin_header, "rejected request with non-UTF-8 Origin header");
|
||||
})
|
||||
.map_err(|_| bad_request_response("Bad Request: Invalid Origin header encoding"))?;
|
||||
.map_err(|_| forbidden_response("Forbidden: Invalid Origin header encoding"))?;
|
||||
let origin = parse_origin_value(origin_str).ok_or_else(|| {
|
||||
tracing::warn!(
|
||||
origin = origin_str,
|
||||
"rejected request with malformed Origin header",
|
||||
);
|
||||
bad_request_response("Bad Request: Invalid Origin header")
|
||||
forbidden_response("Forbidden: Invalid Origin header")
|
||||
})?;
|
||||
if !origin_is_allowed(&origin, allowed_origins) {
|
||||
if !origin_is_allowed(&origin, &config.allowed_origins) {
|
||||
tracing::warn!(
|
||||
origin = ?origin,
|
||||
"rejected request with disallowed Origin header (possible cross-origin attack)",
|
||||
|
|
|
|||
|
|
@ -880,10 +880,11 @@ fn test_protocol_version_utilities() {
|
|||
assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2026_07_28));
|
||||
}
|
||||
|
||||
/// Integration test: Verify server validates only the Host header for DNS rebinding protection
|
||||
/// Integration test: Verify Host validation remains enabled when Origin validation is
|
||||
/// disabled by default
|
||||
#[tokio::test]
|
||||
#[cfg(all(feature = "transport-streamable-http-server", feature = "server",))]
|
||||
async fn test_server_validates_host_header_for_dns_rebinding_protection() {
|
||||
async fn test_server_validates_host_when_origin_validation_is_disabled_by_default() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
|
|
@ -1127,7 +1128,7 @@ mod origin_validation {
|
|||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use http::{Method, Request, header::CONTENT_TYPE};
|
||||
use http::{HeaderValue, Method, Request, header::CONTENT_TYPE};
|
||||
use http_body_util::Full;
|
||||
use rmcp::{
|
||||
handler::server::ServerHandler,
|
||||
|
|
@ -1147,12 +1148,20 @@ mod origin_validation {
|
|||
}
|
||||
}
|
||||
|
||||
fn service_with_allowed_origins(
|
||||
origins: &[&str],
|
||||
fn service_with_config(
|
||||
config: StreamableHttpServerConfig,
|
||||
) -> StreamableHttpService<TestHandler, LocalSessionManager> {
|
||||
StreamableHttpService::new(
|
||||
|| Ok(TestHandler),
|
||||
Arc::new(LocalSessionManager::default()),
|
||||
config,
|
||||
)
|
||||
}
|
||||
|
||||
fn service_with_allowed_origins(
|
||||
origins: &[&str],
|
||||
) -> StreamableHttpService<TestHandler, LocalSessionManager> {
|
||||
service_with_config(
|
||||
StreamableHttpServerConfig::default().with_allowed_origins(origins.iter().copied()),
|
||||
)
|
||||
}
|
||||
|
|
@ -1199,6 +1208,80 @@ mod origin_validation {
|
|||
assert_eq!(response.status(), http::StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_origin_is_forbidden() {
|
||||
let service = service_with_allowed_origins(&["http://localhost:8080"]);
|
||||
let response = service.handle(init_request(Some("not-an-origin"))).await;
|
||||
assert_eq!(response.status(), http::StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_utf8_origin_is_forbidden() {
|
||||
let service = service_with_allowed_origins(&["http://localhost:8080"]);
|
||||
let mut request = init_request(None);
|
||||
request.headers_mut().insert(
|
||||
http::header::ORIGIN,
|
||||
HeaderValue::from_bytes(b"\xff").unwrap(),
|
||||
);
|
||||
let response = service.handle(request).await;
|
||||
assert_eq!(response.status(), http::StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enabled_empty_allowlist_forbids_present_origin() {
|
||||
let service =
|
||||
service_with_config(StreamableHttpServerConfig::default().enforce_origin_validation());
|
||||
let response = service
|
||||
.handle(init_request(Some("http://localhost:8080")))
|
||||
.await;
|
||||
assert_eq!(response.status(), http::StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enabled_empty_allowlist_allows_missing_origin() {
|
||||
let service =
|
||||
service_with_config(StreamableHttpServerConfig::default().enforce_origin_validation());
|
||||
let response = service.handle(init_request(None)).await;
|
||||
assert_eq!(response.status(), http::StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_allowed_origins_preserves_disabled_validation() {
|
||||
let service = service_with_config(
|
||||
StreamableHttpServerConfig::default().with_allowed_origins(std::iter::empty::<&str>()),
|
||||
);
|
||||
let response = service
|
||||
.handle(init_request(Some("http://attacker.example")))
|
||||
.await;
|
||||
assert_eq!(response.status(), http::StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nonempty_public_allowed_origins_field_enables_validation() {
|
||||
let mut config = StreamableHttpServerConfig::default();
|
||||
config
|
||||
.allowed_origins
|
||||
.push("http://localhost:8080".to_string());
|
||||
let service = service_with_config(config);
|
||||
let response = service
|
||||
.handle(init_request(Some("http://attacker.example")))
|
||||
.await;
|
||||
assert_eq!(response.status(), http::StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicitly_disabled_validation_allows_present_origin() {
|
||||
let service = service_with_config(
|
||||
StreamableHttpServerConfig::default()
|
||||
.enforce_origin_validation()
|
||||
.disable_allowed_origins(),
|
||||
);
|
||||
let response = service
|
||||
.handle(init_request(Some("http://attacker.example")))
|
||||
.await;
|
||||
assert_eq!(response.status(), http::StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_origin_passes_through() {
|
||||
let service = service_with_allowed_origins(&["http://localhost:8080"]);
|
||||
|
|
|
|||
Loading…
Reference in a new issue