feat(s3): enforce multipart presigned size limits (#6732)

This commit is contained in:
cxymds
2026-08-27 18:33:56 +08:00
committed by GitHub
parent a199312e45
commit 94a6da6e83
11 changed files with 629 additions and 58 deletions
+120 -18
View File
@@ -67,6 +67,7 @@ use super::storage_api::multipart_usecase::sse::{
use super::storage_api::multipart_usecase::{
StorageObjectInfo as ObjectInfo, StorageObjectOptions as ObjectOptions, StoragePutObjReader as PutObjReader,
};
use crate::app::object::{guard_put_object_body_read_timeout, put_object_body_read_timeout};
use crate::app::object_data_cache::{
ObjectDataCacheAdapter, invalidate_object_data_cache_after_complete_multipart_success,
invalidate_object_data_cache_before_mutation,
@@ -78,7 +79,11 @@ use crate::app::object_usecase::{
use crate::app::runtime_sources::{
AppContext, current_app_context, current_object_data_cache_for_context, current_object_store_handle_for_context,
};
use crate::auth::{VerifiedPresignedRequest, reject_presigned_put_max_content_length_for_other_operation};
use crate::auth::{
VerifiedPresignedRequest, VerifiedSigV4Request, parse_presigned_multipart_max_total_object_size,
reject_presigned_multipart_max_total_object_size_for_other_operation,
reject_presigned_put_max_content_length_for_other_operation,
};
use crate::capacity::record_capacity_write;
use crate::error::ApiError;
use crate::table_catalog;
@@ -92,8 +97,9 @@ use rustfs_utils::CompressionAlgorithm;
#[cfg(test)]
use rustfs_utils::http::insert_header;
use rustfs_utils::http::{
SUFFIX_REPLICATION_PRESERVE_CIPHERTEXT, SUFFIX_REPLICATION_STATUS, SUFFIX_REPLICATION_TIMESTAMP,
SUFFIX_SOURCE_REPLICATION_REQUEST, contains_key_str, get_header, get_source_scheme,
SUFFIX_MAX_TOTAL_OBJECT_SIZE, SUFFIX_REPLICATION_PRESERVE_CIPHERTEXT, SUFFIX_REPLICATION_STATUS,
SUFFIX_REPLICATION_TIMESTAMP, SUFFIX_SOURCE_REPLICATION_REQUEST, contains_key_str, get_consistent_str, get_header,
get_source_scheme,
headers::{AMZ_DECODED_CONTENT_LENGTH, AMZ_OBJECT_TAGGING, AMZ_STORAGE_CLASS},
insert_str,
};
@@ -108,6 +114,7 @@ use s3s::{S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error};
use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use tokio_util::io::StreamReader;
use tracing::{instrument, warn};
@@ -226,6 +233,22 @@ fn create_multipart_upload_metadata(
metadata
}
fn multipart_max_total_object_size(metadata: &HashMap<String, String>) -> S3Result<Option<u64>> {
if !contains_key_str(metadata, SUFFIX_MAX_TOTAL_OBJECT_SIZE) {
return Ok(None);
}
let value = get_consistent_str(metadata, SUFFIX_MAX_TOTAL_OBJECT_SIZE).ok_or_else(|| {
S3Error::with_message(
S3ErrorCode::InvalidRequest,
"multipart size capability metadata is missing or inconsistent".to_string(),
)
})?;
value.parse::<u64>().map(Some).map_err(|_| {
S3Error::with_message(S3ErrorCode::InvalidRequest, "multipart size capability metadata is invalid".to_string())
})
}
/// A multipart session advertises disk compression only when the staged-rollout
/// switch (`RUSTFS_COMPRESSION_MULTIPART_ENABLED`) is on, the object key/headers
/// qualify, AND the session is not an SSE-C ciphertext-passthrough replication
@@ -398,6 +421,11 @@ impl DefaultMultipartUsecase {
&self,
req: S3Request<AbortMultipartUploadInput>,
) -> S3Result<S3Response<AbortMultipartUploadOutput>> {
reject_presigned_multipart_max_total_object_size_for_other_operation(
&req.headers,
req.uri.query(),
req.extensions.get::<VerifiedSigV4Request>().is_some(),
)?;
reject_presigned_put_max_content_length_for_other_operation(
&req.headers,
req.uri.query(),
@@ -444,6 +472,11 @@ impl DefaultMultipartUsecase {
&self,
req: S3Request<CompleteMultipartUploadInput>,
) -> S3Result<S3Response<CompleteMultipartUploadOutput>> {
reject_presigned_multipart_max_total_object_size_for_other_operation(
&req.headers,
req.uri.query(),
req.extensions.get::<VerifiedSigV4Request>().is_some(),
)?;
reject_presigned_put_max_content_length_for_other_operation(
&req.headers,
req.uri.query(),
@@ -752,6 +785,11 @@ impl DefaultMultipartUsecase {
&self,
req: S3Request<CreateMultipartUploadInput>,
) -> S3Result<S3Response<CreateMultipartUploadOutput>> {
let multipart_max_total_object_size = parse_presigned_multipart_max_total_object_size(
&req.headers,
req.uri.query(),
req.extensions.get::<VerifiedSigV4Request>().is_some(),
)?;
reject_presigned_put_max_content_length_for_other_operation(
&req.headers,
req.uri.query(),
@@ -807,6 +845,9 @@ impl DefaultMultipartUsecase {
)?;
let mut metadata = create_multipart_upload_metadata(input_metadata, &req.headers, tagging, storage_class.as_ref());
if let Some(limit) = multipart_max_total_object_size {
insert_str(&mut metadata, SUFFIX_MAX_TOTAL_OBJECT_SIZE, limit.to_string());
}
let has_explicit_object_lock_retention = object_lock_mode.is_some()
|| object_lock_retain_until_date.is_some()
@@ -978,6 +1019,11 @@ impl DefaultMultipartUsecase {
#[instrument(level = "debug", skip(self, req))]
#[hotpath::measure(impl_type = "MultipartUsecase")]
pub async fn execute_upload_part(&self, req: S3Request<UploadPartInput>) -> S3Result<S3Response<UploadPartOutput>> {
reject_presigned_multipart_max_total_object_size_for_other_operation(
&req.headers,
req.uri.query(),
req.extensions.get::<VerifiedSigV4Request>().is_some(),
)?;
reject_presigned_put_max_content_length_for_other_operation(
&req.headers,
req.uri.query(),
@@ -1006,6 +1052,40 @@ impl DefaultMultipartUsecase {
let mut size = resolve_upload_part_size(&req.headers, content_length)?;
let mut body_stream = body.ok_or_else(|| s3_error!(IncompleteBody))?;
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
let fi = store
.get_multipart_info(&bucket, &key, &upload_id, &opts)
.await
.map_err(ApiError::from)?;
let max_total_object_size = multipart_max_total_object_size(&fi.user_defined)?;
if max_total_object_size.is_some() && size.is_some_and(|size| size < 0) {
return Err(S3Error::new(S3ErrorCode::UnexpectedContent));
}
if max_total_object_size.is_some() && size.is_none() {
return Err(S3Error::new(S3ErrorCode::UnexpectedContent));
}
if let (Some(limit), Some(size)) = (max_total_object_size, size)
&& u64::try_from(size).is_ok_and(|size| size > limit)
{
return Err(S3Error::new(S3ErrorCode::EntityTooLarge));
}
if max_total_object_size.is_some() {
let request_id = req
.extensions
.get::<super::storage_api::multipart_usecase::request_context::RequestContext>()
.map(|ctx| ctx.request_id.clone())
.unwrap_or_default();
body_stream = guard_put_object_body_read_timeout(
body_stream,
&bucket,
&key,
&request_id,
content_length,
put_object_body_read_timeout().max(Duration::from_secs(rustfs_config::DEFAULT_HTTP_REQUEST_BODY_READ_TIMEOUT)),
);
}
if size.is_none() {
let mut total = 0i64;
@@ -1026,16 +1106,6 @@ impl DefaultMultipartUsecase {
body_stream = StreamingBlob::wrap(stream);
}
// Get multipart info early to check if managed encryption will be applied
let Some(store) = self.object_store() else {
return Err(S3Error::with_message(S3ErrorCode::InternalError, "Not init".to_string()));
};
let fi = store
.get_multipart_info(&bucket, &key, &upload_id, &opts)
.await
.map_err(ApiError::from)?;
let mut size = size.ok_or_else(|| s3_error!(UnexpectedContent))?;
let ingress_stage_start = rustfs_io_metrics::put_stage_metrics_enabled().then(std::time::Instant::now);
@@ -1250,6 +1320,11 @@ impl DefaultMultipartUsecase {
&self,
req: S3Request<ListMultipartUploadsInput>,
) -> S3Result<S3Response<ListMultipartUploadsOutput>> {
reject_presigned_multipart_max_total_object_size_for_other_operation(
&req.headers,
req.uri.query(),
req.extensions.get::<VerifiedSigV4Request>().is_some(),
)?;
reject_presigned_put_max_content_length_for_other_operation(
&req.headers,
req.uri.query(),
@@ -1302,6 +1377,11 @@ impl DefaultMultipartUsecase {
}
pub async fn execute_list_parts(&self, req: S3Request<ListPartsInput>) -> S3Result<S3Response<ListPartsOutput>> {
reject_presigned_multipart_max_total_object_size_for_other_operation(
&req.headers,
req.uri.query(),
req.extensions.get::<VerifiedSigV4Request>().is_some(),
)?;
reject_presigned_put_max_content_length_for_other_operation(
&req.headers,
req.uri.query(),
@@ -1338,6 +1418,11 @@ impl DefaultMultipartUsecase {
&self,
req: S3Request<UploadPartCopyInput>,
) -> S3Result<S3Response<UploadPartCopyOutput>> {
reject_presigned_multipart_max_total_object_size_for_other_operation(
&req.headers,
req.uri.query(),
req.extensions.get::<VerifiedSigV4Request>().is_some(),
)?;
reject_presigned_put_max_content_length_for_other_operation(
&req.headers,
req.uri.query(),
@@ -1441,6 +1526,7 @@ impl DefaultMultipartUsecase {
.get_multipart_info(&bucket, &key, &upload_id, &dst_opts)
.await
.map_err(ApiError::from)?;
let destination_size_limit = multipart_max_total_object_size(&mp_info.user_defined)?;
EncryptionRequest {
bucket: &bucket,
key: &key,
@@ -1523,19 +1609,25 @@ impl DefaultMultipartUsecase {
return Err(s3_error!(PreconditionFailed));
}
let source_logical_size = match src_info.get_actual_size() {
Ok(size) if size >= 0 => size,
Ok(_) | Err(_) if destination_size_limit.is_some() => {
return Err(S3Error::new(S3ErrorCode::UnexpectedContent));
}
Ok(_) | Err(_) => src_info.size,
};
let (_start_offset, length) = if let Some(ref range_spec) = rs {
// Copy-source ranges are expressed over the logical plaintext object.
// Encrypted (and compressed) objects have a larger or smaller physical
// representation, so validating against `size` rejects valid later parts.
let validation_size = src_info.get_actual_size().unwrap_or(src_info.size);
validate_copy_source_range_not_exceeds(range_spec, validation_size)?;
validate_copy_source_range_not_exceeds(range_spec, source_logical_size)?;
range_spec
.get_offset_length(validation_size)
.get_offset_length(source_logical_size)
.map_err(|e| S3Error::with_message(S3ErrorCode::InvalidRange, e.to_string()))?
} else {
(0, src_info.size)
(0, source_logical_size)
};
let is_disk_compressed =
@@ -2137,6 +2229,16 @@ mod tests {
assert_eq!(metadata.get(AMZ_OBJECT_TAGGING), Some(&"project=rustfs".to_string()));
}
#[test]
fn multipart_max_total_object_size_reads_compatible_internal_metadata() {
let mut metadata = HashMap::new();
insert_str(&mut metadata, SUFFIX_MAX_TOTAL_OBJECT_SIZE, "104857600".to_string());
assert_eq!(multipart_max_total_object_size(&metadata).unwrap(), Some(104_857_600));
metadata.insert("x-minio-internal-max-total-object-size".to_string(), "1".to_string());
assert!(multipart_max_total_object_size(&metadata).is_err());
}
#[tokio::test]
async fn execute_complete_multipart_upload_rejects_missing_parts_payload() {
let input = CompleteMultipartUploadInput::builder()
+1
View File
@@ -195,6 +195,7 @@ pub(crate) use self::delete::*;
pub(crate) use self::extract::*;
pub(crate) use self::get::*;
use self::put::*;
pub(crate) use self::put::{guard_put_object_body_read_timeout, put_object_body_read_timeout};
pub(crate) use self::shared::*;
#[cfg(test)]
use self::test_support::*;
+2 -2
View File
@@ -90,7 +90,7 @@ fn resolve_put_object_authoritative_size(headers: &HeaderMap, content_length: Op
/// Returns `Duration::ZERO` when disabled (`RUSTFS_HTTP_REQUEST_BODY_READ_TIMEOUT=0`),
/// in which case [`guard_put_object_body_read_timeout`] passes the body through
/// untouched.
fn put_object_body_read_timeout() -> Duration {
pub(crate) fn put_object_body_read_timeout() -> Duration {
Duration::from_secs(rustfs_utils::get_env_u64(
rustfs_config::ENV_HTTP_REQUEST_BODY_READ_TIMEOUT,
rustfs_config::DEFAULT_HTTP_REQUEST_BODY_READ_TIMEOUT,
@@ -260,7 +260,7 @@ impl ByteStream for RequestBodyReadTimeout {
/// Wrap an incoming request body with [`RequestBodyReadTimeout`] unless the
/// feature is disabled (`timeout == 0`), in which case the body is returned
/// untouched. `remaining_length` is preserved via [`StreamingBlob::new`].
fn guard_put_object_body_read_timeout(
pub(crate) fn guard_put_object_body_read_timeout(
body: StreamingBlob,
bucket: &str,
key: &str,
+136
View File
@@ -53,6 +53,7 @@ const EVENT_SESSION_TOKEN_EXTRACTION: &str = "session_token_extraction";
/// RustFS-specific query capability for a single presigned PutObject request.
pub(crate) const RUSTFS_MAX_CONTENT_LENGTH_QUERY: &str = "x-rustfs-max-content-length";
pub(crate) const RUSTFS_MAX_TOTAL_OBJECT_SIZE_QUERY: &str = "x-rustfs-max-total-object-size";
/// Inserted by the S3 access boundary after the upstream verifier accepts a
/// request as SigV4 presigned. Downstream capability parsing must require this
@@ -60,6 +61,9 @@ pub(crate) const RUSTFS_MAX_CONTENT_LENGTH_QUERY: &str = "x-rustfs-max-content-l
#[derive(Debug, Clone, Copy)]
pub(crate) struct VerifiedPresignedRequest;
#[derive(Debug, Clone, Copy)]
pub(crate) struct VerifiedSigV4Request;
/// Performs constant-time string comparison to prevent timing attacks.
///
/// This function should be used when comparing sensitive values like passwords,
@@ -1111,6 +1115,92 @@ pub(crate) fn reject_presigned_put_max_content_length_for_other_operation(
Ok(())
}
/// Parse the V2 multipart total-size capability after SigV4 authentication.
/// Header-authenticated CreateMultipartUpload requests are accepted because the
/// custom query is covered by the SigV4 canonical request; later multipart
/// operations read the immutable value from the upload session metadata.
pub(crate) fn parse_presigned_multipart_max_total_object_size(
header: &HeaderMap,
query: Option<&str>,
verified_sigv4: bool,
) -> S3Result<Option<u64>> {
let Some(query) = query else {
return Ok(None);
};
let mut value = None;
let mut decoded_query = Vec::new();
for (name, candidate) in form_urlencoded::parse(query.as_bytes()) {
decoded_query.push((name.to_string(), candidate.to_string()));
if name == RUSTFS_MAX_TOTAL_OBJECT_SIZE_QUERY {
if value.is_some() {
return Err(S3Error::with_message(
S3ErrorCode::InvalidRequest,
format!("{RUSTFS_MAX_TOTAL_OBJECT_SIZE_QUERY} must appear exactly once"),
));
}
value = Some(candidate.into_owned());
} else if name.eq_ignore_ascii_case(RUSTFS_MAX_TOTAL_OBJECT_SIZE_QUERY) {
return Err(S3Error::with_message(
S3ErrorCode::InvalidRequest,
format!("query parameter name must be exactly {RUSTFS_MAX_TOTAL_OBJECT_SIZE_QUERY}"),
));
}
}
let Some(value) = value else {
return Ok(None);
};
let auth_type = get_request_auth_type_with_query(header, Some(query));
let is_presigned = matches!(auth_type, AuthType::Presigned);
let is_header_signed = matches!(auth_type, AuthType::Signed);
let complete_presigned_query = [
("x-amz-algorithm", "AWS4-HMAC-SHA256"),
("x-amz-date", ""),
("x-amz-expires", ""),
("x-amz-signedheaders", ""),
("x-amz-credential", ""),
("x-amz-signature", ""),
]
.into_iter()
.all(|(name, expected)| {
decoded_query
.iter()
.find(|(candidate, _)| candidate.eq_ignore_ascii_case(name))
.is_some_and(|(_, candidate)| !candidate.is_empty() && (expected.is_empty() || candidate == expected))
});
let authenticated = verified_sigv4 && (is_header_signed || (is_presigned && complete_presigned_query));
if !authenticated {
return Err(S3Error::with_message(
S3ErrorCode::InvalidRequest,
format!("{RUSTFS_MAX_TOTAL_OBJECT_SIZE_QUERY} requires a verified SigV4 request"),
));
}
value.parse::<u64>().map(Some).map_err(|_| {
S3Error::with_message(
S3ErrorCode::InvalidRequest,
format!("{RUSTFS_MAX_TOTAL_OBJECT_SIZE_QUERY} must be a non-negative 64-bit integer"),
)
})
}
pub(crate) fn reject_presigned_multipart_max_total_object_size_for_other_operation(
header: &HeaderMap,
query: Option<&str>,
verified_sigv4: bool,
) -> S3Result<()> {
if parse_presigned_multipart_max_total_object_size(header, query, verified_sigv4)?.is_some() {
return Err(S3Error::with_message(
S3ErrorCode::InvalidRequest,
format!("{RUSTFS_MAX_TOTAL_OBJECT_SIZE_QUERY} is only supported for CreateMultipartUpload"),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1816,6 +1906,52 @@ mod tests {
}
}
#[test]
fn multipart_max_total_object_size_requires_signed_create_request() {
let headers = HeaderMap::new();
let signed_prefix = "X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20260827T000000Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host&X-Amz-Credential=test/20260827/us-east-1/s3/aws4_request&X-Amz-Signature=signature";
let query = format!("{signed_prefix}&x-rustfs-max-total-object-size=104857600");
assert_eq!(
parse_presigned_multipart_max_total_object_size(&headers, Some(&query), true).unwrap(),
Some(104_857_600)
);
assert_eq!(
reject_presigned_multipart_max_total_object_size_for_other_operation(&headers, Some(&query), true)
.unwrap_err()
.code(),
&S3ErrorCode::InvalidRequest
);
}
#[test]
fn multipart_max_total_object_size_rejects_tampering_and_invalid_values() {
let headers = HeaderMap::new();
let signed_prefix = "X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20260827T000000Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host&X-Amz-Credential=test/20260827/us-east-1/s3/aws4_request&X-Amz-Signature=signature";
for query in [
"x-rustfs-max-total-object-size=1",
"X-RustFS-Max-Total-Object-Size=1",
"X-Amz-Algorithm=AWS4-HMAC-SHA256&x-rustfs-max-total-object-size=1&x-rustfs-max-total-object-size=2",
"X-Amz-Algorithm=AWS4-HMAC-SHA256&x-rustfs-max-total-object-size=-1",
"X-Amz-Algorithm=AWS4-HMAC-SHA256&x-rustfs-max-total-object-size=18446744073709551616",
] {
assert_eq!(
parse_presigned_multipart_max_total_object_size(&headers, Some(query), true)
.unwrap_err()
.code(),
&S3ErrorCode::InvalidRequest
);
}
let forged = format!("{signed_prefix}&x-rustfs-max-total-object-size=1");
assert_eq!(
parse_presigned_multipart_max_total_object_size(&headers, Some(&forged), false)
.unwrap_err()
.code(),
&S3ErrorCode::InvalidRequest
);
}
#[test]
fn test_credentials_is_expired() {
let mut cred = create_test_credentials();
+1
View File
@@ -372,6 +372,7 @@ impl From<StorageError> for ApiError {
StorageError::ObjectExistsAsDirectory(_, _) => S3ErrorCode::InvalidArgument,
StorageError::InvalidPart(_, _, _) => S3ErrorCode::InvalidPart,
StorageError::EntityTooSmall(_, _, _) => S3ErrorCode::EntityTooSmall,
StorageError::EntityTooLarge(_, _) => S3ErrorCode::EntityTooLarge,
StorageError::PreconditionFailed => S3ErrorCode::PreconditionFailed,
StorageError::NotModified => S3ErrorCode::NotModified,
StorageError::InvalidRangeSpec(_) => S3ErrorCode::InvalidRange,
+18 -4
View File
@@ -16,9 +16,10 @@ use super::ObjectOptions;
use super::ecfs::FS;
use super::{ECStore, PolicySys, ReplicationStatusType, StorageError, get_lock_acquire_timeout, is_err_bucket_not_found};
use crate::auth::{
AuthType, RUSTFS_MAX_CONTENT_LENGTH_QUERY, VerifiedPresignedRequest, check_key_valid_with_context,
get_condition_values_with_client_info, get_condition_values_with_query_and_client_info, get_request_auth_type_with_query,
get_session_token, parse_presigned_put_max_content_length,
AuthType, RUSTFS_MAX_CONTENT_LENGTH_QUERY, RUSTFS_MAX_TOTAL_OBJECT_SIZE_QUERY, VerifiedPresignedRequest,
VerifiedSigV4Request, check_key_valid_with_context, get_condition_values_with_client_info,
get_condition_values_with_query_and_client_info, get_request_auth_type_with_query, get_session_token,
parse_presigned_multipart_max_total_object_size, parse_presigned_put_max_content_length,
};
use crate::error::ApiError;
use crate::license::license_check;
@@ -1771,7 +1772,9 @@ impl S3Access for FS {
// Publish this server's context slot so downstream data-plane handlers
// resolve the same store (backlog#1052 S6).
let verified_presigned = matches!(get_request_auth_type_with_query(cx.headers(), cx.uri().query()), AuthType::Presigned);
let auth_type = get_request_auth_type_with_query(cx.headers(), cx.uri().query());
let verified_presigned = matches!(auth_type, AuthType::Presigned);
let verified_sigv4 = matches!(auth_type, AuthType::Presigned | AuthType::Signed);
{
let ext = cx.extensions_mut();
ext.insert(self.server_ctx().clone());
@@ -1779,6 +1782,9 @@ impl S3Access for FS {
if verified_presigned {
ext.insert(VerifiedPresignedRequest);
}
if verified_sigv4 {
ext.insert(VerifiedSigV4Request);
}
}
// The size capability is intentionally scoped to the single-object
@@ -1793,6 +1799,14 @@ impl S3Access for FS {
format!("{RUSTFS_MAX_CONTENT_LENGTH_QUERY} is only supported for presigned PutObject"),
));
}
if parse_presigned_multipart_max_total_object_size(cx.headers(), cx.uri().query(), verified_sigv4)?.is_some()
&& cx.s3_op().name() != "CreateMultipartUpload"
{
return Err(S3Error::with_message(
S3ErrorCode::InvalidRequest,
format!("{RUSTFS_MAX_TOTAL_OBJECT_SIZE_QUERY} is only supported for CreateMultipartUpload"),
));
}
license_check().map_err(|er| match er.kind() {
std::io::ErrorKind::PermissionDenied => s3_error!(AccessDenied, "{er}"),
_ => {