diff --git a/crates/ecstore/src/bucket/target/bucket_target.rs b/crates/ecstore/src/bucket/target/bucket_target.rs index 4c0a257a9..cc96b7c5b 100644 --- a/crates/ecstore/src/bucket/target/bucket_target.rs +++ b/crates/ecstore/src/bucket/target/bucket_target.rs @@ -530,6 +530,47 @@ mod tests { assert!(redacted_json.contains(r#""session_token":null"#)); } + #[test] + fn historical_bucket_target_options_remain_readable() { + let target: BucketTarget = serde_json::from_value(serde_json::json!({ + "endpoint": "legacy.example:9000", + "credentials": { + "accessKey": "legacy-access", + "secretKey": "legacy-secret", + "session_token": "legacy-session-token", + "expiration": "2024-12-31T23:59:59Z" + }, + "targetbucket": "legacy-bucket", + "api": "s3v2", + "healthCheckDuration": 30, + "disableProxy": true, + "edge": true, + "edgeSyncBeforeExpiry": true, + "type": "replication" + })) + .expect("historical remote target should remain readable"); + + assert_eq!(target.api, "s3v2"); + assert_eq!(target.health_check_duration, Duration::from_secs(30)); + assert!(target.disable_proxy); + assert!(target.edge); + assert!(target.edge_sync_before_expiry); + assert_eq!( + target + .credentials + .as_ref() + .and_then(|credentials| credentials.session_token.as_deref()), + Some("legacy-session-token") + ); + assert!( + target + .credentials + .as_ref() + .and_then(|credentials| credentials.expiration) + .is_some() + ); + } + #[test] fn test_bucket_target_type_json_deserialize() { // Test BucketTargetType JSON deserialization diff --git a/rustfs/src/admin/handlers/replication.rs b/rustfs/src/admin/handlers/replication.rs index 8f71bdec6..27f85d764 100644 --- a/rustfs/src/admin/handlers/replication.rs +++ b/rustfs/src/admin/handlers/replication.rs @@ -50,6 +50,8 @@ use time::OffsetDateTime; use tracing::{debug, error, info, warn}; use url::Host; +const SUPPORTED_REMOTE_TARGET_API: &str = "s3v4"; + fn extract_query_params(uri: &Uri) -> HashMap { let mut params = HashMap::new(); @@ -78,7 +80,7 @@ fn map_bucket_target_error(err: BucketTargetError) -> S3Error { } } -#[derive(Debug, Deserialize)] +#[derive(Deserialize)] #[serde(deny_unknown_fields)] struct RemoteTargetCredentialsRequest { #[serde(rename = "accessKey")] @@ -100,7 +102,7 @@ impl From for TargetCredentials { } } -#[derive(Debug, Deserialize)] +#[derive(Deserialize)] #[serde(deny_unknown_fields)] struct RemoteTargetRequest { #[serde(rename = "sourcebucket", default)] @@ -179,6 +181,32 @@ impl RemoteTargetRequest { return Err(s3_error!(InvalidRequest, "credentials.secretKey is required")); } + if self + .credentials + .session_token + .as_deref() + .is_some_and(|token| !token.trim().is_empty()) + { + return Err(s3_error!( + InvalidRequest, + "remote target field credentials.session_token is not supported by this RustFS version" + )); + } + + if self.credentials.expiration.is_some() { + return Err(s3_error!( + InvalidRequest, + "remote target field credentials.expiration is not supported by this RustFS version" + )); + } + + if !self.api.is_empty() && self.api != SUPPORTED_REMOTE_TARGET_API { + return Err(s3_error!( + InvalidRequest, + "remote target field api value is not supported by this RustFS version" + )); + } + for (unsupported, configured) in [ ("disableProxy", self.disable_proxy), ("healthCheckDuration", self.health_check_duration != 0), @@ -1021,7 +1049,7 @@ impl Operation for ReplicationMrfHandler { #[cfg(test)] mod tests { use super::{ - RemoteTargetRequest, build_mrf_response, extract_query_params, unique_replication_peers, + RemoteTargetRequest, SUPPORTED_REMOTE_TARGET_API, build_mrf_response, extract_query_params, unique_replication_peers, validate_remote_target_tls_settings, }; use crate::admin::storage_api::bucket::target::BucketTarget; @@ -1276,8 +1304,10 @@ mod tests { let mut request = valid_remote_target_request(); request["unexpected"] = serde_json::json!(true); - let err = serde_json::from_value::(request) - .expect_err("remote target request should reject unknown fields"); + let err = match serde_json::from_value::(request) { + Ok(_) => panic!("remote target request should reject unknown fields"), + Err(err) => err, + }; assert!(err.to_string().contains("unknown field")); } @@ -1290,8 +1320,10 @@ mod tests { .expect("request should be an object") .remove("credentials"); - let err = - serde_json::from_value::(request).expect_err("remote target request should require credentials"); + let err = match serde_json::from_value::(request) { + Ok(_) => panic!("remote target request should require credentials"), + Err(err) => err, + }; assert!(err.to_string().contains("missing field")); } @@ -1314,13 +1346,20 @@ mod tests { #[test] fn remote_target_request_rejects_unimplemented_fields() { for (field, value) in [ + ("credentials.session_token", serde_json::json!("session-token")), + ("credentials.expiration", serde_json::json!("2026-01-01T00:00:00Z")), + ("api", serde_json::json!("s3v2")), ("disableProxy", serde_json::json!(true)), ("healthCheckDuration", serde_json::json!(5)), ("edge", serde_json::json!(true)), ("edgeSyncBeforeExpiry", serde_json::json!(true)), ] { let mut request = valid_remote_target_request(); - request[field] = value; + if let Some((credential_field, credential_name)) = field.split_once('.') { + request[credential_field][credential_name] = value; + } else { + request[field] = value; + } let request: RemoteTargetRequest = serde_json::from_value(request).expect("unsupported field should still deserialize"); let err = request @@ -1332,6 +1371,46 @@ mod tests { } } + #[test] + fn remote_target_request_accepts_static_credentials_and_supported_api() { + let mut request = valid_remote_target_request(); + request["api"] = serde_json::json!("s3v4"); + request["secure"] = serde_json::json!(true); + request["caCertPem"] = serde_json::json!("-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n"); + request["credentials"]["session_token"] = serde_json::json!(""); + + let target = serde_json::from_value::(request) + .expect("supported remote target request should deserialize") + .into_bucket_target() + .expect("static credentials, SigV4 and custom CA should remain supported"); + + assert_eq!(target.api, SUPPORTED_REMOTE_TARGET_API); + assert_eq!( + target + .credentials + .as_ref() + .and_then(|credentials| credentials.session_token.as_deref()), + Some("") + ); + assert!(!target.ca_cert_pem.is_empty()); + } + + #[test] + fn remote_target_request_validation_does_not_echo_credential_values() { + let mut request = valid_remote_target_request(); + request["credentials"]["session_token"] = serde_json::json!("session-token-must-not-leak"); + + let request: RemoteTargetRequest = serde_json::from_value(request).expect("request should deserialize"); + let err = request + .into_bucket_target() + .expect_err("session tokens must be rejected before persistence"); + let message = err.to_string(); + + assert!(message.contains("credentials.session_token")); + assert!(!message.contains("session-token-must-not-leak")); + assert!(!message.contains("secret")); + } + #[test] fn remote_target_request_converts_to_bucket_target() { let target = serde_json::from_value::(valid_remote_target_request())