diff --git a/crates/e2e_test/src/multipart_auth_test.rs b/crates/e2e_test/src/multipart_auth_test.rs index 28483524c..887793d2d 100644 --- a/crates/e2e_test/src/multipart_auth_test.rs +++ b/crates/e2e_test/src/multipart_auth_test.rs @@ -6028,6 +6028,33 @@ async fn test_signed_put_object_extract_authorizes_each_pax_privilege_and_retent } let version_condition_client = restricted_user_client(&env, version_condition_user, version_condition_secret); + let mismatching_version_pax = HashMap::from([("minio.versionId", Uuid::new_v4().to_string())]); + let archive = make_tar_with_pax_entry("version-mismatch-entry.txt", b"must-not-write", None, &mismatching_version_pax).await; + let err = version_condition_client + .put_object() + .bucket(bucket) + .key("version-mismatch.tar") + .body(ByteStream::from(archive)) + .customize() + .mutate_request(|req| { + req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true"); + }) + .send() + .await + .expect_err("a mismatching PAX version ID must fail the replication condition"); + assert_eq!(err.as_service_error().and_then(|error| error.meta().code()), Some("AccessDenied")); + let err = admin_client + .head_object() + .bucket(bucket) + .key("version-mismatch-entry.txt") + .send() + .await + .expect_err("a denied PAX entry must not be written"); + assert!(matches!( + err.as_service_error().and_then(|error| error.meta().code()), + Some("NoSuchKey" | "NotFound") + )); + let matching_version_pax = HashMap::from([("minio.versionId", conditional_version_id)]); let archive = make_tar_with_pax_entry("condition-entry.txt", b"condition-body", None, &matching_version_pax).await; version_condition_client @@ -6041,6 +6068,13 @@ async fn test_signed_put_object_extract_authorizes_each_pax_privilege_and_retent }) .send() .await?; + let stored = admin_client + .get_object() + .bucket(bucket) + .key("condition-entry.txt") + .send() + .await?; + assert_eq!(stored.body.collect().await?.into_bytes().as_ref(), b"condition-body"); let pax_context_client = restricted_user_client(&env, pax_context_user, pax_context_secret); let tag_pax = HashMap::from([("minio.metadata.x-amz-tagging", "classification=public".to_string())]); diff --git a/crates/policy/src/policy/function/key_name.rs b/crates/policy/src/policy/function/key_name.rs index 7ad64a7c5..30158e3de 100644 --- a/crates/policy/src/policy/function/key_name.rs +++ b/crates/policy/src/policy/function/key_name.rs @@ -56,6 +56,7 @@ impl KeyName { KeyName::S3(S3KeyName::S3SignatureAge), KeyName::S3(S3KeyName::S3XAmzContentSha256), KeyName::S3(S3KeyName::S3LocationConstraint), + KeyName::S3(S3KeyName::S3VersionId), //aws KeyName::Aws(AwsKeyName::AWSReferer), KeyName::Aws(AwsKeyName::AWSSourceIP), @@ -191,6 +192,9 @@ pub enum S3KeyName { #[strum(serialize = "s3:LocationConstraint")] S3LocationConstraint, + #[strum(to_string = "s3:versionid", serialize = "s3:VersionId")] + S3VersionId, + #[strum(serialize = "s3:object-lock-retain-until-date")] S3ObjectLockRetainUntilDate, @@ -379,6 +383,8 @@ mod tests { use test_case::test_case; #[test_case("s3:x-amz-copy-source", KeyName::S3(S3KeyName::S3XAmzCopySource))] + #[test_case("s3:VersionId", KeyName::S3(S3KeyName::S3VersionId) ; "aws_version_id")] + #[test_case("s3:versionid", KeyName::S3(S3KeyName::S3VersionId) ; "minio_version_id")] #[test_case("aws:SecureTransport", KeyName::Aws(AwsKeyName::AWSSecureTransport))] #[test_case("jwt:sub", KeyName::Jwt(JwtKeyName::JWTSub))] #[test_case("ldap:user", KeyName::Ldap(LdapKeyName::User))] @@ -399,6 +405,8 @@ mod tests { } #[test_case("s3:x-amz-copy-source", KeyName::S3(S3KeyName::S3XAmzCopySource))] + #[test_case("s3:VersionId", KeyName::S3(S3KeyName::S3VersionId) ; "aws_version_id")] + #[test_case("s3:versionid", KeyName::S3(S3KeyName::S3VersionId) ; "minio_version_id")] #[test_case("aws:SecureTransport", KeyName::Aws(AwsKeyName::AWSSecureTransport))] #[test_case("jwt:sub", KeyName::Jwt(JwtKeyName::JWTSub))] #[test_case("ldap:user", KeyName::Ldap(LdapKeyName::User))] @@ -416,6 +424,7 @@ mod tests { } #[test_case("s3:x-amz-copy-source", KeyName::S3(S3KeyName::S3XAmzCopySource))] + #[test_case("s3:versionid", KeyName::S3(S3KeyName::S3VersionId))] #[test_case("aws:SecureTransport", KeyName::Aws(AwsKeyName::AWSSecureTransport))] #[test_case("jwt:sub", KeyName::Jwt(JwtKeyName::JWTSub))] #[test_case("ldap:user", KeyName::Ldap(LdapKeyName::User))] diff --git a/crates/policy/src/policy/function/string.rs b/crates/policy/src/policy/function/string.rs index a1ef1a7b6..6368d21c5 100644 --- a/crates/policy/src/policy/function/string.rs +++ b/crates/policy/src/policy/function/string.rs @@ -408,6 +408,8 @@ mod tests { #[test_case(new_fkv("s3:ExistingObjectTag/security", vec!["public"]), false, vec![("ExistingObjectTag/security", vec!["public"])] => true ; "19")] #[test_case(new_fkv("s3:ExistingObjectTag/security", vec!["public"]), false, vec![("ExistingObjectTag/security", vec!["private"])] => false ; "20")] #[test_case(new_fkv("s3:ExistingObjectTag/security", vec!["public"]), false, vec![("ExistingObjectTag/project", vec!["webapp"])] => false ; "21")] + #[test_case(new_fkv("s3:VersionId", vec!["version-1"]), false, vec![("versionid", vec!["version-1"])] => true ; "aws_version_id")] + #[test_case(new_fkv("s3:versionid", vec!["version-1"]), false, vec![("versionid", vec!["version-1"])] => true ; "minio_version_id")] fn test_string_equals(s: FuncKeyValue, for_all: bool, values: Vec<(&str, Vec<&str>)>) -> bool { test_eval(s, for_all, false, false, values) } diff --git a/rustfs/src/auth.rs b/rustfs/src/auth.rs index 73bf72378..07acbe6ff 100644 --- a/rustfs/src/auth.rs +++ b/rustfs/src/auth.rs @@ -1389,6 +1389,19 @@ mod tests { ); } + #[test] + fn version_id_condition_ignores_spoofed_headers() { + let cred = create_test_credentials(); + let mut headers = HeaderMap::new(); + headers.insert("versionid", "spoofed-version".parse().unwrap()); + + let conditions = get_condition_values(&headers, &cred, None, None, None); + assert_eq!(conditions.get("versionid"), None); + + let conditions = get_condition_values(&headers, &cred, Some("server-version"), None, None); + assert_eq!(conditions.get("versionid"), Some(&vec!["server-version".to_string()])); + } + #[test] fn ghsa_6r96_claim_condition_keys_ignore_spoofed_headers() { // The credential carries no groups/roles claims, so these keys are absent --