fix(notify): strip rustfs/minio internal metadata from event userMetadata (#4419)

fix(notify): strip rustfs/minio internal metadata from event userMetadata (backlog#964)

Notification event construction only stripped keys prefixed with
`x-amz-meta-internal-`, which is not a prefix RustFS actually uses. Real
internal metadata lives under `x-rustfs-internal-*` / `x-minio-internal-*`
(xl.meta compat, incl. `x-minio-internal-server-side-encryption-*`) and
server-side-encryption details under `x-rustfs-encryption-*` /
`x-minio-encryption-*`. None of these were filtered, so they leaked into
`s3.object.userMetadata` sent to downstream notification targets
(webhook/MQ) — an information disclosure of SSE/replication/healing
internal state.

Filter via the shared classifiers `rustfs_utils::http::is_internal_key`
and `is_encryption_metadata_key` (case-insensitive, covering both key
flavors), while retaining the legacy `x-amz-meta-internal-*` strip for
backward compat. Genuine user metadata (`x-amz-meta-*`, content-type, ...)
is preserved unchanged.

Adds a regression test asserting both internal prefixes, the SSE internal
key, both encryption prefixes (incl. mixed-case) and the legacy prefix are
stripped while user keys survive.

Refs: https://github.com/rustfs/backlog/issues/964
This commit is contained in:
Zhengchao An
2026-07-08 15:01:49 +08:00
committed by GitHub
parent f64f0ca91a
commit cf89291898
+85 -3
View File
@@ -16,9 +16,26 @@ use chrono::{DateTime, SecondsFormat, Utc};
use hashbrown::HashMap;
use rustfs_s3_ops::is_object_removed_event;
use rustfs_s3_types::{EventName, event_schema_version};
use rustfs_utils::http::{is_encryption_metadata_key, is_internal_key};
use serde::{Deserialize, Serialize};
use url::form_urlencoded;
/// Legacy internal-metadata prefix retained for backward compatibility.
const LEGACY_AMZ_META_INTERNAL_PREFIX: &str = "x-amz-meta-internal-";
/// Returns `true` if `key` is RustFS/MinIO internal metadata that must not be exposed in
/// notification `userMetadata`. Covers the internal xl.meta prefixes
/// (`x-rustfs-internal-*` / `x-minio-internal-*`), the server-side-encryption prefixes
/// (`x-rustfs-encryption-*` / `x-minio-encryption-*`), and the legacy
/// `x-amz-meta-internal-*` prefix. Case-insensitive.
fn is_internal_metadata_key(key: &str) -> bool {
is_internal_key(key)
|| is_encryption_metadata_key(key)
|| key.len() >= LEGACY_AMZ_META_INTERNAL_PREFIX.len()
&& key.as_bytes()[..LEGACY_AMZ_META_INTERNAL_PREFIX.len()]
.eq_ignore_ascii_case(LEGACY_AMZ_META_INTERNAL_PREFIX.as_bytes())
}
/// Represents the identity of the user who triggered the event
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -258,12 +275,18 @@ impl Event {
s3_metadata.object.size = Some(args.object.size);
s3_metadata.object.e_tag = args.object.etag.clone();
s3_metadata.object.content_type = args.object.content_type.clone();
// Filter out internal reserved metadata
// Filter out internal/reserved metadata so it never leaks to downstream
// notification targets (webhook/MQ). RustFS stores internal metadata under both
// `x-rustfs-internal-*` and `x-minio-internal-*` (see rustfs_utils metadata_compat),
// and server-side-encryption details under `x-rustfs-encryption-*` /
// `x-minio-encryption-*` (header_compat). All of these must be stripped; only genuine
// user-defined metadata (e.g. `x-amz-meta-*`) is preserved.
let mut user_metadata = HashMap::new();
for (k, v) in args.object.user_defined.iter() {
if !k.to_lowercase().starts_with("x-amz-meta-internal-") {
user_metadata.insert(k.clone(), v.clone());
if is_internal_metadata_key(k) {
continue;
}
user_metadata.insert(k.clone(), v.clone());
}
s3_metadata.object.user_metadata = Some(user_metadata);
}
@@ -537,6 +560,65 @@ mod tests {
assert_eq!(glacier.restore_event_data.lifecycle_restore_storage_class, "GLACIER");
}
#[test]
fn event_user_metadata_strips_internal_and_encryption_keys() {
let mut user_defined = HashMap::new();
// RustFS internal (xl.meta) keys
user_defined.insert("x-rustfs-internal-inline-data".to_string(), "true".to_string());
user_defined.insert("x-rustfs-internal-transition-tier".to_string(), "WARM".to_string());
// MinIO internal keys (interop) + SSE internal metadata
user_defined.insert("x-minio-internal-compression".to_string(), "s2".to_string());
user_defined.insert("x-minio-internal-server-side-encryption-iv".to_string(), "secret-iv".to_string());
// Encryption prefixes (both flavors), including mixed-case
user_defined.insert("x-rustfs-encryption-key".to_string(), "wrapped-key".to_string());
user_defined.insert("X-Minio-Encryption-Iv".to_string(), "secret".to_string());
// Legacy internal prefix
user_defined.insert("x-amz-meta-internal-foo".to_string(), "bar".to_string());
// Genuine user metadata that MUST be preserved
user_defined.insert("x-amz-meta-project".to_string(), "rustfs".to_string());
user_defined.insert("content-type".to_string(), "text/plain".to_string());
let args = EventArgsBuilder::new(
EventName::ObjectCreatedPut,
"bucket",
NotifyObjectInfo {
bucket: "bucket".to_string(),
name: "key".to_string(),
user_defined,
..Default::default()
},
)
.build();
let event = Event::new(args);
let user_metadata = event
.s3
.object
.user_metadata
.expect("user_metadata should be present for a create event");
// All internal / encryption keys stripped.
for internal in [
"x-rustfs-internal-inline-data",
"x-rustfs-internal-transition-tier",
"x-minio-internal-compression",
"x-minio-internal-server-side-encryption-iv",
"x-rustfs-encryption-key",
"X-Minio-Encryption-Iv",
"x-amz-meta-internal-foo",
] {
assert!(
!user_metadata.contains_key(internal),
"internal key {internal:?} leaked into notification userMetadata"
);
}
// Genuine user metadata preserved unchanged.
assert_eq!(user_metadata.get("x-amz-meta-project").map(String::as_str), Some("rustfs"));
assert_eq!(user_metadata.get("content-type").map(String::as_str), Some("text/plain"));
assert_eq!(user_metadata.len(), 2);
}
#[test]
fn event_time_serializes_with_millisecond_precision() {
let mut event = Event::new_test_event("bucket", "key", EventName::ObjectCreatedPut);