From e157a88f09b8670640b577d7ca4ae2a97fb9915f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=89=E6=AD=A3=E8=B6=85?= Date: Mon, 2 Mar 2026 15:46:50 +0800 Subject: [PATCH] fix: support query-only presigned URL access (#2046) --- rustfs/src/app/multipart_usecase.rs | 5 +- rustfs/src/app/object_usecase.rs | 6 +- rustfs/src/auth.rs | 98 ++++++++++++++++++++++++++--- rustfs/src/storage/access.rs | 14 ++++- rustfs/src/storage/options.rs | 83 ++++++++++++++++-------- 5 files changed, 165 insertions(+), 41 deletions(-) diff --git a/rustfs/src/app/multipart_usecase.rs b/rustfs/src/app/multipart_usecase.rs index de96c0110..f2a8d104f 100644 --- a/rustfs/src/app/multipart_usecase.rs +++ b/rustfs/src/app/multipart_usecase.rs @@ -21,7 +21,8 @@ use crate::storage::ecfs::RUSTFS_OWNER; use crate::storage::entity; use crate::storage::helper::OperationHelper; use crate::storage::options::{ - copy_src_opts, extract_metadata, get_complete_multipart_upload_opts, get_content_sha256, parse_copy_source_range, put_opts, + copy_src_opts, extract_metadata, get_complete_multipart_upload_opts, get_content_sha256_with_query, parse_copy_source_range, + put_opts, }; use crate::storage::*; use bytes::Bytes; @@ -615,7 +616,7 @@ impl DefaultMultipartUsecase { None }; - let mut sha256hex = get_content_sha256(&req.headers); + let mut sha256hex = get_content_sha256_with_query(&req.headers, req.uri.query()); if is_compressible { let mut hrd = HashReader::new(reader, size, actual_size, md5hex, sha256hex, false).map_err(ApiError::from)?; diff --git a/rustfs/src/app/object_usecase.rs b/rustfs/src/app/object_usecase.rs index 535f38132..68fd8a6d4 100644 --- a/rustfs/src/app/object_usecase.rs +++ b/rustfs/src/app/object_usecase.rs @@ -26,7 +26,7 @@ use crate::storage::head_prefix::{head_prefix_not_found_message, probe_prefix_ha use crate::storage::helper::OperationHelper; use crate::storage::options::{ copy_dst_opts, copy_src_opts, del_opts, extract_metadata, extract_metadata_from_mime_with_object_name, - filter_object_metadata, get_content_sha256, get_opts, put_opts, + filter_object_metadata, get_content_sha256_with_query, get_opts, put_opts, }; use crate::storage::s3_api::multipart::parse_list_parts_params; use crate::storage::s3_api::{restore, select}; @@ -461,7 +461,7 @@ impl DefaultObjectUsecase { None }; - let mut sha256hex = get_content_sha256(&req.headers); + let mut sha256hex = get_content_sha256_with_query(&req.headers, req.uri.query()); if is_compressible(&req.headers, &key) && size > MIN_COMPRESSIBLE_SIZE as i64 { let algorithm = CompressionAlgorithm::default(); @@ -3521,7 +3521,7 @@ impl DefaultObjectUsecase { None }; - let sha256hex = get_content_sha256(&req.headers); + let sha256hex = get_content_sha256_with_query(&req.headers, req.uri.query()); let actual_size = size; let reader: Box = Box::new(WarpReader::new(body)); diff --git a/rustfs/src/auth.rs b/rustfs/src/auth.rs index 18c1d25aa..bc5f3ba0c 100644 --- a/rustfs/src/auth.rs +++ b/rustfs/src/auth.rs @@ -397,6 +397,29 @@ pub fn get_condition_values( version_id: Option<&str>, region: Option, remote_addr: Option, +) -> HashMap> { + get_condition_values_with_query(header, cred, version_id, region, remote_addr, None) +} + +/// Get condition values for policy evaluation with optional query-string values. +/// +/// # Arguments +/// * `header` - HTTP headers of the request +/// * `cred` - User credentials +/// * `version_id` - Optional version ID of the object +/// * `region` - Optional region/location constraint +/// * `remote_addr` - Optional remote address of the connection +/// * `query` - Optional request query string +/// +/// # Returns +/// * `HashMap>` - Condition values for policy evaluation +pub fn get_condition_values_with_query( + header: &HeaderMap, + cred: &Credentials, + version_id: Option<&str>, + region: Option, + remote_addr: Option, + query: Option<&str>, ) -> HashMap> { let username = if cred.is_temp() || cred.is_service_account() { cred.parent_user.clone() @@ -427,8 +450,8 @@ pub fn get_condition_values( // Use provided version ID or empty string let vid = version_id.unwrap_or(""); - // Determine auth type and signature version from headers - let (auth_type, signature_version) = determine_auth_type_and_version(header); + // Determine auth type and signature version from headers and query + let (auth_type, signature_version) = determine_auth_type_and_version_with_query(header, query); // Get TLS status from header let is_tls = header @@ -582,10 +605,16 @@ pub fn get_condition_values( /// # Returns /// * `AuthType` - The determined authentication type /// +#[allow(dead_code)] pub fn get_request_auth_type(header: &HeaderMap) -> AuthType { + get_request_auth_type_with_query(header, None) +} + +#[allow(dead_code)] +pub(crate) fn get_request_auth_type_with_query(header: &HeaderMap, query: Option<&str>) -> AuthType { if is_request_signature_v2(header) { AuthType::SignedV2 - } else if is_request_presigned_signature_v2(header) { + } else if is_request_presigned_signature_v2(header, query) { AuthType::PresignedV2 } else if is_request_sign_streaming_v4(header) { AuthType::StreamingSigned @@ -595,7 +624,7 @@ pub fn get_request_auth_type(header: &HeaderMap) -> AuthType { AuthType::StreamingUnsignedTrailer } else if is_request_signature_v4(header) { AuthType::Signed - } else if is_request_presigned_signature_v4(header) { + } else if is_request_presigned_signature_v4_with_query(header, query) { AuthType::Presigned } else if is_request_jwt(header) { AuthType::JWT @@ -618,8 +647,14 @@ pub fn get_request_auth_type(header: &HeaderMap) -> AuthType { /// # Returns /// * `(String, String)` - Tuple of auth type and signature version /// +#[allow(dead_code)] fn determine_auth_type_and_version(header: &HeaderMap) -> (String, String) { - match get_request_auth_type(header) { + determine_auth_type_and_version_with_query(header, None) +} + +#[allow(dead_code)] +fn determine_auth_type_and_version_with_query(header: &HeaderMap, query: Option<&str>) -> (String, String) { + match get_request_auth_type_with_query(header, query) { AuthType::JWT => ("JWT".to_string(), String::new()), AuthType::SignedV2 => ("REST-HEADER".to_string(), "AWS2".to_string()), AuthType::PresignedV2 => ("REST-QUERY-STRING".to_string(), "AWS2".to_string()), @@ -690,11 +725,18 @@ fn is_request_signature_v2(header: &HeaderMap) -> bool { /// /// # Returns /// * `bool` - True if request has AWS PreSign Version '4', false otherwise +#[allow(dead_code)] pub(crate) fn is_request_presigned_signature_v4(header: &HeaderMap) -> bool { + is_request_presigned_signature_v4_with_query(header, None) +} + +pub(crate) fn is_request_presigned_signature_v4_with_query(header: &HeaderMap, query: Option<&str>) -> bool { if let Some(credential) = header.get(AMZ_CREDENTIAL) { return !credential.to_str().unwrap_or("").is_empty(); } - false + query + .and_then(|query| get_query_param(query, "x-amz-credential")) + .is_some_and(|credential| !credential.is_empty()) } /// Verify request has AWS PreSign Version '2' @@ -704,11 +746,13 @@ pub(crate) fn is_request_presigned_signature_v4(header: &HeaderMap) -> bool { /// /// # Returns /// * `bool` - True if request has AWS PreSign Version '2', false otherwise -fn is_request_presigned_signature_v2(header: &HeaderMap) -> bool { +fn is_request_presigned_signature_v2(header: &HeaderMap, query: Option<&str>) -> bool { if let Some(access_key) = header.get(AMZ_ACCESS_KEY_ID) { return !access_key.to_str().unwrap_or("").is_empty(); } - false + query + .and_then(|query| get_query_param(query, "awsaccesskeyid")) + .is_some_and(|access_key| !access_key.is_empty()) } /// Verify if request has AWS Post policy Signature Version '4' @@ -1016,6 +1060,20 @@ mod tests { assert_eq!(conditions.get("principaltype"), Some(&vec!["User".to_string()])); } + #[test] + fn test_get_condition_values_with_presigned_query() { + let cred = create_test_credentials(); + let headers = HeaderMap::new(); + let uri: Uri = "https://example.com/?X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request" + .parse() + .unwrap(); + + let conditions = get_condition_values_with_query(&headers, &cred, None, None, None, uri.query()); + + assert_eq!(conditions.get("signatureversion"), Some(&vec!["AWS4-HMAC-SHA256".to_string()])); + assert_eq!(conditions.get("authType"), Some(&vec!["REST-QUERY-STRING".to_string()])); + } + #[test] fn test_get_condition_values_temp_user() { let cred = create_temp_credentials(); @@ -1284,6 +1342,18 @@ mod tests { assert_eq!(auth_type, AuthType::PresignedV2); } + #[test] + fn test_get_request_auth_type_presigned_v2_from_query() { + let headers = HeaderMap::new(); + let uri: Uri = "https://example.com/?AWSAccessKeyId=AKIAIOSFODNN7EXAMPLE&Signature=example&Expires=1672531200" + .parse() + .unwrap(); + + let auth_type = get_request_auth_type_with_query(&headers, uri.query()); + + assert_eq!(auth_type, AuthType::PresignedV2); + } + #[test] fn test_get_request_auth_type_presigned_v4() { let mut headers = HeaderMap::new(); @@ -1297,6 +1367,18 @@ mod tests { assert_eq!(auth_type, AuthType::Presigned); } + #[test] + fn test_get_request_auth_type_presigned_v4_from_query() { + let headers = HeaderMap::new(); + let uri: Uri = "https://example.com/?X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request" + .parse() + .unwrap(); + + let auth_type = get_request_auth_type_with_query(&headers, uri.query()); + + assert_eq!(auth_type, AuthType::Presigned); + } + #[test] fn test_get_request_auth_type_post_policy() { let mut headers = HeaderMap::new(); diff --git a/rustfs/src/storage/access.rs b/rustfs/src/storage/access.rs index fc4f2cf1e..fdae2ec1c 100644 --- a/rustfs/src/storage/access.rs +++ b/rustfs/src/storage/access.rs @@ -19,7 +19,7 @@ use super::ecfs::{ stored_acl_from_canned_object, }; use super::options::get_opts; -use crate::auth::{check_key_valid, get_condition_values, get_session_token}; +use crate::auth::{check_key_valid, get_condition_values_with_query, get_session_token}; use crate::error::ApiError; use crate::license::license_check; use crate::server::RemoteAddr; @@ -276,7 +276,14 @@ pub async fn authorize_request(req: &mut S3Request, action: Action) -> S3R let default_claims = HashMap::new(); let claims = cred.claims.as_ref().unwrap_or(&default_claims); - let mut conditions = get_condition_values(&req.headers, cred, req_info.version_id.as_deref(), None, remote_addr); + let mut conditions = get_condition_values_with_query( + &req.headers, + cred, + req_info.version_id.as_deref(), + None, + remote_addr, + req.uri.query(), + ); // Merge object tag conditions; extend existing values if the same key exists (e.g. from get_condition_values). if let Some(ref tags) = object_tag_conditions { for (k, v) in &tags.0 { @@ -425,12 +432,13 @@ pub async fn authorize_request(req: &mut S3Request, action: Action) -> S3R } } } else { - let mut conditions = get_condition_values( + let mut conditions = get_condition_values_with_query( &req.headers, &rustfs_credentials::Credentials::default(), req_info.version_id.as_deref(), req.region.clone(), remote_addr, + req.uri.query(), ); // Merge object tag conditions; extend existing values if the same key exists. if let Some(ref tags) = object_tag_conditions { diff --git a/rustfs/src/storage/options.rs b/rustfs/src/storage/options.rs index 12c98e6e5..a52c7eeda 100644 --- a/rustfs/src/storage/options.rs +++ b/rustfs/src/storage/options.rs @@ -44,8 +44,9 @@ use tracing::error; use uuid::Uuid; use crate::auth::AuthType; -use crate::auth::get_request_auth_type; -use crate::auth::is_request_presigned_signature_v4; +use crate::auth::get_query_param; +use crate::auth::get_request_auth_type_with_query; +use crate::auth::is_request_presigned_signature_v4_with_query; const REPLICATION_REQUEST_TRUE: HeaderValue = HeaderValue::from_static("true"); @@ -580,13 +581,18 @@ pub fn parse_copy_source_range(range_str: &str) -> S3Result { } } +#[allow(dead_code)] pub(crate) fn get_content_sha256(headers: &HeaderMap) -> Option { - match get_request_auth_type(headers) { + get_content_sha256_with_query(headers, None) +} + +pub(crate) fn get_content_sha256_with_query(headers: &HeaderMap, query: Option<&str>) -> Option { + match get_request_auth_type_with_query(headers, query) { AuthType::Presigned | AuthType::Signed => { - if skip_content_sha256_cksum(headers) { + if skip_content_sha256_cksum_with_query(headers, query) { None } else { - Some(get_content_sha256_cksum(headers, ServiceType::S3)) + Some(get_content_sha256_cksum_with_query(headers, query, ServiceType::S3)) } } _ => None, @@ -595,24 +601,21 @@ pub(crate) fn get_content_sha256(headers: &HeaderMap) -> Option) -> bool { - let content_sha256 = if is_request_presigned_signature_v4(headers) { - // For presigned requests, check query params first, then headers - // Note: In a real implementation, you would need to check query parameters - // For now, we'll just check headers - headers.get(AMZ_CONTENT_SHA256) - } else { - headers.get(AMZ_CONTENT_SHA256) - }; + skip_content_sha256_cksum_with_query(headers, None) +} - // Skip if no header was set +fn skip_content_sha256_cksum_with_query(headers: &HeaderMap, query: Option<&str>) -> bool { + let include_query_values = matches!(get_request_auth_type_with_query(headers, query), AuthType::Presigned); + let content_sha256 = get_content_sha256_value(headers, query, include_query_values); + + // Skip if no checksum value was set in header/query for query-presigned requests. let Some(header_value) = content_sha256 else { return true; }; - let Ok(value) = header_value.to_str() else { - return true; - }; + let value = header_value; // If x-amz-content-sha256 is set and the value is not // 'UNSIGNED-PAYLOAD' we should validate the content sha256. @@ -643,7 +646,11 @@ fn skip_content_sha256_cksum(headers: &HeaderMap) -> bool { } /// Returns SHA256 for calculating canonical-request. -fn get_content_sha256_cksum(headers: &HeaderMap, service_type: ServiceType) -> String { +fn get_content_sha256_cksum_with_query( + headers: &HeaderMap, + query: Option<&str>, + service_type: ServiceType, +) -> String { if service_type == ServiceType::STS { // For STS requests, we would need to read the body and calculate SHA256 // This is a simplified implementation - in practice you'd need access to the request body @@ -651,28 +658,54 @@ fn get_content_sha256_cksum(headers: &HeaderMap, service_type: Serv return "sts-body-sha256-placeholder".to_string(); } - let (default_sha256_cksum, content_sha256) = if is_request_presigned_signature_v4(headers) { + let (default_sha256_cksum, content_sha256) = if is_request_presigned_signature_v4_with_query(headers, query) { // For a presigned request we look at the query param for sha256. // X-Amz-Content-Sha256, if not set in presigned requests, checksum // will default to 'UNSIGNED-PAYLOAD'. - (UNSIGNED_PAYLOAD.to_string(), headers.get(AMZ_CONTENT_SHA256)) + (UNSIGNED_PAYLOAD.to_string(), get_content_sha256_value(headers, query, true)) } else { // X-Amz-Content-Sha256, if not set in signed requests, checksum // will default to sha256([]byte("")). - (EMPTY_STRING_SHA256_HASH.to_string(), headers.get(AMZ_CONTENT_SHA256)) + ( + EMPTY_STRING_SHA256_HASH.to_string(), + headers + .get(AMZ_CONTENT_SHA256) + .and_then(|v| v.to_str().ok().map(str::to_owned)), + ) }; // We found 'X-Amz-Content-Sha256' return the captured value. - if let Some(header_value) = content_sha256 - && let Ok(value) = header_value.to_str() - { - return value.to_string(); + if let Some(header_value) = content_sha256 { + return header_value; } // We couldn't find 'X-Amz-Content-Sha256'. default_sha256_cksum } +fn get_content_sha256_value( + headers: &HeaderMap, + query: Option<&str>, + include_query_for_presigned: bool, +) -> Option { + if include_query_for_presigned && is_request_presigned_signature_v4_with_query(headers, query) { + return query + .and_then(|q| get_query_param(q, "x-amz-content-sha256")) + .or_else(|| headers.get(AMZ_CONTENT_SHA256).and_then(|v| v.to_str().ok())) + .map(str::to_owned); + } + + headers + .get(AMZ_CONTENT_SHA256) + .and_then(|v| v.to_str().ok()) + .map(str::to_owned) +} + +#[allow(dead_code)] +fn get_content_sha256_cksum(headers: &HeaderMap, service_type: ServiceType) -> String { + get_content_sha256_cksum_with_query(headers, None, service_type) +} + #[cfg(test)] mod tests { use super::*;