fix(filemeta): redact sealed keys and elide inline data in FileInfo Debug (#5725)

FileInfo's derived Debug printed the full metadata map (including X-Rustfs/X-Minio-Internal-Server-Side-Encryption-Sealed-Key and -Iv values, i.e. KEK-wrapped DEK ciphertext) and the full inline data bytes (plaintext user content for non-SSE small objects), so any whole-struct log dump such as the heal_object dumps leaked user data and sealed key material into logs.

Replace the derive with a manual Debug impl that redacts encryption metadata values (keys stay visible, values print as redacted with length) under both internal prefixes, and elides data/checksum bytes to a length summary. The exhaustive destructuring forces every future field through an explicit show/redact decision. starts_with_ignore_ascii_case is made pub in rustfs-utils for reuse.
This commit is contained in:
Zhengchao An
2026-08-05 10:07:01 +08:00
committed by GitHub
parent 53a8e02a08
commit 5bd28048d5
2 changed files with 164 additions and 2 deletions
+163 -1
View File
@@ -19,6 +19,7 @@ use rustfs_utils::HashAlgorithm;
use rustfs_utils::http::{
SUFFIX_COMPRESSION, SUFFIX_DATA_MOVED, SUFFIX_FREE_VERSION, SUFFIX_HEALING, SUFFIX_INLINE_DATA, SUFFIX_TIER_FV_ID,
SUFFIX_TIER_FV_MARKER, SUFFIX_TIER_SKIP_FV_ID, contains_key_str, get_str, has_internal_suffix, insert_str,
is_encryption_metadata_key, starts_with_ignore_ascii_case,
};
use s3s::dto::{RestoreStatus, Timestamp};
use s3s::header::X_AMZ_RESTORE;
@@ -231,7 +232,7 @@ pub enum TransitionVersionState {
Exact,
}
#[derive(Debug, PartialEq, Clone, Default)]
#[derive(PartialEq, Clone, Default)]
pub struct FileInfo {
pub volume: String,
pub name: String,
@@ -271,6 +272,117 @@ pub struct FileInfo {
pub uses_legacy_checksum: bool,
}
/// Metadata keys whose values carry sealed encryption material (KEK-wrapped DEK,
/// IV) under either the `x-rustfs-internal-` or `x-minio-internal-` prefix.
/// Values of these keys must never reach logs at any level.
fn is_sensitive_metadata_key(key: &str) -> bool {
// `is_encryption_metadata_key` covers the x-minio-internal- SSE prefix but not
// its x-rustfs-internal- twin, which the dual-key invariant writes alongside it.
is_encryption_metadata_key(key) || starts_with_ignore_ascii_case(key, "x-rustfs-internal-server-side-encryption-")
}
struct RedactedMetadata<'a>(&'a HashMap<String, String>);
impl std::fmt::Debug for RedactedMetadata<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut map = f.debug_map();
for (key, value) in self.0 {
if is_sensitive_metadata_key(key) {
map.entry(key, &format_args!("<redacted {} bytes>", value.len()));
} else {
map.entry(key, value);
}
}
map.finish()
}
}
struct ElidedBytes<'a>(&'a Option<Bytes>);
impl std::fmt::Debug for ElidedBytes<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.0 {
Some(bytes) => write!(f, "Some(<{} bytes elided>)", bytes.len()),
None => f.write_str("None"),
}
}
}
// Manual Debug: `data` holds full inline object bytes (plaintext user content for
// non-SSE objects) and `metadata` holds sealed key material — both must stay out
// of Debug output so whole-struct log dumps cannot leak them. `checksum` is elided
// too: for non-SSE objects it fingerprints plaintext content, and its raw bytes
// carry no diagnostic value. The exhaustive destructuring (no `..`) forces every
// future field through an explicit show/redact decision here.
impl std::fmt::Debug for FileInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let Self {
volume,
name,
version_id,
is_latest,
deleted,
transition_status,
transitioned_objname,
transition_tier,
transition_version_id,
transition_version,
transition_version_state,
expire_restored,
data_dir,
mod_time,
size,
mode,
written_by_version,
metadata,
parts,
erasure,
mark_deleted,
replication_state_internal,
data,
num_versions,
successor_mod_time,
fresh,
idx,
checksum,
versioned,
uses_legacy_checksum,
} = self;
f.debug_struct("FileInfo")
.field("volume", volume)
.field("name", name)
.field("version_id", version_id)
.field("is_latest", is_latest)
.field("deleted", deleted)
.field("transition_status", transition_status)
.field("transitioned_objname", transitioned_objname)
.field("transition_tier", transition_tier)
.field("transition_version_id", transition_version_id)
.field("transition_version", transition_version)
.field("transition_version_state", transition_version_state)
.field("expire_restored", expire_restored)
.field("data_dir", data_dir)
.field("mod_time", mod_time)
.field("size", size)
.field("mode", mode)
.field("written_by_version", written_by_version)
.field("metadata", &RedactedMetadata(metadata))
.field("parts", parts)
.field("erasure", erasure)
.field("mark_deleted", mark_deleted)
.field("replication_state_internal", replication_state_internal)
.field("data", &ElidedBytes(data))
.field("num_versions", num_versions)
.field("successor_mod_time", successor_mod_time)
.field("fresh", fresh)
.field("idx", idx)
.field("checksum", &ElidedBytes(checksum))
.field("versioned", versioned)
.field("uses_legacy_checksum", uses_legacy_checksum)
.finish()
}
}
#[derive(Deserialize)]
#[serde(remote = "FileInfo")]
struct FileInfoMapDef {
@@ -2410,4 +2522,54 @@ mod tests {
};
assert!(with_state.replication_info_equals(&with_state_clone));
}
#[test]
fn debug_redacts_sealed_encryption_metadata_values() {
let sealed_key = "IAAfANqt7wIJfVSgFAG3f5S6HuC2eyM5DdJlx7RSJKw2ZakSb3d5";
let sealed_iv = "0Vr8QLGvQThk8gIWFCUnBOTUwZgs7TTBteRnAK9avD0=";
let mut fi = FileInfo::default();
for key in [
"X-Rustfs-Internal-Server-Side-Encryption-Sealed-Key",
"X-Minio-Internal-Server-Side-Encryption-Sealed-Key",
] {
fi.metadata.insert(key.to_string(), sealed_key.to_string());
}
for key in [
"X-Rustfs-Internal-Server-Side-Encryption-Iv",
"X-Minio-Internal-Server-Side-Encryption-Iv",
"x-rustfs-encryption-iv",
] {
fi.metadata.insert(key.to_string(), sealed_iv.to_string());
}
fi.metadata.insert("content-type".to_string(), "text/plain".to_string());
let dump = format!("{fi:?}");
assert!(!dump.contains(sealed_key), "sealed key leaked into Debug output: {dump}");
assert!(!dump.contains(sealed_iv), "sealed IV leaked into Debug output: {dump}");
// Keys stay visible so operators can still see which metadata is present.
assert!(dump.contains("X-Rustfs-Internal-Server-Side-Encryption-Sealed-Key"));
assert!(dump.contains(&format!("<redacted {} bytes>", sealed_key.len())));
// Non-sensitive metadata values keep their diagnostic value.
assert!(dump.contains("text/plain"));
}
#[test]
fn debug_elides_inline_data_bytes() {
let fi = FileInfo {
data: Some(Bytes::from_static(b"plaintext user object content")),
checksum: Some(Bytes::from_static(b"\x01\x02checksumblob")),
..Default::default()
};
let dump = format!("{fi:?}");
assert!(
!dump.contains("plaintext user object content"),
"inline data leaked into Debug output: {dump}"
);
assert!(!dump.contains("checksumblob"), "checksum bytes leaked into Debug output: {dump}");
assert!(dump.contains("data: Some(<29 bytes elided>)"), "missing data length summary: {dump}");
let empty = FileInfo::default();
assert!(format!("{empty:?}").contains("data: None"));
}
}
+1 -1
View File
@@ -65,7 +65,7 @@ pub const SUFFIX_REPLICATION_DELETE_MARKER_VERSION_ARN_PREFIX: &str = "replicati
/// Case-insensitive (ASCII) check that `s` begins with `prefix`. Equivalent to
/// `s.to_lowercase().starts_with(prefix)` when `prefix` is ASCII (as both internal prefixes are),
/// but without allocating.
fn starts_with_ignore_ascii_case(s: &str, prefix: &str) -> bool {
pub fn starts_with_ignore_ascii_case(s: &str, prefix: &str) -> bool {
s.len() >= prefix.len() && s.as_bytes()[..prefix.len()].eq_ignore_ascii_case(prefix.as_bytes())
}