mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-26 16:28:15 +00:00
fix(server): actionable 501 for virtual-hosted-style S3 requests (#3442)
This commit is contained in:
committed by
GitHub
parent
f8e300bf3b
commit
e4c1ee9941
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<GlobalRea
|
||||
}
|
||||
|
||||
let is_console = config.console_enable;
|
||||
let server_domains_configured = !config.server_domains.is_empty();
|
||||
let task_handle = tokio::spawn(async move {
|
||||
// Note: CORS layer is removed from global middleware stack
|
||||
// - S3 API CORS is handled by bucket-level CORS configuration in apply_cors_headers()
|
||||
@@ -690,6 +691,7 @@ pub async fn start_http_server(config: &config::Config, readiness: Arc<GlobalRea
|
||||
s3_service: s3_service.clone(),
|
||||
compression_config: compression_config.clone(),
|
||||
is_console,
|
||||
server_domains_configured,
|
||||
readiness: readiness.clone(),
|
||||
keystone_auth: auth_keystone::get_keystone_auth(),
|
||||
trusted_proxy_layer: rustfs_trusted_proxies::is_enabled().then(|| rustfs_trusted_proxies::layer().clone()),
|
||||
@@ -742,6 +744,8 @@ struct ConnectionContext {
|
||||
s3_service: S3Service,
|
||||
compression_config: HttpCompressionConfig,
|
||||
is_console: bool,
|
||||
/// Whether `RUSTFS_SERVER_DOMAINS` is configured (i.e. s3s virtual-hosted-style routing is active).
|
||||
server_domains_configured: bool,
|
||||
readiness: Arc<GlobalReadiness>,
|
||||
/// Pre-computed Keystone auth provider (avoids per-connection OnceLock read).
|
||||
keystone_auth: Option<Arc<rustfs_keystone::KeystoneAuthProvider>>,
|
||||
@@ -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);
|
||||
|
||||
+313
-1
@@ -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<String> {
|
||||
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::<http::uri::Authority>().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<RestBody, GrpcBody>(
|
||||
version: http::Version,
|
||||
host: &str,
|
||||
resource: &str,
|
||||
) -> Response<HybridBody<RestBody, GrpcBody>>
|
||||
where
|
||||
RestBody: From<Bytes>,
|
||||
{
|
||||
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!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
|
||||
<Error><Code>NotImplemented</Code><Message>{message}</Message><Resource>{resource}</Resource></Error>",
|
||||
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<S> Layer<S> for VirtualHostStyleHintLayer {
|
||||
type Service = VirtualHostStyleHintService<S>;
|
||||
|
||||
fn layer(&self, inner: S) -> Self::Service {
|
||||
VirtualHostStyleHintService { inner }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct VirtualHostStyleHintService<S> {
|
||||
inner: S,
|
||||
}
|
||||
|
||||
impl<S, ReqBody, RestBody, GrpcBody> Service<HttpRequest<ReqBody>> for VirtualHostStyleHintService<S>
|
||||
where
|
||||
S: Service<HttpRequest<ReqBody>, Response = Response<HybridBody<RestBody, GrpcBody>>> + Clone + Send + 'static,
|
||||
S::Future: Send + 'static,
|
||||
ReqBody: Send + 'static,
|
||||
RestBody: From<Bytes> + Send + 'static,
|
||||
GrpcBody: Send + 'static,
|
||||
{
|
||||
type Response = Response<HybridBody<RestBody, GrpcBody>>;
|
||||
type Error = S::Error;
|
||||
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
|
||||
|
||||
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
self.inner.poll_ready(cx)
|
||||
}
|
||||
|
||||
fn call(&mut self, req: HttpRequest<ReqBody>) -> 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<HybridBody<Full<Bytes>, Full<Bytes>>> =
|
||||
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<HybridBody<Full<Bytes>, Full<Bytes>>> =
|
||||
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::<Bytes>::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::<Bytes>::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() {
|
||||
|
||||
Reference in New Issue
Block a user