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:
Luca 2026-09-11 02:38:27 +01:00 • committed by GitHub
commit e27c5d1c24
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -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)",