From 74b1189016a869b660726adc9d2f66082c64f23e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=94=90=E5=B0=8F=E9=B8=AD?= Date: Sun, 16 Aug 2026 01:24:23 +0800 Subject: [PATCH] fix(storage): reserve replication transport names at metadata ingest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review round: a client PUT of x-amz-meta-x-rustfs-source-replication-tagging-timestamp materialized the bare transport key as stored user metadata. The outbound replication header builder forwards user metadata verbatim on a server-authorized request, so the receiver would persist the attacker-chosen value as trusted internal LWW state — and for a tagless object nothing later overwrites it. The ingest namespacing guard now reserves the whole x-rustfs-source- / x-minio-source- families (the new timestamps and their siblings: source-mtime/-etag/-version-id/-replication-request), folding forged keys back under x-amz-meta-. Forged-ingress regression covers both prefixes and a sibling. --- rustfs/src/storage/options.rs | 50 +++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/rustfs/src/storage/options.rs b/rustfs/src/storage/options.rs index 4028afa64..c43eff730 100644 --- a/rustfs/src/storage/options.rs +++ b/rustfs/src/storage/options.rs @@ -710,6 +710,13 @@ fn is_reserved_user_metadata_key(key: &str) -> bool { || starts_with_ignore_ascii_case(key, MINIO_INTERNAL_PREFIX) || starts_with_ignore_ascii_case(key, RUSTFS_ENCRYPTION_PREFIX) || starts_with_ignore_ascii_case(key, MINIO_ENCRYPTION_PREFIX) + // Replication transport names (source-replication timestamps, + // source-mtime/-etag/-version-id, ...). A bare stored key with one of + // these names is forwarded verbatim by the outbound replication + // header builder on a server-authorized request, so the receiver + // would persist attacker-chosen values as trusted internal LWW state. + || starts_with_ignore_ascii_case(key, "x-rustfs-source-") + || starts_with_ignore_ascii_case(key, "x-minio-source-") } fn stored_user_metadata_key(key: &str) -> String { @@ -1646,6 +1653,49 @@ mod tests { assert!(opts_invalid.mod_time.is_none()); } + /// A client PUT must not materialize the replication transport names as + /// bare stored user-metadata keys: the outbound replication header + /// builder forwards user metadata verbatim on a server-authorized + /// request, so a bare `x-rustfs-source-replication-*-timestamp` key would + /// deliver an attacker-chosen value into the replica's trusted internal + /// LWW state (for a tagless object nothing later overwrites it). + #[test] + fn test_replication_transport_names_cannot_be_forged_via_user_metadata() { + let mut headers = HeaderMap::new(); + for name in [ + "x-amz-meta-x-rustfs-source-replication-tagging-timestamp", + "x-amz-meta-x-minio-source-replication-legalhold-timestamp", + "x-rustfs-meta-x-rustfs-source-replication-retention-timestamp", + "x-amz-meta-x-rustfs-source-mtime", + ] { + headers.insert( + http::header::HeaderName::from_static(name), + HeaderValue::from_static("2026-01-02T03:04:05Z"), + ); + } + + let metadata = extract_metadata(&headers); + + for forged in [ + "x-rustfs-source-replication-tagging-timestamp", + "x-minio-source-replication-legalhold-timestamp", + "x-rustfs-source-replication-retention-timestamp", + "x-rustfs-source-mtime", + ] { + assert!( + metadata.get(forged).is_none(), + "{forged} must not be storable as a bare user-metadata key" + ); + } + // The values survive, namespaced back under the user-metadata prefix. + assert_eq!( + metadata + .get("x-amz-meta-x-rustfs-source-replication-tagging-timestamp") + .map(String::as_str), + Some("2026-01-02T03:04:05Z") + ); + } + #[test] fn test_put_opts_from_headers_gates_replication_timestamp_persistence_on_authorization() { // Sender-side LWW state (replication_target_boundary.rs) is read back