fix(http): fall back to :authority for HTTP/2 (#827)

This commit is contained in:
Dale Seo 2026-05-01 09:17:06 -04:00 committed by GitHub
parent 4cf78736e7
commit ef74147113
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 121 additions and 22 deletions

View file

@ -328,34 +328,42 @@ fn bad_request_response(message: &str) -> BoxResponse {
.expect("failed to build bad request response") .expect("failed to build bad request response")
} }
fn parse_host_header(headers: &HeaderMap) -> Result<NormalizedAuthority, BoxResponse> { fn parse_host_header(
let Some(host) = headers.get(http::header::HOST) else { uri: &http::Uri,
tracing::warn!("rejected request with missing Host header"); headers: &HeaderMap,
return Err(bad_request_response("Bad Request: missing Host header")); ) -> Result<NormalizedAuthority, BoxResponse> {
}; if let Some(host) = headers.get(http::header::HOST) {
let host_str = host
let host_str = host .to_str()
.to_str() .inspect_err(|_| {
.inspect_err(|_| { tracing::warn!(host = ?host, "rejected request with non-UTF-8 Host header");
tracing::warn!(host = ?host, "rejected request with non-UTF-8 Host header"); })
}) .map_err(|_| bad_request_response("Bad Request: Invalid Host header encoding"))?;
.map_err(|_| bad_request_response("Bad Request: Invalid Host header encoding"))?; let authority = http::uri::Authority::try_from(host_str)
let authority = http::uri::Authority::try_from(host_str) .inspect_err(|_| {
.inspect_err(|_| { tracing::warn!(
tracing::warn!( host = host_str,
host = host_str, "rejected request with malformed Host header"
"rejected request with malformed Host header" );
); })
}) .map_err(|_| bad_request_response("Bad Request: Invalid Host header"))?;
.map_err(|_| bad_request_response("Bad Request: Invalid Host header"))?; return Ok(normalize_authority(authority.host(), authority.port_u16()));
}
// HTTP/2 carries the host in `:authority`; middleware such as
// `axum::Router::nest` can drop the `Host` header hyper synthesizes from it.
let authority = uri.authority().ok_or_else(|| {
tracing::warn!("rejected request with missing Host header and no :authority");
bad_request_response("Bad Request: missing Host header")
})?;
Ok(normalize_authority(authority.host(), authority.port_u16())) Ok(normalize_authority(authority.host(), authority.port_u16()))
} }
fn validate_dns_rebinding_headers( fn validate_dns_rebinding_headers(
uri: &http::Uri,
headers: &HeaderMap, headers: &HeaderMap,
config: &StreamableHttpServerConfig, config: &StreamableHttpServerConfig,
) -> Result<(), BoxResponse> { ) -> Result<(), BoxResponse> {
let host = parse_host_header(headers)?; let host = parse_host_header(uri, headers)?;
if !host_is_allowed(&host, &config.allowed_hosts) { if !host_is_allowed(&host, &config.allowed_hosts) {
tracing::warn!( tracing::warn!(
host = ?host, host = ?host,
@ -806,7 +814,9 @@ where
B: Body + Send + 'static, B: Body + Send + 'static,
B::Error: Display, B::Error: Display,
{ {
if let Err(response) = validate_dns_rebinding_headers(request.headers(), &self.config) { if let Err(response) =
validate_dns_rebinding_headers(request.uri(), request.headers(), &self.config)
{
return response; return response;
} }
let method = request.method().clone(); let method = request.method().clone();

View file

@ -1031,6 +1031,95 @@ async fn test_server_validates_host_header_port_for_dns_rebinding_protection() {
assert_eq!(response.status(), http::StatusCode::FORBIDDEN); assert_eq!(response.status(), http::StatusCode::FORBIDDEN);
} }
/// Integration test: Verify the validator falls back to the URI authority when
/// the Host header is absent (HTTP/2 :authority pseudo-header scenario).
#[tokio::test]
#[cfg(all(feature = "transport-streamable-http-server", feature = "server",))]
async fn test_server_falls_back_to_uri_authority_when_host_header_missing() {
use std::sync::Arc;
use bytes::Bytes;
use http::{Method, Request, header::CONTENT_TYPE};
use http_body_util::Full;
use rmcp::{
handler::server::ServerHandler,
model::{ServerCapabilities, ServerInfo},
transport::streamable_http_server::{
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
},
};
use serde_json::json;
#[derive(Clone)]
struct TestHandler;
impl ServerHandler for TestHandler {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().build())
}
}
let service = StreamableHttpService::new(
|| Ok(TestHandler),
Arc::new(LocalSessionManager::default()),
StreamableHttpServerConfig::default(),
);
let init_body = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {
"name": "test-client",
"version": "1.0.0"
}
}
});
// Allowed authority via URI only — no Host header.
let allowed_request = Request::builder()
.method(Method::POST)
.uri("http://localhost:8080/")
.header("Accept", "application/json, text/event-stream")
.header(CONTENT_TYPE, "application/json")
.body(Full::new(Bytes::from(init_body.to_string())))
.unwrap();
assert!(allowed_request.headers().get("Host").is_none());
let response = service.handle(allowed_request).await;
assert_eq!(response.status(), http::StatusCode::OK);
// Disallowed authority via URI only — no Host header.
let bad_request = Request::builder()
.method(Method::POST)
.uri("http://attacker.example/")
.header("Accept", "application/json, text/event-stream")
.header(CONTENT_TYPE, "application/json")
.body(Full::new(Bytes::from(init_body.to_string())))
.unwrap();
assert!(bad_request.headers().get("Host").is_none());
let response = service.handle(bad_request).await;
assert_eq!(response.status(), http::StatusCode::FORBIDDEN);
// Neither Host header nor URI authority — still a 400.
let missing_request = Request::builder()
.method(Method::POST)
.uri("/")
.header("Accept", "application/json, text/event-stream")
.header(CONTENT_TYPE, "application/json")
.body(Full::new(Bytes::from(init_body.to_string())))
.unwrap();
assert!(missing_request.headers().get("Host").is_none());
assert!(missing_request.uri().authority().is_none());
let response = service.handle(missing_request).await;
assert_eq!(response.status(), http::StatusCode::BAD_REQUEST);
}
#[cfg(all(feature = "transport-streamable-http-server", feature = "server"))] #[cfg(all(feature = "transport-streamable-http-server", feature = "server"))]
mod origin_validation { mod origin_validation {
use std::sync::Arc; use std::sync::Arc;