mirror of
https://github.com/rustfs/rustfs.git
synced 2026-11-15 11:30:31 +00:00
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:
@@ -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())
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user