fix(gateway): honor the SSE-C transport setting

Reuse the outer transport layer's deployment setting in the gateway builder. The existing false default allows customer keys over plaintext with the gateway's explicit acknowledgement; true keeps both key sides strict. Transport facts remain per request.

The shared signer fixture now accepts headers before signing. The new test exercises 45 scenarios (38 refusals, 7 test-handler successes), checks signed SSE headers and raw-key handoff, and runs the inner plaintext gate separately from the outer layer.

Validation on ebe31dfdb5: fmt, all-target Clippy with and without gateway-stack, test wiring, and 63 gateway-stack/handler/transport tests passed. Production diff: +29/-14 lines.

Fault controls fail ssec_deployment_policy_and_key_handoff: forced strict/allow, false secure transport, re-signed key/input rewrites, skipped handler, response-key echo and wrong refusal code. Separate fixture checks reject anonymous allowance and unsigned SSE headers. The original strict selector fails a valid signed plaintext request with InvalidRequest after a TLS handler control succeeds.

Not verified: copy-source key wire handoff, storage use cases, real Served IAM authorization, network TLS termination, cutover acceptance or CI. No production operation was added.
This commit is contained in:
overtrue
2026-11-14 10:40:24 +08:00
parent 9ea8e8d37f
commit 4bb272ed64
5 changed files with 222 additions and 15 deletions
@@ -54,7 +54,19 @@ pub(crate) fn signed_request(
access_key: &str,
secret_key: &str,
) -> Request<Empty<Bytes>> {
let mut headers = HeaderMap::new();
signed_request_with_headers(form, method, path, query, access_key, secret_key, HeaderMap::new())
}
/// Signs the supplied headers along with the request target.
pub(crate) fn signed_request_with_headers(
form: Signed,
method: Method,
path: &str,
query: &str,
access_key: &str,
secret_key: &str,
mut headers: HeaderMap,
) -> Request<Empty<Bytes>> {
headers.insert(http::header::HOST, HeaderValue::from_static("localhost"));
let probe = Request::builder()
.method(method.clone())
+28 -13
View File
@@ -27,7 +27,7 @@
//! RustFS's own layers stay in front of the gateway and the gateway's counterparts stay off
//! (rustfs/backlog#2771): CORS is answered by `ConditionalCorsLayer`, so no CORS source is
//! installed and the gateway writes no CORS header; the SSE-C transport layer runs first and the
//! gateway's own strict gate stays behind it; and the request identifier is the one
//! gateway gate follows the same deployment setting; and the request identifier is the one
//! `ExternalRequestContextLayer` assigned, handed over as the gateway's `HostRequestId`, so an
//! answer and RustFS's logs name a request identically.
//!
@@ -55,15 +55,16 @@ compile_error!("gateway-stack is restricted to debug builds");
use crate::app::dto_values::owner;
use crate::runtime_sources::{ServerContextSlot, current_action_credentials};
use crate::server::gateway_handlers::{GatewayBackend, OPERATIONS, OperationTable, register, serves};
use crate::server::ssec_transport::is_secure_transport;
use crate::server::ssec_transport::{is_secure_transport, require_tls};
use crate::shared_types::{NativeTlsTransport, RemoteAddr};
use crate::storage_api::server::http::request_context::RequestContext;
use http::{Extensions, Request, Response};
use rustfs_config::RUSTFS_REGION;
use rustfs_gateway::{
AssemblyError, Authorizer, BoxFuture, BucketName, BucketOwnerError, BucketOwnerSource, ClientAddr,
DEFAULT_MAX_BUFFERED_BODY_BYTES, Decision, HandlerDeadlineClass, HandlerDeadlineConfig, MintedTraces, RegionSet,
RequestBodyDeadlineConfig, ServiceBuilder, ServiceConfig, SigV4Authenticator, SseConfig, TransportSecurity, decide_with,
DEFAULT_MAX_BUFFERED_BODY_BYTES, Decision, HandlerDeadlineClass, HandlerDeadlineConfig, MintedTraces,
PlaintextCustomerKeyAck, RegionSet, RequestBodyDeadlineConfig, ServiceBuilder, ServiceConfig, SigV4Authenticator, SseConfig,
TransportSecurity, decide_with,
};
use rustfs_gateway_adapter::{
IamCredentials, IamKeyLookup, RequestIdCarrier, RootCredentials, RootCredentialsError, RootSource, host_request_id,
@@ -149,6 +150,20 @@ impl GatewayStack {
root: Arc<dyn RootSource>,
authorizer: impl Authorizer,
) -> Result<Self, GatewayAssemblyError> {
let builder = Self::configured_builder(&assembly, iam, root, authorizer)?;
let backend = Arc::new(GatewayBackend::new(Arc::clone(&assembly.server_ctx)));
let service = register(OPERATIONS, builder, &backend)
.build()
.map_err(GatewayAssemblyError::Service)?;
Ok(Self { service })
}
fn configured_builder(
assembly: &GatewayAssembly,
iam: Arc<dyn IamKeyLookup>,
root: Arc<dyn RootSource>,
authorizer: impl Authorizer,
) -> Result<ServiceBuilder, GatewayAssemblyError> {
// The pinned gateway admits a request that carries only `X-Auth-Token` as anonymous and
// runs no authenticator for it, while the RustFS code behind the gateway reads the
// Keystone identity: the request would be authorized as one caller and served as
@@ -165,8 +180,6 @@ impl GatewayStack {
region: region.to_owned(),
})?;
let hosts = rustfs_host_resolver(&assembly.server_domains).map_err(GatewayAssemblyError::ServerDomains)?;
let backend = Arc::new(GatewayBackend::new(Arc::clone(&assembly.server_ctx)));
// Every legacy RustFS reading of a request, a credential scope and an answer, in the
// gateway's one reviewable preset; its other half is on the authenticator.
let credentials = Arc::new(IamCredentials::new(iam, root));
@@ -175,17 +188,19 @@ impl GatewayStack {
.authenticator(SigV4Authenticator::new(credentials, regions).rustfs_profile())
.authorizer(authorizer)
.bucket_owner_source(ReportedOwner(owner().id.map(Arc::from)))
// The outer SSE-C transport layer answers first; the gateway's own gate stays strict
// behind it.
.sse_config(SseConfig::strict())
// Both gates follow the deployment setting; transport facts remain per request.
.sse_config(if require_tls() {
SseConfig::strict()
} else {
SseConfig::allowing_customer_keys_over_plaintext(
PlaintextCustomerKeyAck::i_understand_customer_keys_will_be_sent_in_the_clear(),
)
})
.host_resolver(hosts)
// The fallback only: `attach_host_facts` hands RustFS's own identifier over.
.trace_source(MintedTraces::with_uuid_request_ids());
let (builder, _settings) = builder.config(service_config(assembly.body_read_timeout));
let service = register(OPERATIONS, builder, &backend)
.build()
.map_err(GatewayAssemblyError::Service)?;
Ok(Self { service })
Ok(builder)
}
/// Answers one request on the gateway stack, with the host's facts attached.
+2
View File
@@ -14,6 +14,8 @@
//! The assembled gateway stack, driven through [`GatewayStack::call`] exactly as `server/http.rs`
//! drives it. Requests that need object storage are in `rustfs/tests/embedded_gateway_stack_test.rs`.
mod ssec;
use super::*;
use crate::server::gateway_handlers::rows;
use crate::server::gateway_handlers::test_support::{CLIENT, PEER, REQUEST_ID, Signed, request_context, signed_request};
@@ -0,0 +1,178 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Deployment SSE-C policy through the production builder and outer transport layer.
//! The handler observes key handoff; this does not exercise object storage.
use super::*;
use crate::server::gateway_handlers::test_support::signed_request_with_headers;
use md5::Digest;
use rustfs_gateway::{Handler, HandlerResult, Req, Resp, dto};
use std::sync::atomic::{AtomicUsize, Ordering};
use subtle::ConstantTimeEq;
use tower::{Layer, ServiceExt};
const ALGORITHM: &str = "x-amz-server-side-encryption-customer-algorithm";
const KEY: &str = "x-amz-server-side-encryption-customer-key";
const MD5: &str = "x-amz-server-side-encryption-customer-key-md5";
struct KeyProbe(AtomicUsize);
impl Handler<dto::GetObject> for KeyProbe {
async fn call(&self, request: Req<dto::GetObject>) -> HandlerResult<dto::GetObject> {
let value = request.context().headers().iter_raw().find(|(name, _)| name.as_str() == KEY);
let matches = value.is_some_and(|(_, value)| {
base64_simd::STANDARD
.decode_to_vec(value.as_bytes())
.is_ok_and(|key| bool::from(key.as_slice().ct_eq(&[7u8; 32])))
});
assert!(matches, "the handler must receive the original customer key");
self.0.fetch_add(1, Ordering::SeqCst);
Ok(Resp::new(dto::GetObjectOutput::default()))
}
}
#[test]
fn ssec_deployment_policy_and_key_handoff() {
use crate::server::ssec_transport::{ENV_SSE_C_REQUIRE_TLS, SsecTransportLayer};
// Keep the environment override and every async request inside temp_env's serialized scope.
for setting in [None, Some("false"), Some("true")] {
temp_env::with_var(ENV_SSE_C_REQUIRE_TLS, setting, || {
tokio::runtime::Runtime::new().expect("test runtime").block_on(async {
let probe = Arc::new(KeyProbe(AtomicUsize::new(0)));
let builder = GatewayStack::configured_builder(
&assembly(),
Arc::new(ScriptedIam { ready: true }),
root().source(),
rustfs_gateway::allow_when(|request| request.operation == "GetObject" && request.identity.is_some()),
)
.expect("the production builder assembles");
let stack = GatewayStack {
service: builder
.register::<dto::GetObject, _>(Arc::clone(&probe))
.build()
.expect("probe registration"),
};
// Also exercise the inner gate alone: the outer refusal must not mask it.
for outer in [true, false] {
for (secure, invalid, authenticated) in [
(true, None, true),
(false, None, true),
(false, Some("malformed"), true),
(true, Some("malformed"), true),
(false, Some("digest"), true),
(true, Some("digest"), true),
(false, Some("missing"), true),
(true, Some("missing"), true),
(false, None, false),
(true, None, false),
] {
if !outer && secure {
continue;
}
let mut headers = HeaderMap::new();
let encoded = base64_simd::STANDARD.encode_to_string([7u8; 32]);
headers
.insert(ALGORITHM, "AES256".parse().expect("static header"));
headers.insert(KEY, encoded.parse().expect("base64 header"));
let digest = base64_simd::STANDARD.encode_to_string(md5::Md5::digest([7u8; 32]));
headers.insert(MD5, digest.parse().expect("base64 digest"));
match invalid {
Some("malformed") => {
headers.insert(KEY, "!".parse().expect("static header"));
}
Some("digest") => {
headers.insert(
MD5,
base64_simd::STANDARD
.encode_to_string([0u8; 16])
.parse()
.expect("base64 digest"),
);
}
Some("missing") => {
headers.remove(MD5);
}
_ => {}
}
// A raw forwarding claim must not turn a plaintext connection into TLS.
headers
.insert("x-forwarded-proto", "https".parse().expect("static header"));
let mut request = signed_request_with_headers(
Signed::Query, Method::GET, "/photos/key", "", ROOT_ACCESS_KEY, ROOT_SECRET_KEY, headers,
);
let signed_headers = url::form_urlencoded::parse(request.uri().query().expect("signed query").as_bytes())
.find(|(name, _)| name.eq_ignore_ascii_case("X-Amz-SignedHeaders"))
.map(|(_, value)| value.into_owned())
.expect("the signer declares its signed headers");
for header in [ALGORITHM, KEY, MD5] {
if request.headers().contains_key(header) {
assert!(signed_headers.split(';').any(|name| name == header), "SSE-C headers must be signed");
}
}
if !authenticated {
*request.uri_mut() = "/photos/key".parse().expect("static URI");
}
let before = probe.0.load(Ordering::SeqCst);
let service = tower::service_fn(|request| async {
let response = stack.call(request).await;
Ok::<_, std::convert::Infallible>(response.map(|body| {
body.map_err(|error| -> Box<dyn std::error::Error + Send + Sync> { Box::new(error) })
.boxed_unsync()
}))
});
let response = if outer {
SsecTransportLayer::new(secure).layer(service).oneshot(request).await
} else {
service.oneshot(request).await
}.expect("infallible service");
let status = response.status();
let body = response.into_body().collect().await.expect("response body").to_bytes();
let text = String::from_utf8_lossy(&body);
let observed_code = ["InvalidRequest", "InvalidArgument", "AccessDenied", "SignatureDoesNotMatch", "InvalidAccessKeyId", "AuthorizationHeaderMalformed", "InternalError", "NotImplemented"]
.into_iter().find(|code| text.contains(&format!("<Code>{code}</Code>"))).unwrap_or("none_or_other");
let allowed = invalid.is_none() && authenticated && (secure || setting != Some("true"));
let expected_status = if allowed {
StatusCode::OK
} else if !authenticated && (secure || setting != Some("true")) {
StatusCode::FORBIDDEN
} else {
StatusCode::BAD_REQUEST
};
assert_eq!(
status,
expected_status,
"outer={outer} setting={setting:?} secure={secure} invalid={invalid:?} authenticated={authenticated} code={observed_code} prior_handler_calls={before}"
);
assert_eq!(probe.0.load(Ordering::SeqCst) - before, usize::from(allowed), "handler invocation count");
if !allowed {
let code = if !secure && setting == Some("true") {
"InvalidRequest"
} else if !authenticated {
"AccessDenied"
} else {
"InvalidArgument"
};
assert_eq!(observed_code, code, "the refusal code");
}
assert!(
!body.windows(encoded.len()).any(|window| bool::from(window.ct_eq(encoded.as_bytes()))),
"responses must not expose the customer key"
);
}
}
});
});
}
}
+1 -1
View File
@@ -101,7 +101,7 @@ pub(crate) fn is_secure_transport(connection_is_tls: bool, client_info: Option<&
.is_some_and(|proto| proto.eq_ignore_ascii_case("https"))
}
fn require_tls() -> bool {
pub(crate) fn require_tls() -> bool {
rustfs_utils::get_env_bool(ENV_SSE_C_REQUIRE_TLS, DEFAULT_SSE_C_REQUIRE_TLS)
}