diff --git a/rustfs/src/admin/handlers/config_admin.rs b/rustfs/src/admin/handlers/config_admin.rs index c9b6bad04..f2e1506dd 100644 --- a/rustfs/src/admin/handlers/config_admin.rs +++ b/rustfs/src/admin/handlers/config_admin.rs @@ -16,7 +16,8 @@ use crate::admin::auth::validate_admin_request; use crate::admin::handlers::supervise_admin_mutation; use crate::admin::router::{AdminOperation, Operation, S3Router}; use crate::admin::runtime_sources::{ - current_app_context, current_object_store_handle_for_context, current_server_config_for_context, publish_server_config, + current_action_credentials, current_app_context, current_object_store_handle_for_context, current_server_config_for_context, + publish_server_config, }; use crate::admin::service::config::{ CONFIG_WORKER_RELOAD_FAILURE_STATE, EVENT_CONFIG_WORKER_RELOAD_FAILED, FULL_CONFIG_WORKER_SUBSYSTEMS, LOG_COMPONENT_ADMIN, @@ -86,6 +87,7 @@ use serde::Serialize; use std::collections::{BTreeSet, HashMap}; use std::env; use std::future::Future; +use std::mem::size_of; use time::OffsetDateTime; use tracing::warn; use uuid::Uuid; @@ -97,6 +99,8 @@ const TEXT_CONTENT_TYPE: &str = "text/plain; charset=utf-8"; const CONFIG_HISTORY_PREFIX: &str = "config/history"; const CONFIG_HISTORY_SUFFIX: &str = ".kv"; const CONFIG_HISTORY_SNAPSHOT_HEADER: &str = "# rustfs-config-history: full-snapshot-v1"; +const CONFIG_HISTORY_SNAPSHOT_ENVELOPE_HEADER: &str = "# rustfs-config-history: snapshot-envelope-v2"; +const CONFIG_HISTORY_ENCRYPTED_HEADER: &str = "# rustfs-config-history: encrypted-full-snapshot-v2"; const CONFIG_APPLIED_HEADER: &str = "x-rustfs-config-applied"; const CONFIG_APPLIED_COMPAT_HEADER: &str = "x-minio-config-applied"; const CONFIG_APPLIED_TRUE: &str = "true"; @@ -166,6 +170,16 @@ struct ConfigHistoryEntry { create_time: OffsetDateTime, #[serde(rename = "Data", skip_serializing_if = "Option::is_none")] data: Option, + #[serde(rename = "DataRedacted", skip_serializing_if = "Option::is_none")] + data_redacted: Option, +} + +impl ConfigHistoryEntry { + fn set_redacted_data(&mut self, persisted: &[u8]) -> S3Result<()> { + self.data = Some(redact_config_history_data(persisted)?); + self.data_redacted = Some(true); + Ok(()) + } } const STORAGE_CLASS_HELP_KEYS: &[HelpKeyMetadata] = &[ @@ -839,8 +853,8 @@ async fn save_server_config_history(data: &[u8]) -> S3Result { /// this record while holding the server-config write lock, before persisting /// their replacement state. The version marker deliberately makes older /// directive-only entries distinguishable so restore can reject them safely. -fn encode_config_history_snapshot(config: &ServerConfig) -> Vec { - let rendered = render_full_config(config); +fn encode_config_history_recovery(config: &ServerConfig) -> Vec { + let rendered = render_full_config(config, false); let mut snapshot = Vec::with_capacity(CONFIG_HISTORY_SNAPSHOT_HEADER.len() + 1 + rendered.len()); snapshot.extend_from_slice(CONFIG_HISTORY_SNAPSHOT_HEADER.as_bytes()); snapshot.push(b'\n'); @@ -848,8 +862,106 @@ fn encode_config_history_snapshot(config: &ServerConfig) -> Vec { snapshot } +fn encode_config_history_snapshot(config: &ServerConfig) -> S3Result> { + let recovery = encode_config_history_recovery(config); + let display = render_full_config(config, true); + let recovery_len = + u64::try_from(recovery.len()).map_err(|_| s3_error!(InternalError, "config history recovery snapshot is too large"))?; + let mut snapshot = + Vec::with_capacity(CONFIG_HISTORY_SNAPSHOT_ENVELOPE_HEADER.len() + 1 + size_of::() + recovery.len() + display.len()); + snapshot.extend_from_slice(CONFIG_HISTORY_SNAPSHOT_ENVELOPE_HEADER.as_bytes()); + snapshot.push(b'\n'); + snapshot.extend_from_slice(&recovery_len.to_be_bytes()); + snapshot.extend_from_slice(&recovery); + snapshot.extend_from_slice(&display); + Ok(snapshot) +} + +fn split_config_history_snapshot(data: &[u8]) -> S3Result> { + let Some(envelope) = data.strip_prefix(CONFIG_HISTORY_SNAPSHOT_ENVELOPE_HEADER.as_bytes()) else { + return Ok(None); + }; + let envelope = envelope + .strip_prefix(b"\n") + .ok_or_else(|| s3_error!(InvalidRequest, "config history snapshot envelope header is malformed"))?; + let (recovery_len, payload) = envelope + .split_at_checked(size_of::()) + .ok_or_else(|| s3_error!(InvalidRequest, "config history snapshot envelope is truncated"))?; + let recovery_len = u64::from_be_bytes( + recovery_len + .try_into() + .map_err(|_| s3_error!(InvalidRequest, "config history snapshot envelope length is malformed"))?, + ); + let recovery_len = usize::try_from(recovery_len) + .map_err(|_| s3_error!(InvalidRequest, "config history recovery snapshot length is unsupported"))?; + let (recovery, display) = payload + .split_at_checked(recovery_len) + .ok_or_else(|| s3_error!(InvalidRequest, "config history snapshot envelope recovery data is truncated"))?; + Ok(Some((recovery, display))) +} + +fn config_history_encryption_key() -> S3Result> { + if let Some(key) = rustfs_iam::keyring::encrypt_key() { + return Ok(key); + } + + current_action_credentials() + .map(|credentials| credentials.secret_key.into_bytes()) + .filter(|key| !key.is_empty()) + .ok_or_else(|| s3_error!(InternalError, "config history encryption key is not initialized")) +} + +fn config_history_decryption_keys() -> S3Result>> { + let mut keys = rustfs_iam::keyring::decrypt_keys(); + if let Some(key) = current_action_credentials() + .map(|credentials| credentials.secret_key.into_bytes()) + .filter(|key| !key.is_empty()) + { + keys.push(key); + } + if keys.is_empty() { + return Err(s3_error!(InternalError, "config history decryption key is not initialized")); + } + Ok(keys) +} + +fn seal_config_history_snapshot(snapshot: &[u8], key: &[u8]) -> S3Result> { + let ciphertext = rustfs_crypto::encrypt_stream_io(key, snapshot) + .map_err(|err| s3_error!(InternalError, "failed to encrypt config history snapshot: {}", err))?; + let mut sealed = Vec::with_capacity(CONFIG_HISTORY_ENCRYPTED_HEADER.len() + 1 + ciphertext.len()); + sealed.extend_from_slice(CONFIG_HISTORY_ENCRYPTED_HEADER.as_bytes()); + sealed.push(b'\n'); + sealed.extend_from_slice(&ciphertext); + Ok(sealed) +} + +fn open_config_history_data_with_keys(data: &[u8], keys: &[Vec]) -> S3Result> { + let Some(ciphertext) = data.strip_prefix(CONFIG_HISTORY_ENCRYPTED_HEADER.as_bytes()) else { + return Ok(data.to_vec()); + }; + let ciphertext = ciphertext + .strip_prefix(b"\n") + .ok_or_else(|| s3_error!(InvalidRequest, "encrypted config history header is malformed"))?; + + for key in keys { + if let Ok(plaintext) = rustfs_crypto::decrypt_stream_io(key, ciphertext) { + return Ok(plaintext); + } + } + Err(s3_error!(InvalidRequest, "config history snapshot cannot be decrypted")) +} + +fn open_config_history_data(data: &[u8]) -> S3Result> { + if data.starts_with(CONFIG_HISTORY_ENCRYPTED_HEADER.as_bytes()) { + return open_config_history_data_with_keys(data, &config_history_decryption_keys()?); + } + Ok(data.to_vec()) +} + fn decode_config_history_snapshot(data: &[u8]) -> S3Result { - let text = std::str::from_utf8(data).map_err(|_| s3_error!(InvalidRequest, "config history entry is not valid UTF-8"))?; + let data = open_config_history_data(data)?; + let recovery = split_config_history_snapshot(&data)?.map_or(data.as_slice(), |(recovery, _)| recovery); + let text = std::str::from_utf8(recovery).map_err(|_| s3_error!(InvalidRequest, "config history entry is not valid UTF-8"))?; let Some(snapshot) = text.strip_prefix(CONFIG_HISTORY_SNAPSHOT_HEADER) else { return Err(s3_error!( InvalidRequest, @@ -879,7 +991,9 @@ fn validate_restore_rollback_generation(current: &ServerConfig, restored: &Serve } async fn save_server_config_history_snapshot(config: &ServerConfig) -> S3Result { - save_server_config_history(&encode_config_history_snapshot(config)).await + let snapshot = encode_config_history_snapshot(config)?; + let sealed = seal_config_history_snapshot(&snapshot, &config_history_encryption_key()?)?; + save_server_config_history(&sealed).await } async fn cleanup_failed_config_history_snapshot(restore_id: &str) { @@ -939,25 +1053,11 @@ async fn list_server_config_history(with_data: bool, count: Option) -> S3 continue; }; - let data = if with_data { - Some( - String::from_utf8( - read_admin_config(store.clone(), &object.name) - .await - .map_err(ApiError::from) - .map_err(S3Error::from)?, - ) - .map_err(ApiError::other) - .map_err(S3Error::from)?, - ) - } else { - None - }; - entries.push(ConfigHistoryEntry { restore_id, create_time: object.mod_time.unwrap_or(OffsetDateTime::UNIX_EPOCH), - data, + data: None, + data_redacted: None, }); } @@ -971,7 +1071,17 @@ async fn list_server_config_history(with_data: bool, count: Option) -> S3 } } - Ok(trim_history_entries(entries, count)) + let mut entries = trim_history_entries(entries, count); + if with_data { + for entry in &mut entries { + let persisted = read_admin_config(store.clone(), &history_object_name(&entry.restore_id)) + .await + .map_err(ApiError::from) + .map_err(S3Error::from)?; + entry.set_redacted_data(&persisted)?; + } + } + Ok(entries) } fn normalize_target(target: &str) -> String { @@ -1159,6 +1269,99 @@ fn is_sensitive_key_name(key: &str) -> bool { || normalized.ends_with("_private_key") } +fn is_sensitive_env_key_name(key: &str) -> bool { + if is_sensitive_key_name(key) { + return true; + } + let normalized = format!("_{}_", key.trim().to_ascii_lowercase()); + normalized.contains("_token_") + || normalized.contains("_client_key_") + || normalized.contains("_tls_client_key_") + || normalized.contains("_private_key_") +} + +fn is_sensitive_config_key(sub_system: &str, key: &str) -> bool { + if is_sensitive_key_name(key) { + return true; + } + if DEFAULT_KVS.get().is_none() { + crate::admin::storage_api::config::init_admin_config_defaults(); + } + DEFAULT_KVS + .get() + .and_then(|defaults| defaults.get(sub_system)) + .and_then(|kvs| kvs.0.iter().find(|entry| entry.key == key)) + .is_some_and(|entry| entry.hidden_if_empty) +} + +fn redact_config_history_line(line: &str) -> String { + let trimmed = line.trim(); + if trimmed.is_empty() { + return String::new(); + } + + let (comment_prefix, content) = match trimmed.strip_prefix('#') { + Some(content) => ("# ", content.trim()), + None => ("", trimmed), + }; + let Ok(tokens) = tokenize_config_line(content) else { + return format!("{comment_prefix}{REDACTED_VALUE}"); + }; + let Some(scope) = tokens.first() else { + return String::new(); + }; + + if scope.contains('=') { + let assignments = tokens + .iter() + .map(|token| { + let Some((key, value)) = token.split_once('=') else { + return REDACTED_VALUE.to_string(); + }; + let value = if is_sensitive_env_key_name(key) { + REDACTED_VALUE + } else { + value + }; + format_kv_pair(key, value) + }) + .collect::>(); + return format!("{comment_prefix}{}", assignments.join(" ")); + } + + let sub_system = scope.split_once(':').map_or(scope.as_str(), |(sub_system, _)| sub_system); + let mut redacted = Vec::with_capacity(tokens.len()); + redacted.push(scope.clone()); + for token in tokens.iter().skip(1) { + let Some((key, value)) = token.split_once('=') else { + redacted.push(token.clone()); + continue; + }; + let value = if is_sensitive_config_key(sub_system, key) { + REDACTED_VALUE + } else { + value + }; + redacted.push(format_kv_pair(key, value)); + } + format!("{comment_prefix}{}", redacted.join(" ")) +} + +fn redact_config_history_data(data: &[u8]) -> S3Result { + let plaintext = open_config_history_data(data)?; + if let Some((_, display)) = split_config_history_snapshot(&plaintext)? { + return Ok(std::str::from_utf8(display).unwrap_or(REDACTED_VALUE).to_string()); + } + let Ok(text) = std::str::from_utf8(&plaintext) else { + return Ok(REDACTED_VALUE.to_string()); + }; + let text = text + .strip_prefix(CONFIG_HISTORY_SNAPSHOT_HEADER) + .and_then(|snapshot| snapshot.strip_prefix('\n')) + .unwrap_or(text); + Ok(text.lines().map(redact_config_history_line).collect::>().join("\n")) +} + fn apply_set_directives(config: &mut ServerConfig, directives: &[ConfigDirective]) -> S3Result<()> { for directive in directives { let targets = config.0.entry(directive.sub_system.clone()).or_default(); @@ -1383,7 +1586,7 @@ fn render_selected_config(config: &ServerConfig, selector: &ConfigSelector, reda Ok(lines.join("\n").into_bytes()) } -fn render_full_config(config: &ServerConfig) -> Vec { +fn render_full_config(config: &ServerConfig, redact_secrets: bool) -> Vec { let mut subsystems = config.0.iter().collect::>(); subsystems.sort_by_key(|(lhs, _)| *lhs); @@ -1393,7 +1596,7 @@ fn render_full_config(config: &ServerConfig) -> Vec { sorted_targets.sort_by_key(|(lhs, _)| *lhs); for (target, kvs) in sorted_targets { - if let Some(line) = render_scope_line(sub_system, target, kvs, false) { + if let Some(line) = render_scope_line(sub_system, target, kvs, redact_secrets) { lines.push(line); } } @@ -2071,7 +2274,7 @@ impl Operation for GetConfigHandler { async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { let cred = validate_config_admin_request(&req).await?; let config = load_active_server_config().await?; - let payload = render_full_config(&config); + let payload = render_full_config(&config, false); let (body, content_type) = encode_config_payload(req.uri.path(), &cred.secret_key, payload, TEXT_CONTENT_TYPE)?; response_with_content_type(StatusCode::OK, body, &content_type) } @@ -2128,7 +2331,9 @@ impl Operation for SetConfigHandler { #[cfg(test)] mod tests { use super::*; + use serial_test::serial; use std::sync::{Arc, Mutex}; + use temp_env::with_vars; #[tokio::test] async fn prepared_config_commit_persists_before_publish() { @@ -2309,12 +2514,12 @@ identity_openid config_url="https://issuer.example" client_id="console""#, ) .expect("apply original directives"); - let exported = String::from_utf8(render_full_config(&original)).expect("utf8 export"); + let exported = String::from_utf8(render_full_config(&original, false)).expect("utf8 export"); let mut restored = ServerConfig::new(); apply_set_directives(&mut restored, &parse_config_directives(&exported, false).expect("parse exported config")) .expect("apply restored directives"); - assert_eq!(render_full_config(&original), render_full_config(&restored)); + assert_eq!(render_full_config(&original, false), render_full_config(&restored, false)); } #[test] @@ -2623,25 +2828,269 @@ notify_webhook:primary endpoint="https://hooks.example" auth_token="target-secre ) .expect("apply fixture"); - let encoded = encode_config_history_snapshot(&original); + let encoded = encode_config_history_snapshot(&original).expect("encode snapshot"); let restored = decode_config_history_snapshot(&encoded).expect("decode snapshot"); - assert_eq!(render_full_config(&restored), render_full_config(&original)); - let rendered = String::from_utf8(render_full_config(&restored)).expect("utf8"); + assert_eq!(render_full_config(&restored, false), render_full_config(&original, false)); + let rendered = String::from_utf8(render_full_config(&restored, false)).expect("utf8"); assert!(rendered.contains(r#"client_secret="unrelated-secret""#)); assert!(rendered.contains(r#"notify_webhook:primary"#)); assert!(rendered.contains(r#"auth_token="target-secret""#)); } + #[test] + fn sealed_history_snapshot_hides_secrets_and_rejects_wrong_key() { + crate::admin::storage_api::config::init_admin_config_defaults(); + let mut config = ServerConfig::new(); + apply_set_directives( + &mut config, + &parse_config_directives(r#"identity_openid client_id="client" client_secret="snapshot-canary-secret""#, false) + .expect("parse fixture"), + ) + .expect("apply fixture"); + let snapshot = encode_config_history_snapshot(&config).expect("encode snapshot"); + + let sealed = seal_config_history_snapshot(&snapshot, b"history-test-key").expect("seal snapshot"); + + assert!(sealed.starts_with(CONFIG_HISTORY_ENCRYPTED_HEADER.as_bytes())); + assert!( + !sealed + .windows(b"snapshot-canary-secret".len()) + .any(|window| window == b"snapshot-canary-secret") + ); + let wrong_key_error = + open_config_history_data_with_keys(&sealed, &[b"wrong-key".to_vec()]).expect_err("wrong key must fail closed"); + assert!(!wrong_key_error.to_string().contains("snapshot-canary-secret")); + let opened = open_config_history_data_with_keys(&sealed, &[b"history-test-key".to_vec()]).expect("open snapshot"); + assert_eq!(opened, snapshot); + } + + #[test] + #[serial] + fn history_snapshot_key_rotation_uses_current_and_old_iam_master_keys() { + crate::admin::storage_api::config::init_admin_config_defaults(); + let mut config = ServerConfig::new(); + apply_set_directives( + &mut config, + &parse_config_directives(r#"storage_class standard="EC:2""#, false).expect("parse fixture"), + ) + .expect("apply fixture"); + let snapshot = encode_config_history_snapshot(&config).expect("encode snapshot"); + let sealed = with_vars( + [ + (rustfs_iam::keyring::ENV_IAM_MASTER_KEY, Some("history-master-old")), + (rustfs_iam::keyring::ENV_IAM_MASTER_KEY_OLD_KEYS, None::<&str>), + ], + || { + let key = config_history_encryption_key().expect("current history encryption key"); + seal_config_history_snapshot(&snapshot, &key).expect("seal with current key") + }, + ); + + with_vars( + [ + (rustfs_iam::keyring::ENV_IAM_MASTER_KEY, Some("history-master-new")), + ( + rustfs_iam::keyring::ENV_IAM_MASTER_KEY_OLD_KEYS, + Some("history-master-unused,history-master-old"), + ), + ], + || { + let restored = decode_config_history_snapshot(&sealed).expect("restore with old-key fallback"); + assert_eq!(render_full_config(&restored, false), render_full_config(&config, false)); + }, + ); + } + + #[test] + fn history_snapshot_listing_redacts_known_and_fallback_secrets() { + crate::admin::storage_api::config::init_admin_config_defaults(); + let mut config = ServerConfig::new(); + apply_set_directives( + &mut config, + &parse_config_directives( + r#"identity_openid:primary client_id="client" client_secret="snapshot-client-canary" +notify_webhook:primary endpoint="https://hooks.example" auth_token="snapshot-token-canary" +notify_webhook:secondary endpoint="https://secondary.example" client_key="snapshot-key-canary""#, + false, + ) + .expect("parse fixture"), + ) + .expect("apply fixture"); + + let snapshot = encode_config_history_snapshot(&config).expect("encode snapshot"); + let listed = redact_config_history_data(&snapshot).expect("redact snapshot"); + + assert!(listed.contains(r#"client_id="client""#)); + assert!(listed.contains(r#"endpoint="https://hooks.example""#)); + assert!(listed.contains(r#"client_secret="*redacted*""#)); + assert!(listed.contains(r#"auth_token="*redacted*""#)); + assert!(listed.contains(r#"client_key="*redacted*""#)); + assert!(!listed.contains("snapshot-client-canary")); + assert!(!listed.contains("snapshot-token-canary")); + assert!(!listed.contains("snapshot-key-canary")); + } + + #[test] + fn history_snapshot_listing_preserves_creation_time_hidden_metadata() { + crate::admin::storage_api::config::init_admin_config_defaults(); + let mut config = ServerConfig::new(); + let targets = config.0.entry(IDENTITY_OPENID_SUB_SYS.to_string()).or_default(); + targets.insert( + DEFAULT_DELIMITER.to_string(), + KVS(vec![KV { + key: OIDC_CLIENT_ID.to_string(), + value: "metadata-hidden-canary".to_string(), + hidden_if_empty: true, + }]), + ); + + let snapshot = encode_config_history_snapshot(&config).expect("encode snapshot"); + let listed = redact_config_history_data(&snapshot).expect("redact snapshot"); + let restored = decode_config_history_snapshot(&snapshot).expect("decode snapshot"); + + assert!(listed.contains(r#"client_id="*redacted*""#)); + assert!(!listed.contains("metadata-hidden-canary")); + assert!( + String::from_utf8(render_full_config(&restored, false)) + .expect("utf8 restored config") + .contains(r#"client_id="metadata-hidden-canary""#) + ); + } + + #[test] + fn plaintext_v1_history_snapshot_remains_restorable_and_lists_redacted() { + crate::admin::storage_api::config::init_admin_config_defaults(); + let mut config = ServerConfig::new(); + apply_set_directives( + &mut config, + &parse_config_directives(r#"identity_openid client_id="legacy-client" client_secret="plaintext-v1-canary""#, false) + .expect("parse fixture"), + ) + .expect("apply fixture"); + let snapshot = encode_config_history_recovery(&config); + + let listed = redact_config_history_data(&snapshot).expect("redact v1 snapshot"); + let restored = decode_config_history_snapshot(&snapshot).expect("restore v1 snapshot"); + + assert!(listed.contains(r#"client_secret="*redacted*""#)); + assert!(!listed.contains("plaintext-v1-canary")); + assert!( + String::from_utf8(render_full_config(&restored, false)) + .expect("utf8 restored config") + .contains(r#"client_secret="plaintext-v1-canary""#) + ); + } + + #[test] + fn legacy_history_listing_redacts_quotes_env_lines_and_multiple_targets() { + let history = br#"identity_openid:primary client_id="visible-client" client_secret="quoted canary secret" +notify_webhook:primary endpoint="https://hooks.example" auth_token="target-canary-token" +notify_webhook:secondary endpoint="https://secondary.example" password="fallback-canary-password" +# RUSTFS_NOTIFY_WEBHOOK_AUTH_TOKEN_PRIMARY="env-canary-token" +RUSTFS_IDENTITY_OPENID_CLIENT_SECRET="env-canary-secret""#; + + let listed = redact_config_history_data(history).expect("redact legacy history"); + + assert!(listed.contains(r#"client_id="visible-client""#)); + assert!(listed.contains(r#"endpoint="https://hooks.example""#)); + assert!(listed.contains(r#"client_secret="*redacted*""#)); + assert!(listed.contains(r#"auth_token="*redacted*""#)); + assert!(listed.contains(r#"password="*redacted*""#)); + assert!(listed.contains(r#"# RUSTFS_NOTIFY_WEBHOOK_AUTH_TOKEN_PRIMARY="*redacted*""#)); + assert!(listed.contains(r#"RUSTFS_IDENTITY_OPENID_CLIENT_SECRET="*redacted*""#)); + for canary in [ + "quoted canary secret", + "target-canary-token", + "fallback-canary-password", + "env-canary-token", + "env-canary-secret", + ] { + assert!(!listed.contains(canary)); + } + } + + #[test] + fn corrupt_legacy_history_listing_fails_closed_without_echoing_bytes() { + let malformed = redact_config_history_data(b"identity_openid client_secret=\"unterminated-canary") + .expect("malformed history is replaced"); + let binary = + redact_config_history_data(&[0xff, 0xfe, b's', b'e', b'c', b'r', b'e', b't']).expect("binary history is replaced"); + + assert_eq!(malformed, REDACTED_VALUE); + assert_eq!(binary, REDACTED_VALUE); + assert!(!malformed.contains("unterminated-canary")); + assert!(!binary.contains("secret")); + } + + #[test] + fn malformed_history_snapshot_envelope_fails_closed() { + let mut truncated_length = format!("{CONFIG_HISTORY_SNAPSHOT_ENVELOPE_HEADER}\n").into_bytes(); + truncated_length.extend_from_slice(b"canary"); + let mut truncated_recovery = format!("{CONFIG_HISTORY_SNAPSHOT_ENVELOPE_HEADER}\n").into_bytes(); + truncated_recovery.extend_from_slice(&u64::MAX.to_be_bytes()); + truncated_recovery.extend_from_slice(b"envelope-canary"); + + for malformed in [truncated_length, truncated_recovery] { + let error = redact_config_history_data(&malformed).expect_err("malformed envelope must fail closed"); + assert_eq!(error.code(), &S3ErrorCode::InvalidRequest); + assert!(!error.to_string().contains("canary")); + } + } + + #[test] + fn history_response_marks_data_as_redacted_without_serializing_canary() { + let mut entry = ConfigHistoryEntry { + restore_id: "restore-id".to_string(), + create_time: OffsetDateTime::UNIX_EPOCH, + data: None, + data_redacted: None, + }; + entry + .set_redacted_data(b"identity_openid client_secret=\"history-response-canary\"") + .expect("redact response data"); + + let payload = serde_json::to_string(&entry).expect("serialize history entry"); + + assert!(payload.contains(r#""DataRedacted":true"#)); + assert!(payload.contains("*redacted*")); + assert!(!payload.contains("history-response-canary")); + } + + #[test] + fn minio_compatible_history_response_encrypts_only_redacted_data() { + let mut entry = ConfigHistoryEntry { + restore_id: "restore-id".to_string(), + create_time: OffsetDateTime::UNIX_EPOCH, + data: None, + data_redacted: None, + }; + entry + .set_redacted_data(b"identity_openid client_secret=\"compat-history-canary\"") + .expect("redact response data"); + let payload = serde_json::to_vec(&[entry]).expect("serialize history response"); + + let (encoded, content_type) = + encode_config_payload("/minio/admin/v3/list-config-history-kv", "compat-test-key", payload, JSON_CONTENT_TYPE) + .expect("encode compatible response"); + let decoded = rustfs_crypto::decrypt_stream_io(b"compat-test-key", &encoded).expect("decrypt compatible response"); + let decoded = String::from_utf8(decoded).expect("utf8 response"); + + assert_eq!(content_type, OCTET_STREAM_CONTENT_TYPE); + assert!(decoded.contains(r#""DataRedacted":true"#)); + assert!(decoded.contains("*redacted*")); + assert!(!decoded.contains("compat-history-canary")); + } + #[test] fn history_snapshot_supports_complete_default_config() { crate::admin::storage_api::config::init_admin_config_defaults(); let original = ServerConfig::new(); - let encoded = encode_config_history_snapshot(&original); + let encoded = encode_config_history_snapshot(&original).expect("encode snapshot"); let restored = decode_config_history_snapshot(&encoded).expect("decode default snapshot"); - assert_eq!(render_full_config(&restored), render_full_config(&original)); + assert_eq!(render_full_config(&restored, false), render_full_config(&original, false)); } #[test] @@ -2658,7 +3107,7 @@ notify_webhook:secondary endpoint="https://secondary.example" auth_token="second .expect("parse before fixture"), ) .expect("apply before fixture"); - let snapshot = encode_config_history_snapshot(&before); + let snapshot = encode_config_history_snapshot(&before).expect("encode snapshot"); let mut after = before.clone(); apply_set_directives( @@ -2672,8 +3121,8 @@ notify_webhook:secondary endpoint="https://secondary.example" auth_token="second ); let restored = decode_config_history_snapshot(&snapshot).expect("restore pre-change snapshot"); - assert_eq!(render_full_config(&restored), render_full_config(&before)); - assert_ne!(render_full_config(&restored), render_full_config(&after)); + assert_eq!(render_full_config(&restored, false), render_full_config(&before, false)); + assert_ne!(render_full_config(&restored, false), render_full_config(&after, false)); } #[test] @@ -2718,16 +3167,19 @@ notify_webhook:secondary endpoint="https://secondary.example" auth_token="second restore_id: "oldest".to_string(), create_time: OffsetDateTime::from_unix_timestamp(1).expect("timestamp"), data: None, + data_redacted: None, }, ConfigHistoryEntry { restore_id: "middle".to_string(), create_time: OffsetDateTime::from_unix_timestamp(2).expect("timestamp"), data: None, + data_redacted: None, }, ConfigHistoryEntry { restore_id: "newest".to_string(), create_time: OffsetDateTime::from_unix_timestamp(3).expect("timestamp"), data: None, + data_redacted: None, }, ]; @@ -2832,7 +3284,7 @@ identity_openid client_id="existing-client""#, .expect("apply"); // Simulate "mc admin config export" (render_full_config) - let full_output = String::from_utf8(render_full_config(&config)).expect("utf8"); + let full_output = String::from_utf8(render_full_config(&config, false)).expect("utf8"); // Simulate "mc admin config get storage_class" (target=None, redact=true) let selected_output = String::from_utf8(