mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-16 09:58:21 +00:00
fix(storage): reserve replication transport names at metadata ingest
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.
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user