From e4c1ee99414562c55f558c398329c623f1843530 Mon Sep 17 00:00:00 2001 From: Ramakrishna Chilaka <49393831+RamakrishnaChilaka@users.noreply.github.com> Date: Mon, 15 Jun 2026 04:19:31 +0530 Subject: [PATCH] fix(server): actionable 501 for virtual-hosted-style S3 requests (#3442) --- deploy/config/rustfs.env | 5 +- rustfs/src/config/cli.rs | 8 +- rustfs/src/server/http.rs | 13 +- rustfs/src/server/layer.rs | 314 ++++++++++++++++++++++++++++++++++++- 4 files changed, 336 insertions(+), 4 deletions(-) diff --git a/deploy/config/rustfs.env b/deploy/config/rustfs.env index 61b1a6789..c16ecd95e 100644 --- a/deploy/config/rustfs.env +++ b/deploy/config/rustfs.env @@ -13,7 +13,10 @@ RUSTFS_ADDRESS=0.0.0.0:9000 RUSTFS_CONSOLE_ENABLE=true # RustFS console listen address and port RUSTFS_CONSOLE_ADDRESS=0.0.0.0:9001 -# Optional service domain configuration for virtual-hosted-style requests (comma-separated). +# Optional service domain(s) for virtual-hosted-style requests (comma-separated). +# Required for clients that default to virtual-hosted-style addressing (AWS SDK, +# Terraform/Pulumi). Without it, only path-style addressing works (set the client's +# s3_use_path_style = true / force_path_style=true). Example: # RUSTFS_SERVER_DOMAINS=s3.example.com # Optional RustFS license content # RUSTFS_LICENSE=REPLACE_WITH_LICENSE_CONTENT diff --git a/rustfs/src/config/cli.rs b/rustfs/src/config/cli.rs index a91f00af6..cf121922b 100644 --- a/rustfs/src/config/cli.rs +++ b/rustfs/src/config/cli.rs @@ -189,7 +189,13 @@ pub struct ServerOpts { )] pub address: String, - /// Domain name used for virtual-hosted-style requests. + /// Domain name(s) for virtual-hosted-style S3 requests (comma-separated). + /// + /// Required for clients that default to virtual-hosted-style addressing + /// (AWS SDK, Terraform/Pulumi, etc.), e.g. `RUSTFS_SERVER_DOMAINS=s3.example.com` + /// so that `bucket.s3.example.com` is routed to bucket `bucket`. When unset, only + /// path-style addressing is supported (configure clients with + /// `s3_use_path_style = true` / `force_path_style=true`). #[arg( long, env = "RUSTFS_SERVER_DOMAINS", diff --git a/rustfs/src/server/http.rs b/rustfs/src/server/http.rs index d07ed7531..3b55858af 100644 --- a/rustfs/src/server/http.rs +++ b/rustfs/src/server/http.rs @@ -24,7 +24,7 @@ use crate::server::{ layer::{ BodylessStatusFixLayer, ConditionalCorsLayer, EmptyBodyContentLengthCompatLayer, HeadRequestBodyFixLayer, ObjectAttributesEtagFixLayer, PublicHealthEndpointLayer, RedirectLayer, RequestContextLayer, RequestLoggingLayer, - S3ErrorMessageCompatLayer, redact_sensitive_uri_query, + S3ErrorMessageCompatLayer, VirtualHostStyleHintLayer, redact_sensitive_uri_query, }, tls_material::{ TlsAcceptorHolder, TlsHandshakeFailureKind, build_acceptor_from_loaded, load_tls_material, spawn_reload_loop, @@ -506,6 +506,7 @@ pub async fn start_http_server(config: &config::Config, readiness: Arc, /// Pre-computed Keystone auth provider (avoids per-connection OnceLock read). keystone_auth: Option>, @@ -843,6 +847,7 @@ fn process_connection( s3_service, compression_config, is_console, + server_domains_configured, readiness, keystone_auth, trusted_proxy_layer, @@ -900,6 +905,7 @@ fn process_connection( // 19. BodylessStatusFixLayer — clears body for 1xx/204/205/304 responses // 20. HeadRequestBodyFixLayer — strips actual body bytes from HEAD responses // 21. PublicHealthEndpointLayer — handles public health before s3s host parsing + // 22. VirtualHostStyleHintLayer — actionable error for unroutable virtual-hosted-style (conditional) // ───────────────────────────────────────────────────────────── let hybrid_service = ServiceBuilder::new() // NOTE: Both extension types are intentionally inserted to maintain compatibility: @@ -1106,6 +1112,11 @@ fn process_connection( // buckets before custom routes. Handle them here so SERVER_DOMAINS // cannot turn /health into an S3 bucket request. .layer(PublicHealthEndpointLayer) + // Virtual-hosted-style S3 requests (the AWS SDK / Terraform default) cannot be + // routed when no server domain is configured: s3s parses them path-style and + // returns an opaque 501. When RUSTFS_SERVER_DOMAINS is unset, return an actionable + // error pointing at the fix. Inert (not installed) once domains are configured. + .option_layer((!server_domains_configured && !is_console).then_some(VirtualHostStyleHintLayer)) .service(service); let hybrid_service = TowerToHyperService::new(hybrid_service); diff --git a/rustfs/src/server/layer.rs b/rustfs/src/server/layer.rs index 15bac6720..73081e01f 100644 --- a/rustfs/src/server/layer.rs +++ b/rustfs/src/server/layer.rs @@ -26,7 +26,7 @@ use crate::server::{ use crate::storage::apply_cors_headers; use crate::storage::request_context::{RequestContext, extract_request_id_from_headers, extract_trace_context_ids_from_headers}; use bytes::Bytes; -use http::{HeaderMap, HeaderValue, Method, Request as HttpRequest, Response, StatusCode}; +use http::{HeaderMap, HeaderValue, Method, Request as HttpRequest, Response, StatusCode, Uri}; use http_body::Body; use http_body_util::BodyExt; use hyper::body::Incoming; @@ -955,6 +955,164 @@ where } } +/// Structured-log event emitted when a virtual-hosted-style request cannot be +/// routed because no server domain is configured. +const VIRTUAL_HOST_STYLE_UNROUTABLE_EVENT: &str = "virtual_host_style_unroutable"; + +/// Detects S3 requests that can only be virtual-hosted-style yet cannot be routed +/// because no server domain is configured (`RUSTFS_SERVER_DOMAINS` unset). +/// +/// Without a configured domain, s3s parses every request as path-style, so a +/// virtual-hosted-style `PUT /` (the AWS SDK / Terraform default for `CreateBucket`) +/// arrives with an empty bucket and fails with an opaque `501 NotImplemented`. +/// +/// Only the service root (`/`) with a method that has no path-style equivalent +/// (`PUT`/`DELETE`) and a DNS-style host (not an IP/socket address, and dotted like +/// `bucket.example.com`) is matched. The host is read from the `Host` header +/// (HTTP/1.1) or the request URI authority (HTTP/2 `:authority`). Such requests +/// already fail today, so returning a clearer error never changes the outcome of a +/// routable request. Returns the request host (without port) when matched. +fn unroutable_virtual_host_target(method: &Method, uri: &Uri, headers: &HeaderMap) -> Option { + if *method != Method::PUT && *method != Method::DELETE { + return None; + } + if uri.path() != "/" { + return None; + } + + // `Host` is set for HTTP/1.1; HTTP/2 carries the host in the URI authority. + let raw_authority = match headers.get(http::header::HOST).and_then(|value| value.to_str().ok()) { + Some(value) => value, + None => uri.authority().map(|authority| authority.as_str())?, + }; + if raw_authority.is_empty() { + return None; + } + // IP / socket-address hosts always use path-style addressing in s3s. + if rustfs_utils::is_socket_addr(raw_authority) { + return None; + } + // Parse as an Authority to split host/port robustly (e.g. bracketed IPv6) rather + // than slicing on `:`. + let authority = raw_authority.parse::().ok()?; + let host = authority.host(); + // Require a dotted DNS name (the shape of a virtual-hosted bucket subdomain). + if !host.contains('.') || host.starts_with('.') || host.ends_with('.') { + return None; + } + + Some(host.to_owned()) +} + +fn xml_escape(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '&' => escaped.push_str("&"), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + '"' => escaped.push_str("""), + '\'' => escaped.push_str("'"), + _ => escaped.push(ch), + } + } + escaped +} + +fn build_virtual_host_hint_response( + version: http::Version, + host: &str, + resource: &str, +) -> Response> +where + RestBody: From, +{ + let message = format!( + "Virtual-hosted-style request for host '{host}' could not be routed because no server domain is \ + configured. Set RUSTFS_SERVER_DOMAINS to the S3 endpoint domain your buckets are addressed under \ + (for example RUSTFS_SERVER_DOMAINS=s3.example.com, so that bucket.s3.example.com routes to bucket \ + 'bucket'), or configure your S3 client to use path-style addressing (AWS SDK: force_path_style=true; \ + Terraform aws provider: s3_use_path_style = true)." + ); + let body = format!( + "\ + NotImplemented{message}{resource}", + message = xml_escape(&message), + resource = xml_escape(resource), + ); + + let mut builder = Response::builder() + .status(StatusCode::NOT_IMPLEMENTED) + .header(http::header::CONTENT_TYPE, "application/xml"); + // This short-circuit path does not drain the request body. For HTTP/1.x, signal + // connection close so an undrained body cannot disrupt keep-alive reuse. `Connection` + // is a forbidden header in HTTP/2+, so it is only set for HTTP/1.x. + if !matches!(version, http::Version::HTTP_2 | http::Version::HTTP_3) { + builder = builder.header(http::header::CONNECTION, "close"); + } + builder + .body(HybridBody::Rest { + rest_body: RestBody::from(Bytes::from(body)), + }) + .expect("failed to build virtual-host hint response") +} + +/// Returns an actionable error for virtual-hosted-style S3 requests that cannot be +/// routed because `RUSTFS_SERVER_DOMAINS` is not configured. See +/// [`unroutable_virtual_host_target`]. The layer is only installed when no server +/// domain is configured, so it is a pure pass-through for every routable request. +#[derive(Clone)] +pub struct VirtualHostStyleHintLayer; + +impl Layer for VirtualHostStyleHintLayer { + type Service = VirtualHostStyleHintService; + + fn layer(&self, inner: S) -> Self::Service { + VirtualHostStyleHintService { inner } + } +} + +#[derive(Clone)] +pub struct VirtualHostStyleHintService { + inner: S, +} + +impl Service> for VirtualHostStyleHintService +where + S: Service, Response = Response>> + Clone + Send + 'static, + S::Future: Send + 'static, + ReqBody: Send + 'static, + RestBody: From + Send + 'static, + GrpcBody: Send + 'static, +{ + type Response = Response>; + type Error = S::Error; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: HttpRequest) -> Self::Future { + if let Some(host) = unroutable_virtual_host_target(req.method(), req.uri(), req.headers()) { + let resource = req.uri().path().to_owned(); + let version = req.version(); + debug!( + event = VIRTUAL_HOST_STYLE_UNROUTABLE_EVENT, + component = LOG_COMPONENT_SERVER, + subsystem = LOG_SUBSYSTEM_HTTP, + host = %host, + method = %req.method(), + "Rejected virtual-hosted-style request: no server domain configured (set RUSTFS_SERVER_DOMAINS or use path-style)" + ); + return Box::pin(async move { Ok(build_virtual_host_hint_response(version, &host, &resource)) }); + } + + let mut inner = self.inner.clone(); + Box::pin(async move { inner.call(req).await }) + } +} + fn is_bodyless_status(status: StatusCode) -> bool { status.is_informational() || status == StatusCode::NO_CONTENT @@ -1454,6 +1612,160 @@ mod tests { .await; } + #[test] + fn unroutable_virtual_host_target_matches_service_root_writes() { + let mut headers = HeaderMap::new(); + headers.insert(http::header::HOST, HeaderValue::from_static("my-bucket.s3.example.com")); + assert_eq!( + unroutable_virtual_host_target(&Method::PUT, &Uri::from_static("/"), &headers), + Some("my-bucket.s3.example.com".to_owned()) + ); + assert_eq!( + unroutable_virtual_host_target(&Method::DELETE, &Uri::from_static("/"), &headers), + Some("my-bucket.s3.example.com".to_owned()) + ); + } + + #[test] + fn unroutable_virtual_host_target_strips_port() { + let mut headers = HeaderMap::new(); + headers.insert(http::header::HOST, HeaderValue::from_static("bucket.localhost:9000")); + assert_eq!( + unroutable_virtual_host_target(&Method::PUT, &Uri::from_static("/"), &headers), + Some("bucket.localhost".to_owned()) + ); + } + + #[test] + fn unroutable_virtual_host_target_uses_uri_authority_when_host_header_absent() { + // HTTP/2 clients carry the host in the URI authority (`:authority`) rather than a Host header. + let uri = Uri::from_static("https://my-bucket.s3.example.com/"); + assert_eq!( + unroutable_virtual_host_target(&Method::PUT, &uri, &HeaderMap::new()), + Some("my-bucket.s3.example.com".to_owned()) + ); + } + + #[test] + fn unroutable_virtual_host_target_ignores_non_matching_requests() { + let mut vhost = HeaderMap::new(); + vhost.insert(http::header::HOST, HeaderValue::from_static("bucket.s3.example.com")); + // Path-style request carries the bucket in the path. + assert_eq!(unroutable_virtual_host_target(&Method::PUT, &Uri::from_static("/bucket"), &vhost), None); + // GET / is ListBuckets, a valid path-style service call. + assert_eq!(unroutable_virtual_host_target(&Method::GET, &Uri::from_static("/"), &vhost), None); + + // IP / socket-address hosts always use path-style addressing. + let mut ip = HeaderMap::new(); + ip.insert(http::header::HOST, HeaderValue::from_static("127.0.0.1:9000")); + assert_eq!(unroutable_virtual_host_target(&Method::PUT, &Uri::from_static("/"), &ip), None); + + // Bracketed IPv6 authority (with port) is a socket address, not a bucket subdomain. + let mut ipv6 = HeaderMap::new(); + ipv6.insert(http::header::HOST, HeaderValue::from_static("[::1]:9000")); + assert_eq!(unroutable_virtual_host_target(&Method::PUT, &Uri::from_static("/"), &ipv6), None); + + // Bare single-label hosts are not virtual-hosted bucket subdomains. + let mut bare = HeaderMap::new(); + bare.insert(http::header::HOST, HeaderValue::from_static("localhost:9000")); + assert_eq!(unroutable_virtual_host_target(&Method::PUT, &Uri::from_static("/"), &bare), None); + + // Trailing-dot FQDN is not matched (avoids suggesting a domain with a trailing dot). + let mut fqdn = HeaderMap::new(); + fqdn.insert(http::header::HOST, HeaderValue::from_static("bucket.example.com.")); + assert_eq!(unroutable_virtual_host_target(&Method::PUT, &Uri::from_static("/"), &fqdn), None); + + // Missing Host header and no URI authority. + assert_eq!( + unroutable_virtual_host_target(&Method::PUT, &Uri::from_static("/"), &HeaderMap::new()), + None + ); + } + + #[test] + fn build_virtual_host_hint_response_is_actionable() { + let response: Response, Full>> = + build_virtual_host_hint_response(http::Version::HTTP_11, "my-bucket.s3.example.com", "/"); + assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); + assert_eq!( + response + .headers() + .get(http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/xml") + ); + // HTTP/1.x error responses close the connection (the request body is not drained). + assert_eq!( + response + .headers() + .get(http::header::CONNECTION) + .and_then(|value| value.to_str().ok()), + Some("close") + ); + + // `Connection` is forbidden in HTTP/2, so it must not be set there. + let h2_response: Response, Full>> = + build_virtual_host_hint_response(http::Version::HTTP_2, "my-bucket.s3.example.com", "/"); + assert!(h2_response.headers().get(http::header::CONNECTION).is_none()); + } + + #[tokio::test] + async fn virtual_host_style_hint_layer_short_circuits_unroutable_put() { + let inner = CountingHybridService::default(); + let calls = inner.calls(); + let mut service = VirtualHostStyleHintLayer.layer(inner); + + let response = service + .call( + Request::builder() + .method(Method::PUT) + .uri("/") + .header(http::header::HOST, "my-bucket.s3.example.com") + .body(Full::::from(Bytes::new())) + .expect("request"), + ) + .await + .expect("hint response"); + + assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); + assert_eq!(calls.load(Ordering::SeqCst), 0); + assert_eq!( + response + .headers() + .get(http::header::CONNECTION) + .and_then(|value| value.to_str().ok()), + Some("close") + ); + + let body = BodyExt::collect(response.into_body()).await.expect("body").to_bytes(); + let body = String::from_utf8(body.to_vec()).expect("utf8 body"); + assert!(body.contains("RUSTFS_SERVER_DOMAINS=s3.example.com")); + assert!(body.contains("s3_use_path_style")); + } + + #[tokio::test] + async fn virtual_host_style_hint_layer_passes_through_path_style() { + let inner = CountingHybridService::default(); + let calls = inner.calls(); + let mut service = VirtualHostStyleHintLayer.layer(inner); + + let response = service + .call( + Request::builder() + .method(Method::PUT) + .uri("/my-bucket") + .header(http::header::HOST, "s3.example.com") + .body(Full::::from(Bytes::new())) + .expect("request"), + ) + .await + .expect("inner response"); + + // Path-style request reaches the inner service unchanged. + assert_eq!(response.status(), StatusCode::IM_A_TEAPOT); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + #[tokio::test] #[serial] async fn public_health_endpoint_layer_handles_ready_head_before_inner_service() {