fix(replication): reject unsupported target options (#5622)

This commit is contained in:
cxymds
2026-08-02 19:28:30 +08:00
committed by GitHub
parent 921ddef2c7
commit 885096d1de
2 changed files with 128 additions and 8 deletions
@@ -530,6 +530,47 @@ mod tests {
assert!(redacted_json.contains(r#""session_token":null"#)); 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] #[test]
fn test_bucket_target_type_json_deserialize() { fn test_bucket_target_type_json_deserialize() {
// Test BucketTargetType JSON deserialization // Test BucketTargetType JSON deserialization
+87 -8
View File
@@ -50,6 +50,8 @@ use time::OffsetDateTime;
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use url::Host; use url::Host;
const SUPPORTED_REMOTE_TARGET_API: &str = "s3v4";
fn extract_query_params(uri: &Uri) -> HashMap<String, String> { fn extract_query_params(uri: &Uri) -> HashMap<String, String> {
let mut params = HashMap::new(); 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)] #[serde(deny_unknown_fields)]
struct RemoteTargetCredentialsRequest { struct RemoteTargetCredentialsRequest {
#[serde(rename = "accessKey")] #[serde(rename = "accessKey")]
@@ -100,7 +102,7 @@ impl From<RemoteTargetCredentialsRequest> for TargetCredentials {
} }
} }
#[derive(Debug, Deserialize)] #[derive(Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct RemoteTargetRequest { struct RemoteTargetRequest {
#[serde(rename = "sourcebucket", default)] #[serde(rename = "sourcebucket", default)]
@@ -179,6 +181,32 @@ impl RemoteTargetRequest {
return Err(s3_error!(InvalidRequest, "credentials.secretKey is required")); 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 [ for (unsupported, configured) in [
("disableProxy", self.disable_proxy), ("disableProxy", self.disable_proxy),
("healthCheckDuration", self.health_check_duration != 0), ("healthCheckDuration", self.health_check_duration != 0),
@@ -1021,7 +1049,7 @@ impl Operation for ReplicationMrfHandler {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ 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, validate_remote_target_tls_settings,
}; };
use crate::admin::storage_api::bucket::target::BucketTarget; use crate::admin::storage_api::bucket::target::BucketTarget;
@@ -1276,8 +1304,10 @@ mod tests {
let mut request = valid_remote_target_request(); let mut request = valid_remote_target_request();
request["unexpected"] = serde_json::json!(true); request["unexpected"] = serde_json::json!(true);
let err = serde_json::from_value::<RemoteTargetRequest>(request) let err = match serde_json::from_value::<RemoteTargetRequest>(request) {
.expect_err("remote target request should reject unknown fields"); Ok(_) => panic!("remote target request should reject unknown fields"),
Err(err) => err,
};
assert!(err.to_string().contains("unknown field")); assert!(err.to_string().contains("unknown field"));
} }
@@ -1290,8 +1320,10 @@ mod tests {
.expect("request should be an object") .expect("request should be an object")
.remove("credentials"); .remove("credentials");
let err = let err = match serde_json::from_value::<RemoteTargetRequest>(request) {
serde_json::from_value::<RemoteTargetRequest>(request).expect_err("remote target request should require credentials"); Ok(_) => panic!("remote target request should require credentials"),
Err(err) => err,
};
assert!(err.to_string().contains("missing field")); assert!(err.to_string().contains("missing field"));
} }
@@ -1314,13 +1346,20 @@ mod tests {
#[test] #[test]
fn remote_target_request_rejects_unimplemented_fields() { fn remote_target_request_rejects_unimplemented_fields() {
for (field, value) in [ 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)), ("disableProxy", serde_json::json!(true)),
("healthCheckDuration", serde_json::json!(5)), ("healthCheckDuration", serde_json::json!(5)),
("edge", serde_json::json!(true)), ("edge", serde_json::json!(true)),
("edgeSyncBeforeExpiry", serde_json::json!(true)), ("edgeSyncBeforeExpiry", serde_json::json!(true)),
] { ] {
let mut request = valid_remote_target_request(); 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 = let request: RemoteTargetRequest =
serde_json::from_value(request).expect("unsupported field should still deserialize"); serde_json::from_value(request).expect("unsupported field should still deserialize");
let err = request 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::<RemoteTargetRequest>(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] #[test]
fn remote_target_request_converts_to_bucket_target() { fn remote_target_request_converts_to_bucket_target() {
let target = serde_json::from_value::<RemoteTargetRequest>(valid_remote_target_request()) let target = serde_json::from_value::<RemoteTargetRequest>(valid_remote_target_request())