From b12441c50e1490a8e61fe643205a8666cfc70d6d Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Fri, 25 Sep 2026 16:47:42 -0300 Subject: [PATCH 1/2] fix(s3): honor the ACL headers on CopyObject, and share one resolver CopyObject read neither x-amz-acl nor the x-amz-grant-* headers and hardcoded the destination's ACL to the owner's FULL_CONTROL, so `copy-object --acl public-read` returned 200 and produced a private object -- the caller believed it had published the copy. It was also the one object-write path that accepted an ACL for a bucket whose ObjectOwnership disables them, and the only one where `--acl pubic-read` was silently ignored rather than refused. The destination now takes its ACL from the request, and a copy is a new object, so an unspecified ACL stays the default owner grant rather than inheriting whatever the source carried. That case has its own test, being the one most easily broken by "just copy the source's grants". The three object-write paths each kept their own copy of the same four checks, and the ORDER is load-bearing: the canned value is validated before the grant headers are resolved, and both before the "named two ways" rejection, so a request carrying an unresolvable grant answers InvalidArgument rather than InvalidRequest. The conformance probe populates the canned member and the grant members together, and only the first of those codes is in the S3 error allowlist -- so a path that reordered its own copy would have silently dropped probe variants. resolve_write_acl_headers now holds the order once, and PutObject, CreateMultipartUpload and CopyObject all call it. Sharing one resolver also let two divergences be fixed in one place rather than three: - AWS accepts an ACL that grants nothing beyond the bucket owner against a BucketOwnerEnforced bucket, spelled either `x-amz-acl: bucket-owner-full-control` or, in the AWS wording, "an equivalent form of this ACL" -- so an explicit full-control grant to the owner is accepted too. All three paths refused both. - BlockPublicAcls was enforced only by PutObjectAcl and PutBucketAcl, so `put-object --acl public-read` stored an AllUsers grant on a bucket that blocks exactly that while `put-object-acl --acl public-read` was refused. AWS refuses both. One normalization worth noting: CreateMultipartUpload checked ownership before validating the canned value, so an invalid value on a BucketOwnerEnforced bucket answered AccessControlListNotSupported. It now answers InvalidArgument, which is what PutObject already did -- the value is wrong whatever the bucket allows. Both orders are pinned by tests now. --- crates/fakecloud-s3/src/service/mod.rs | 110 +++++++ crates/fakecloud-s3/src/service/multipart.rs | 39 +-- .../fakecloud-s3/src/service/objects/mod.rs | 13 +- .../fakecloud-s3/src/service/objects/write.rs | 78 ++--- crates/fakecloud-s3/src/service/tests.rs | 285 ++++++++++++++++++ website/content/docs/services/s3.md | 2 +- 6 files changed, 432 insertions(+), 95 deletions(-) diff --git a/crates/fakecloud-s3/src/service/mod.rs b/crates/fakecloud-s3/src/service/mod.rs index 3a8ccbb23..67cc644e3 100644 --- a/crates/fakecloud-s3/src/service/mod.rs +++ b/crates/fakecloud-s3/src/service/mod.rs @@ -194,6 +194,25 @@ pub(crate) fn reject_conflicting_acl_sources( } } +/// The ACL an object write asks for: a canned value, or resolved grant headers, +/// or neither. +#[derive(Debug)] +pub(crate) struct WriteAclHeaders { + pub(crate) canned: Option, + pub(crate) grants: Option>, +} + +impl WriteAclHeaders { + /// The grants to store, given the owner to fall back to. + pub(crate) fn grants_for(&self, owner_id: &str) -> Option> { + match (&self.grants, self.canned.as_deref()) { + (Some(grants), _) => Some(grants.clone()), + (None, Some(acl)) => Some(canned_acl_grants_for_object(acl, owner_id)), + (None, None) => None, + } + } +} + /// Whether a request body carries nothing an ACL could be read from. /// /// The mutual-exclusion checks and the "names no ACL at all" check have to agree @@ -208,6 +227,97 @@ pub(crate) fn body_is_blank(body: &[u8]) -> bool { std::str::from_utf8(body).map_or(true, |s| s.trim().is_empty()) } +impl S3Service { + /// Validate and resolve the ACL headers of an object write. + /// + /// Every object-write path goes through here: PutObject, + /// CreateMultipartUpload and CopyObject each used to keep their own copy of + /// these four checks, and the ORDER matters -- the canned value is validated + /// before the grant headers are resolved, and both before the + /// "named two ways" rejection, so a request carrying an unresolvable grant + /// answers InvalidArgument rather than InvalidRequest. The conformance probe + /// populates the canned member and the grant members together, and only the + /// first of those codes is in the S3 error allowlist, so a path that + /// reordered its own copy would silently drop probe variants. + /// + /// Call it before any work the request would have to undo -- in particular + /// before the body is spooled to disk, since nothing unlinks the spool file + /// on an error path. + pub(crate) fn resolve_write_acl_headers( + &self, + account_id: &str, + bucket: &str, + headers: &HeaderMap, + ) -> Result { + let canned = headers + .get("x-amz-acl") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + if let Some(acl) = canned.as_deref() { + validate_object_canned_acl(acl)?; + } + let grants = if has_grant_headers(headers) { + Some(resolved_grant_headers(headers)?) + } else { + None + }; + if canned.is_some() && grants.is_some() { + return Err(AwsServiceError::aws_error( + StatusCode::BAD_REQUEST, + "InvalidRequest", + "Specifying both Canned ACLs and Header Grants is not allowed", + )); + } + // What the request actually asks for, so both checks below judge grants + // rather than header spellings. + let requested = match (&grants, canned.as_deref()) { + (Some(g), _) => Some(g.clone()), + (None, Some(acl)) => Some(canned_acl_grants_for_object(acl, account_id)), + (None, None) => None, + }; + let reaches_past_owner = requested.as_deref().is_some_and(|g| { + g.iter().any(|grant| { + grant.permission != "FULL_CONTROL" + || grant.grantee_type != "CanonicalUser" + || grant.grantee_id.as_deref() != Some(account_id) + }) + }); + + // BucketOwnerEnforced disables object ACLs, so asking for one is + // AccessControlListNotSupported -- except an ACL that grants nothing + // beyond the owner, which AWS still accepts there. That covers the + // canned `bucket-owner-full-control` AND, as the AWS wording says, "an + // equivalent form of this ACL", so `x-amz-grant-full-control=id=` + // is accepted too. A write with no ACL header is unaffected. + if reaches_past_owner && self.bucket_owner_enforced(account_id, bucket) { + return Err(AwsServiceError::aws_error( + StatusCode::BAD_REQUEST, + "AccessControlListNotSupported", + "The bucket does not allow ACLs", + )); + } + + // BlockPublicAcls refuses a public grant at write time too. Only the + // Put*Acl paths enforced it, so `put-object --acl public-read` stored an + // AllUsers grant on a bucket that blocks exactly that while + // `put-object-acl --acl public-read` was refused; AWS refuses both. + if let Some(requested) = requested.as_deref() { + if crate::service::config::grants_are_public(requested) { + if let Some(flags) = self.pab_flags(account_id, bucket) { + if flags.block_public_acls { + return Err(AwsServiceError::aws_error( + StatusCode::FORBIDDEN, + "AccessDenied", + "User is not authorized to perform: s3:PutObject. Reason: Public Access Block (BlockPublicAcls)", + )); + } + } + } + } + Ok(WriteAclHeaders { canned, grants }) + } +} + /// Map a [`StoreError`] from the persistence layer to a 500 InternalError /// response. Invoked at every mutation site when the write-through persistence /// call fails: the in-memory mutation has already happened, but we surface the diff --git a/crates/fakecloud-s3/src/service/multipart.rs b/crates/fakecloud-s3/src/service/multipart.rs index 364d5fb55..0bd9d3312 100644 --- a/crates/fakecloud-s3/src/service/multipart.rs +++ b/crates/fakecloud-s3/src/service/multipart.rs @@ -14,7 +14,7 @@ use md5::{Digest, Md5}; use super::{ canned_acl_grants, compute_md5, extract_user_metadata, no_such_bucket, no_such_key, no_such_upload, parse_complete_multipart_xml, parse_url_encoded_tags, precondition_failed, - resolve_object, resolved_grant_headers, s3_xml, xml_escape, S3Service, + resolve_object, s3_xml, xml_escape, S3Service, }; /// Build the `CompleteMultipartUploadResult` XML response for an object that @@ -95,32 +95,7 @@ impl S3Service { .get("x-amz-tagging") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); - let acl_header = req - .headers - .get("x-amz-acl") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()); - let has_grant_headers = super::has_grant_headers(&req.headers); - // Every sibling ACL-setting path rejects an ACL on a bucket whose - // ownership disables them; this one used to accept it and carry the - // grants into the completed object, which now persists them. - if (acl_header.is_some() || has_grant_headers) - && self.bucket_owner_enforced(account_id, bucket) - { - return Err(AwsServiceError::aws_error( - StatusCode::BAD_REQUEST, - "AccessControlListNotSupported", - "The bucket does not allow ACLs", - )); - } - - if acl_header.is_some() && has_grant_headers { - return Err(AwsServiceError::aws_error( - StatusCode::BAD_REQUEST, - "InvalidRequest", - "Specifying both Canned ACLs and Header Grants is not allowed", - )); - } + let write_acl = self.resolve_write_acl_headers(account_id, bucket, &req.headers)?; let checksum_algorithm = req .headers @@ -136,13 +111,9 @@ impl S3Service { .get_mut(bucket) .ok_or_else(|| no_such_bucket(bucket))?; - let acl_grants = if has_grant_headers { - resolved_grant_headers(&req.headers)? - } else { - let acl = acl_header.as_deref().unwrap_or("private"); - super::validate_object_canned_acl(acl)?; - canned_acl_grants(acl, &b.acl_owner_id) - }; + let acl_grants = write_acl + .grants_for(&b.acl_owner_id) + .unwrap_or_else(|| canned_acl_grants("private", &b.acl_owner_id)); let upload = MultipartUpload { upload_id: upload_id.clone(), diff --git a/crates/fakecloud-s3/src/service/objects/mod.rs b/crates/fakecloud-s3/src/service/objects/mod.rs index 7682b2adb..9f6ff9215 100644 --- a/crates/fakecloud-s3/src/service/objects/mod.rs +++ b/crates/fakecloud-s3/src/service/objects/mod.rs @@ -11,13 +11,12 @@ use crate::persistence::object_meta_snapshot; use crate::state::{AclGrant, S3Object}; use super::{ - canned_acl_grants_for_object, check_get_conditionals, check_head_conditionals, - check_object_lock_for_overwrite, compute_checksum, deliver_notifications, etag_matches, - extract_user_metadata, extract_xml_value, is_frozen, is_valid_storage_class, - make_delete_marker, no_such_bucket, no_such_key, parse_delete_objects_quiet, - parse_delete_objects_xml, parse_range_header, parse_url_encoded_tags, precondition_failed, - replicate_through_store, resolve_object, resolved_grant_headers, s3_xml, url_encode_s3_key, - xml_escape, RangeResult, S3Service, + check_get_conditionals, check_head_conditionals, check_object_lock_for_overwrite, + compute_checksum, deliver_notifications, etag_matches, extract_user_metadata, + extract_xml_value, is_frozen, is_valid_storage_class, make_delete_marker, no_such_bucket, + no_such_key, parse_delete_objects_quiet, parse_delete_objects_xml, parse_range_header, + parse_url_encoded_tags, precondition_failed, replicate_through_store, resolve_object, s3_xml, + url_encode_s3_key, xml_escape, RangeResult, S3Service, }; mod delete; diff --git a/crates/fakecloud-s3/src/service/objects/write.rs b/crates/fakecloud-s3/src/service/objects/write.rs index 94f8dea08..8cf76531e 100644 --- a/crates/fakecloud-s3/src/service/objects/write.rs +++ b/crates/fakecloud-s3/src/service/objects/write.rs @@ -40,47 +40,10 @@ impl S3Service { .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); - // Check for ACL header - let acl_header = req - .headers - .get("x-amz-acl") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()); - - // Check for grant headers alongside canned ACL - let has_grant_headers = super::super::has_grant_headers(&req.headers); - - // Validated here, before `take_body_stream` spools the payload to disk: + // Resolved here, before `take_body_stream` spools the payload to disk: // returning after the spool leaks the file, since nothing unlinks it on // the error paths. - if let Some(acl) = acl_header.as_deref() { - super::super::validate_object_canned_acl(acl)?; - } - if has_grant_headers { - resolved_grant_headers(&req.headers)?; - } - if acl_header.is_some() && has_grant_headers { - return Err(AwsServiceError::aws_error( - StatusCode::BAD_REQUEST, - "InvalidRequest", - "Specifying both Canned ACLs and Header Grants is not allowed", - )); - } - - // BucketOwnerEnforced disables object ACLs at write-time too: - // any x-amz-acl or x-amz-grant-* header rejects with - // AccessControlListNotSupported. Plain PutObject without ACL - // headers continues to work — only attempts to set a grant - // are rejected. - if (acl_header.is_some() || has_grant_headers) - && self.bucket_owner_enforced(account_id, bucket) - { - return Err(AwsServiceError::aws_error( - StatusCode::BAD_REQUEST, - "AccessControlListNotSupported", - "The bucket does not allow ACLs", - )); - } + let write_acl = self.resolve_write_acl_headers(account_id, bucket, &req.headers)?; // Parse tags from header let tags = if let Some(tagging) = &tagging_header { @@ -306,12 +269,8 @@ impl S3Service { }); // Build ACL grants for object - let acl_grants = if has_grant_headers { - // Already validated before the body was spooled; this cannot fail - // here, but resolving again keeps one source for the grants. - resolved_grant_headers(&req.headers)? - } else if let Some(ref acl) = acl_header { - canned_acl_grants_for_object(acl, &acl_owner_id) + let acl_grants = if let Some(grants) = write_acl.grants_for(&acl_owner_id) { + grants } else { // Default: owner full control vec![AclGrant { @@ -859,6 +818,15 @@ impl S3Service { .and_then(|v| v.to_str().ok()) .map(|s| s.to_uppercase()); + // CopyObject accepts the same ACL headers as PutObject: a canned + // `x-amz-acl` or the `x-amz-grant-*` pair, never both. Ignoring them -- + // which is what this path used to do -- answered 200 for + // `copy-object --acl public-read` and produced a private object, so the + // caller believed it had published the copy. + // Resolved before the write lock, since the check reads bucket state of + // its own, and before the copy does any work. + let copy_acl = self.resolve_write_acl_headers(account_id, dest_bucket, &req.headers)?; + let mut accts = self.state.write(); let state = accts.get_or_create(account_id); @@ -1152,14 +1120,18 @@ impl S3Service { None }; - // Default ACL for destination (not copied from source) - let dest_acl_grants = vec![AclGrant { - grantee_type: "CanonicalUser".to_string(), - grantee_id: Some(db.acl_owner_id.clone()), - grantee_display_name: Some(db.acl_owner_id.clone()), - grantee_uri: None, - permission: "FULL_CONTROL".to_string(), - }]; + // The destination's ACL comes from this request, never from the source: + // S3 treats a copy as a new object, so an unspecified ACL is the default + // private one rather than whatever the source carried. + let dest_acl_grants = copy_acl.grants_for(&db.acl_owner_id).unwrap_or_else(|| { + vec![AclGrant { + grantee_type: "CanonicalUser".to_string(), + grantee_id: Some(db.acl_owner_id.clone()), + grantee_display_name: Some(db.acl_owner_id.clone()), + grantee_uri: None, + permission: "FULL_CONTROL".to_string(), + }] + }); let dest_obj = S3Object { key: dest_key.to_string(), diff --git a/crates/fakecloud-s3/src/service/tests.rs b/crates/fakecloud-s3/src/service/tests.rs index 6b9f5369d..8ba2fcbe7 100644 --- a/crates/fakecloud-s3/src/service/tests.rs +++ b/crates/fakecloud-s3/src/service/tests.rs @@ -3855,6 +3855,291 @@ fn put_object_acl_updates_the_versioned_copy_too() { ); } +#[test] +fn bucket_owner_full_control_is_accepted_when_ownership_disables_acls() { + // AWS refuses ACLs on a BucketOwnerEnforced bucket with one exception: + // `bucket-owner-full-control`, which grants nothing the owner does not + // already have. All three object-write paths share the check, so all three + // have to honor the exception. + let svc = make_service(); + seed_bucket(&svc, "bofc"); + seed_object(&svc, "bofc", "src.txt", b"body"); + { + let mut mas = svc.state.write(); + let state = mas.default_mut(); + let b = state.buckets.get_mut("bofc").unwrap(); + b.ownership_controls = Some( + "BucketOwnerEnforced\ + " + .to_string(), + ); + } + + let allowed = svc + .resolve_write_acl_headers("123456789012", "bofc", &{ + let mut h = HeaderMap::new(); + h.insert("x-amz-acl", "bucket-owner-full-control".parse().unwrap()); + h + }) + .expect("bucket-owner-full-control must be accepted"); + assert_eq!(allowed.canned.as_deref(), Some("bucket-owner-full-control")); + + // The exception is about what the ACL GRANTS, not how it is spelled: AWS + // accepts "an equivalent form of this ACL" too, so an explicit + // full-control grant to the owner is accepted just like the canned value. + svc.resolve_write_acl_headers("123456789012", "bofc", &{ + let mut h = HeaderMap::new(); + h.insert( + "x-amz-grant-full-control", + "id=123456789012".parse().unwrap(), + ); + h + }) + .expect("an owner-only grant is the equivalent form and must be accepted"); + + // Anything that reaches past the owner is still refused, canned or granted. + for (header, value) in [ + ("x-amz-acl", "public-read"), + ( + "x-amz-grant-read", + "uri=http://acs.amazonaws.com/groups/global/AllUsers", + ), + ] { + let refused = svc.resolve_write_acl_headers("123456789012", "bofc", &{ + let mut h = HeaderMap::new(); + h.insert(header, value.parse().unwrap()); + h + }); + let err = refused.expect_err("an ACL past the owner must be refused"); + assert_eq!(err.code(), "AccessControlListNotSupported", "{err:?}"); + } +} + +#[test] +fn object_writes_refuse_a_public_acl_when_block_public_acls_is_set() { + // Only the Put*Acl paths enforced BlockPublicAcls, so `put-object + // --acl public-read` stored an AllUsers grant on a bucket that blocks + // exactly that, while `put-object-acl --acl public-read` was refused. AWS + // refuses both, and all three write paths share this resolver. + let svc = make_service(); + seed_bucket(&svc, "pab-write"); + { + let mut mas = svc.state.write(); + let state = mas.default_mut(); + let b = state.buckets.get_mut("pab-write").unwrap(); + b.public_access_block = Some( + "true\ + " + .to_string(), + ); + } + + let refused = svc.resolve_write_acl_headers("123456789012", "pab-write", &{ + let mut h = HeaderMap::new(); + h.insert("x-amz-acl", "public-read".parse().unwrap()); + h + }); + let err = refused.expect_err("a public ACL must be refused"); + assert_eq!(err.code(), "AccessDenied", "{err:?}"); + + // A non-public ACL is unaffected. + svc.resolve_write_acl_headers("123456789012", "pab-write", &{ + let mut h = HeaderMap::new(); + h.insert("x-amz-acl", "private".parse().unwrap()); + h + }) + .expect("private is not a public ACL"); +} + +#[test] +fn create_multipart_upload_reports_a_bad_acl_value_before_ownership() { + // Sharing one resolver normalized the order across the write paths: an + // invalid canned value on a BucketOwnerEnforced bucket answers + // InvalidArgument (the value is wrong whatever the bucket allows) rather + // than AccessControlListNotSupported, which is what PutObject already did. + let svc = make_service(); + seed_bucket(&svc, "mpu-order"); + { + let mut mas = svc.state.write(); + let state = mas.default_mut(); + let b = state.buckets.get_mut("mpu-order").unwrap(); + b.ownership_controls = Some( + "BucketOwnerEnforced\ + " + .to_string(), + ); + } + + let mut req = make_request(Method::POST, "/mpu-order/k.txt", &[("uploads", "")], b""); + req.headers + .insert("x-amz-acl", "pubic-read".parse().unwrap()); + assert_aws_err( + svc.create_multipart_upload("123456789012", &req, "mpu-order", "k.txt"), + "InvalidArgument", + ); + + // A valid value that reaches past the owner still reports the ownership. + let mut valid = make_request(Method::POST, "/mpu-order/k.txt", &[("uploads", "")], b""); + valid + .headers + .insert("x-amz-acl", "public-read".parse().unwrap()); + assert_aws_err( + svc.create_multipart_upload("123456789012", &valid, "mpu-order", "k.txt"), + "AccessControlListNotSupported", + ); +} + +#[test] +fn copy_object_honors_the_canned_acl_header() { + // A copy used to ignore the header entirely: `copy-object --acl public-read` + // answered 200 and produced a private object, so the caller believed the + // copy was published. + let svc = make_service(); + seed_bucket(&svc, "copy-src"); + seed_bucket(&svc, "copy-dst"); + seed_object(&svc, "copy-src", "k.txt", b"body"); + + let mut req = make_request(Method::PUT, "/copy-dst/k.txt", &[], b""); + req.headers + .insert("x-amz-copy-source", "copy-src/k.txt".parse().unwrap()); + req.headers + .insert("x-amz-acl", "public-read".parse().unwrap()); + svc.copy_object("123456789012", &req, "copy-dst", "k.txt") + .unwrap(); + + let get = make_request(Method::GET, "/copy-dst/k.txt", &[("acl", "")], b""); + let resp = svc + .get_object_acl("123456789012", &get, "copy-dst", "k.txt") + .unwrap(); + let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap(); + assert!( + body.contains("AllUsers"), + "canned ACL ignored on copy: {body}" + ); +} + +#[test] +fn copy_object_honors_grant_headers() { + let svc = make_service(); + seed_bucket(&svc, "copy-src2"); + seed_bucket(&svc, "copy-dst2"); + seed_object(&svc, "copy-src2", "k.txt", b"body"); + + let mut req = make_request(Method::PUT, "/copy-dst2/k.txt", &[], b""); + req.headers + .insert("x-amz-copy-source", "copy-src2/k.txt".parse().unwrap()); + req.headers.insert( + "x-amz-grant-read", + "uri=http://acs.amazonaws.com/groups/global/AllUsers" + .parse() + .unwrap(), + ); + svc.copy_object("123456789012", &req, "copy-dst2", "k.txt") + .unwrap(); + + let get = make_request(Method::GET, "/copy-dst2/k.txt", &[("acl", "")], b""); + let resp = svc + .get_object_acl("123456789012", &get, "copy-dst2", "k.txt") + .unwrap(); + let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap(); + assert!( + body.contains("AllUsers"), + "grant headers ignored on copy: {body}" + ); + assert!(body.contains("READ"), "{body}"); +} + +#[test] +fn copy_object_without_acl_headers_stays_private() { + // A copy is a new object, so an unspecified ACL is the default owner grant + // rather than whatever the source carried. + let svc = make_service(); + seed_bucket(&svc, "copy-src3"); + seed_bucket(&svc, "copy-dst3"); + seed_object(&svc, "copy-src3", "k.txt", b"body"); + { + let mut mas = svc.state.write(); + let state = mas.default_mut(); + let src = state.buckets.get_mut("copy-src3").unwrap(); + let obj = src.objects.get_mut("k.txt").unwrap(); + obj.acl_grants = canned_acl_grants_for_object("public-read", "123456789012"); + } + + let mut req = make_request(Method::PUT, "/copy-dst3/k.txt", &[], b""); + req.headers + .insert("x-amz-copy-source", "copy-src3/k.txt".parse().unwrap()); + svc.copy_object("123456789012", &req, "copy-dst3", "k.txt") + .unwrap(); + + let get = make_request(Method::GET, "/copy-dst3/k.txt", &[("acl", "")], b""); + let resp = svc + .get_object_acl("123456789012", &get, "copy-dst3", "k.txt") + .unwrap(); + let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap(); + assert!( + !body.contains("AllUsers"), + "the copy inherited the source's public grant: {body}" + ); +} + +#[test] +fn copy_object_rejects_conflicting_and_disallowed_acls() { + let svc = make_service(); + seed_bucket(&svc, "copy-src4"); + seed_bucket(&svc, "copy-dst4"); + seed_object(&svc, "copy-src4", "k.txt", b"body"); + + // A typo used to be accepted here while PutObject refused it. + let mut bad = make_request(Method::PUT, "/copy-dst4/k.txt", &[], b""); + bad.headers + .insert("x-amz-copy-source", "copy-src4/k.txt".parse().unwrap()); + bad.headers + .insert("x-amz-acl", "pubic-read".parse().unwrap()); + assert_aws_err( + svc.copy_object("123456789012", &bad, "copy-dst4", "k.txt"), + "InvalidArgument", + ); + + // Canned plus grants names the ACL two ways. + let mut both = make_request(Method::PUT, "/copy-dst4/k.txt", &[], b""); + both.headers + .insert("x-amz-copy-source", "copy-src4/k.txt".parse().unwrap()); + both.headers.insert("x-amz-acl", "private".parse().unwrap()); + both.headers.insert( + "x-amz-grant-read", + "uri=http://acs.amazonaws.com/groups/global/AllUsers" + .parse() + .unwrap(), + ); + assert_aws_err( + svc.copy_object("123456789012", &both, "copy-dst4", "k.txt"), + "InvalidRequest", + ); + + // And a destination whose ownership disables ACLs refuses them. + { + let mut mas = svc.state.write(); + let state = mas.default_mut(); + let b = state.buckets.get_mut("copy-dst4").unwrap(); + b.ownership_controls = Some( + "BucketOwnerEnforced\ + " + .to_string(), + ); + } + let mut enforced = make_request(Method::PUT, "/copy-dst4/k.txt", &[], b""); + enforced + .headers + .insert("x-amz-copy-source", "copy-src4/k.txt".parse().unwrap()); + enforced + .headers + .insert("x-amz-acl", "public-read".parse().unwrap()); + assert_aws_err( + svc.copy_object("123456789012", &enforced, "copy-dst4", "k.txt"), + "AccessControlListNotSupported", + ); +} + #[test] fn put_object_acl_rejects_an_unresolvable_grantee() { // The object paths share parse_grant_headers, which drops what it cannot diff --git a/website/content/docs/services/s3.md b/website/content/docs/services/s3.md index efa446855..4377f4e9f 100644 --- a/website/content/docs/services/s3.md +++ b/website/content/docs/services/s3.md @@ -18,7 +18,7 @@ fakecloud implements **107 of 107** S3 operations at 100% Smithy conformance. - **Bucket subresources** — policy, CORS, lifecycle, logging, website, public access block, object lock, replication, ownership, inventory, encryption, accelerate, request payment, tagging - **Bucket tags at create time** — `CreateBucket` honors the `Tags` tag set inside `CreateBucketConfiguration`, so a bucket comes out of `CreateBucket` already tagged and `GetBucketTagging` returns the set without a follow-up `PutBucketTagging`. This is the path the AWS Terraform provider (6.x) uses for `aws_s3_bucket`'s `tags`. Duplicate keys and `aws:`-prefixed keys are rejected with `InvalidTag` and no bucket is created, and the tags persist across a restart like any other bucket subresource. Under `--iam`, a tagged create is authorized as `s3:CreateBucket` **and** `s3:TagResource`, as on AWS — a grant of `s3:CreateBucket` alone still creates untagged buckets — and the tag set populates `aws:RequestTag/` / `aws:TagKeys` so policies can condition on it. - **CORS** — `PutBucketCors` rules drive real browser CORS: the `OPTIONS` preflight is matched on origin, `Access-Control-Request-Method` and every header in `Access-Control-Request-Headers` before returning `Access-Control-Allow-Origin/-Methods/-Headers` and `Access-Control-Max-Age`, and an actual request carrying `Origin` gets `Access-Control-Allow-Origin` plus `Access-Control-Expose-Headers`. Actual requests are matched on method as well as origin, so a rule allowing only `GET` hands no allow-origin to a `DELETE`. Multipart operations are CORS-evaluated like any other request, so browser multipart upload works. Errors from S3 itself are evaluated too, so a 404 `NoSuchKey` fetched by an allowed origin carries `Access-Control-Allow-Origin` and reaches the caller as a real 404 rather than an opaque CORS failure. Denials raised before the request reaches S3 (SigV4 or IAM, under `--verify-sigv4` / `--iam`) are not CORS-decorated, so under auth enforcement a rejected browser request still surfaces as an opaque failure. `Vary: Origin, Access-Control-Request-Headers, Access-Control-Request-Method` keeps a cache from reusing one origin's response for a different origin. **Every** preflight response carries it, since a preflight's outcome always turns on `Origin`: `200` when the rules allow it, `403 AccessForbidden` when they do not, and `400 BadRequest` ("Insufficient information. Origin request header needed.") when the request omits `Origin` altogether. On the actual-request path it goes on every response evaluated against a bucket's CORS config, successes and errors alike, while a request without `Origin` is not CORS-evaluated and is served normally with neither header, as on S3. A concrete allowed origin also gets `Access-Control-Allow-Credentials: true`, so `credentials: 'include'` works. Note that an actual request declares no headers, so it can match an earlier, broader rule than the preflight did: if a `*` rule precedes the concrete one, the actual response carries `Access-Control-Allow-Origin: *` and no credentials, and the browser blocks a credentialed request that just passed preflight. That is S3's behavior too — order the concrete rule first. `AllowedOrigin` and `AllowedHeader` each take one `*` anywhere in the value (`https://*.example.com`, `x-amz-*`). `ExposeHeader` takes no wildcard at all, as on S3. A rule missing `AllowedMethods` or `AllowedOrigins`, carrying more than one wildcard in a value, or setting a non-numeric `MaxAgeSeconds`, is rejected at write time rather than silently matching nothing. CloudFormation's `CorsConfiguration` goes through the same validation, so a template cannot deploy a bucket whose preflights all fail. -- **Bucket and object ACLs** — a canned `x-amz-acl`, the `x-amz-grant-*` headers and an `AccessControlPolicy` body are mutually exclusive, as on S3: naming an ACL two ways is `InvalidRequest` rather than one of them silently winning. Canned values are checked against the set S3 accepts for the target (bucket ACLs take `private`, `public-read`, `public-read-write`, `authenticated-read`, `aws-exec-read` and `log-delivery-write`; `bucket-owner-read` and `bucket-owner-full-control` are object-only), and a grant naming no resolvable grantee is rejected instead of being stored as an entry that matches nobody. `emailAddress=` grantees are kept as `AmazonCustomerByEmail` so they round-trip, but they are not resolved to a canonical user and so convey no access. A bucket whose `ObjectOwnership` is `BucketOwnerEnforced` rejects every ACL-setting call, `CreateMultipartUpload` included. +- **Bucket and object ACLs** — a canned `x-amz-acl`, the `x-amz-grant-*` headers and an `AccessControlPolicy` body are mutually exclusive, as on S3: naming an ACL two ways is `InvalidRequest` rather than one of them silently winning. Canned values are checked against the set S3 accepts for the target (bucket ACLs take `private`, `public-read`, `public-read-write`, `authenticated-read`, `aws-exec-read` and `log-delivery-write`; `bucket-owner-read` and `bucket-owner-full-control` are object-only), and a grant naming no resolvable grantee is rejected instead of being stored as an entry that matches nobody. `emailAddress=` grantees are kept as `AmazonCustomerByEmail` so they round-trip, but they are not resolved to a canonical user and so convey no access. A bucket whose `ObjectOwnership` is `BucketOwnerEnforced` rejects every ACL-setting call, `CreateMultipartUpload` and `CopyObject` included. The object-write paths (`PutObject`, `CopyObject`, `CreateMultipartUpload`) make one exception, as AWS does: an ACL that grants nothing beyond the bucket owner is accepted, whether spelled `x-amz-acl: bucket-owner-full-control` or as the equivalent explicit grant. `PutObjectAcl` and `PutBucketAcl` reject every ACL on such a bucket. `CopyObject` honors `x-amz-acl` and the `x-amz-grant-*` headers for the destination object under the same rules; a copy is a new object, so an unspecified ACL is the default owner grant rather than whatever the source carried. - **Object Lock** — legal hold, retention modes. A bucket created with `x-amz-bucket-object-lock-enabled` keeps its lock configuration (and its canned ACL and object-ownership rule) across a restart in persistent mode, so retention stays enforced. - **Website hosting** — index/error documents, redirect rules - **Access Points** — full control plane (`CreateAccessPoint`, `GetAccessPoint`, `DeleteAccessPoint`, `ListAccessPoints`) via the `s3-control` host prefix; data plane traffic to `s3-accesspoint.` resolves the alias to its underlying bucket so standard S3 operations work unchanged. From cfb010feb33aeb4ec8c0a5e08416d05ea3f0748f Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 27 Sep 2026 20:01:12 -0300 Subject: [PATCH 2/2] fix(s3): narrow the BucketOwnerEnforced exception to the ACL AWS names Review caught a loosening relative to main. The exception judged the grants a request RESOLVES to, but `canned_acl_grants` collapses several distinct canned values onto the same owner-only grant, so `private`, `bucket-owner-read` and `aws-exec-read` all passed a gate that previously refused them -- the last two only because their real grantees are not modeled. The model is precise about the one exception: such a bucket "only accept[s] PUT requests that don't specify an ACL or PUT requests that specify bucket owner full control ACLs, such as the bucket-owner-full-control canned ACL or an equivalent form of this ACL expressed in the XML format". So the test is the ACL the request NAMES: the canned `bucket-owner-full-control`, or explicit grants giving the owner full control and nobody anything. `private` is neither "no ACL" nor "bucket owner full control", and AWS refuses it there. Also: a present-but-blank `x-amz-acl` is now treated as absent, the rule `has_grant_headers` already applies to the `x-amz-grant-*` family and for the same reason -- it is what a client sends for an unset config field, and testing presence alone turned it into a hard 400 on every ACL-accepting write. Extending that 400 to CopyObject was this PR's doing; the fix removes it from all three. - the refusal loop covers all four values, so the resolved-shape predicate cannot come back unnoticed - a handler-level test, since the helper-level ones prove nothing about whether a write path calls the resolver before storing the object - the vacuous assertion that echoed back the header the function was handed is replaced by one on the grants it resolves to - docs: the narrowed exception, and the BlockPublicAcls enforcement this PR added at write time --- crates/fakecloud-s3/src/service/mod.rs | 58 +++++++++---- crates/fakecloud-s3/src/service/tests.rs | 103 ++++++++++++++++++++++- website/content/docs/services/s3.md | 4 +- 3 files changed, 143 insertions(+), 22 deletions(-) diff --git a/crates/fakecloud-s3/src/service/mod.rs b/crates/fakecloud-s3/src/service/mod.rs index 67cc644e3..79403b10b 100644 --- a/crates/fakecloud-s3/src/service/mod.rs +++ b/crates/fakecloud-s3/src/service/mod.rs @@ -249,9 +249,15 @@ impl S3Service { bucket: &str, headers: &HeaderMap, ) -> Result { + // A present-but-blank value is treated as absent, the same rule + // `has_grant_headers` applies to the `x-amz-grant-*` family and for the + // same reason: it is what a client sends for an unset config field, and + // testing presence alone turned that into a hard 400 on every + // ACL-accepting write. let canned = headers .get("x-amz-acl") .and_then(|v| v.to_str().ok()) + .filter(|s| !s.trim().is_empty()) .map(|s| s.to_string()); if let Some(acl) = canned.as_deref() { validate_object_canned_acl(acl)?; @@ -268,28 +274,46 @@ impl S3Service { "Specifying both Canned ACLs and Header Grants is not allowed", )); } - // What the request actually asks for, so both checks below judge grants - // rather than header spellings. + // The grants the request resolves to, which is what the public-ACL check + // below has to judge. let requested = match (&grants, canned.as_deref()) { (Some(g), _) => Some(g.clone()), (None, Some(acl)) => Some(canned_acl_grants_for_object(acl, account_id)), (None, None) => None, }; - let reaches_past_owner = requested.as_deref().is_some_and(|g| { - g.iter().any(|grant| { - grant.permission != "FULL_CONTROL" - || grant.grantee_type != "CanonicalUser" - || grant.grantee_id.as_deref() != Some(account_id) - }) - }); - - // BucketOwnerEnforced disables object ACLs, so asking for one is - // AccessControlListNotSupported -- except an ACL that grants nothing - // beyond the owner, which AWS still accepts there. That covers the - // canned `bucket-owner-full-control` AND, as the AWS wording says, "an - // equivalent form of this ACL", so `x-amz-grant-full-control=id=` - // is accepted too. A write with no ACL header is unaffected. - if reaches_past_owner && self.bucket_owner_enforced(account_id, bucket) { + + // BucketOwnerEnforced disables object ACLs. The model is precise about + // the one exception: such a bucket "only accept[s] PUT requests that + // don't specify an ACL or PUT requests that specify bucket owner full + // control ACLs, such as the bucket-owner-full-control canned ACL or an + // equivalent form of this ACL expressed in the XML format". + // + // So the test is the ACL the request NAMES, not the grants it resolves + // to. Judging by resolved shape would also accept `private`, + // `bucket-owner-read` and `aws-exec-read`, all of which AWS refuses + // here: `private` is owner-only by definition, and the other two + // collapse onto an owner-only grant only because their real grantees + // are not modeled. `private` in particular is neither "no ACL" nor + // "bucket owner full control". + let asks_for_owner_full_control = match (&grants, canned.as_deref()) { + // "an equivalent form of this ACL": explicit grants that give the + // owner full control and nobody anything. + (Some(g), _) => { + !g.is_empty() + && g.iter().all(|grant| { + grant.permission == "FULL_CONTROL" + && grant.grantee_type == "CanonicalUser" + && grant.grantee_id.as_deref() == Some(account_id) + }) + } + (None, Some(acl)) => acl == "bucket-owner-full-control", + (None, None) => false, + }; + let specifies_an_acl = grants.is_some() || canned.is_some(); + if specifies_an_acl + && !asks_for_owner_full_control + && self.bucket_owner_enforced(account_id, bucket) + { return Err(AwsServiceError::aws_error( StatusCode::BAD_REQUEST, "AccessControlListNotSupported", diff --git a/crates/fakecloud-s3/src/service/tests.rs b/crates/fakecloud-s3/src/service/tests.rs index 8ba2fcbe7..d42b11948 100644 --- a/crates/fakecloud-s3/src/service/tests.rs +++ b/crates/fakecloud-s3/src/service/tests.rs @@ -3882,7 +3882,16 @@ fn bucket_owner_full_control_is_accepted_when_ownership_disables_acls() { h }) .expect("bucket-owner-full-control must be accepted"); - assert_eq!(allowed.canned.as_deref(), Some("bucket-owner-full-control")); + // Assert what it resolves to, not that the function echoed back the header + // it was handed: the whole point is that the owner ends up with full + // control and nobody else appears. + let grants = allowed + .grants_for("123456789012") + .expect("the canned value resolves to grants"); + assert_eq!(grants.len(), 1, "{grants:?}"); + assert_eq!(grants[0].permission, "FULL_CONTROL"); + assert_eq!(grants[0].grantee_type, "CanonicalUser"); + assert_eq!(grants[0].grantee_id.as_deref(), Some("123456789012")); // The exception is about what the ACL GRANTS, not how it is spelled: AWS // accepts "an equivalent form of this ACL" too, so an explicit @@ -3897,9 +3906,19 @@ fn bucket_owner_full_control_is_accepted_when_ownership_disables_acls() { }) .expect("an owner-only grant is the equivalent form and must be accepted"); - // Anything that reaches past the owner is still refused, canned or granted. + // Every other ACL is refused. The model's wording is narrow -- such a + // bucket "only accept[s] PUT requests that don't specify an ACL or PUT + // requests that specify bucket owner full control ACLs" -- so the three + // values that merely RESOLVE to owner-only grants are refused too: + // `private` is owner-only by definition, and `bucket-owner-read` and + // `aws-exec-read` collapse onto an owner-only grant only because their real + // grantees are not modeled. Judging the resolved shape rather than the ACL + // the request names accepted all three. for (header, value) in [ ("x-amz-acl", "public-read"), + ("x-amz-acl", "private"), + ("x-amz-acl", "bucket-owner-read"), + ("x-amz-acl", "aws-exec-read"), ( "x-amz-grant-read", "uri=http://acs.amazonaws.com/groups/global/AllUsers", @@ -3910,9 +3929,87 @@ fn bucket_owner_full_control_is_accepted_when_ownership_disables_acls() { h.insert(header, value.parse().unwrap()); h }); - let err = refused.expect_err("an ACL past the owner must be refused"); + let err = refused.expect_err(&format!("{header}: {value} must be refused")); assert_eq!(err.code(), "AccessControlListNotSupported", "{err:?}"); } + + // A write with no ACL header at all is what such a bucket is for. + svc.resolve_write_acl_headers("123456789012", "bofc", &HeaderMap::new()) + .expect("a write specifying no ACL must be accepted"); +} + +#[tokio::test] +async fn put_object_honors_the_bucket_owner_enforced_rules_end_to_end() { + // The three write paths share the resolver, but the helper-level test above + // proves nothing about whether a handler actually calls it before writing. + let svc = make_service(); + seed_bucket(&svc, "bofc-e2e"); + { + let mut mas = svc.state.write(); + let state = mas.default_mut(); + let b = state.buckets.get_mut("bofc-e2e").unwrap(); + b.ownership_controls = Some( + "BucketOwnerEnforced\ + " + .to_string(), + ); + } + + let mut refused = make_request(Method::PUT, "/bofc-e2e/k", &[], b"body"); + refused + .headers + .insert("x-amz-acl", "private".parse().unwrap()); + let err = match svc + .put_object("123456789012", &refused, "bofc-e2e", "k") + .await + { + Ok(_) => panic!("`--acl private` is not one of the two ACLs such a bucket accepts"), + Err(e) => e, + }; + assert_eq!(err.code(), "AccessControlListNotSupported", "{err:?}"); + assert!( + svc.get_object( + "123456789012", + &make_request(Method::GET, "/bofc-e2e/k", &[], b""), + "bofc-e2e", + "k" + ) + .is_err(), + "the refused write must not have stored the object" + ); + + let mut allowed = make_request(Method::PUT, "/bofc-e2e/ok", &[], b"body"); + allowed + .headers + .insert("x-amz-acl", "bucket-owner-full-control".parse().unwrap()); + svc.put_object("123456789012", &allowed, "bofc-e2e", "ok") + .await + .expect("bucket-owner-full-control must be accepted"); +} + +#[tokio::test] +async fn a_blank_acl_header_is_treated_as_no_acl() { + // A present-but-blank header is what a client sends for an unset config + // field; the `x-amz-grant-*` family already treats it as absent, and + // 400-ing on the canned one made that inconsistent. + let svc = make_service(); + seed_bucket(&svc, "blank-acl"); + + let resolved = svc + .resolve_write_acl_headers("123456789012", "blank-acl", &{ + let mut h = HeaderMap::new(); + h.insert("x-amz-acl", "".parse().unwrap()); + h + }) + .expect("a blank canned ACL asks for nothing, so it is not an error"); + assert_eq!(resolved.canned, None); + assert!(resolved.grants_for("123456789012").is_none()); + + let mut req = make_request(Method::PUT, "/blank-acl/k", &[], b"body"); + req.headers.insert("x-amz-acl", "".parse().unwrap()); + svc.put_object("123456789012", &req, "blank-acl", "k") + .await + .expect("a blank canned ACL must not fail the write"); } #[test] diff --git a/website/content/docs/services/s3.md b/website/content/docs/services/s3.md index 4377f4e9f..8644b8211 100644 --- a/website/content/docs/services/s3.md +++ b/website/content/docs/services/s3.md @@ -18,13 +18,13 @@ fakecloud implements **107 of 107** S3 operations at 100% Smithy conformance. - **Bucket subresources** — policy, CORS, lifecycle, logging, website, public access block, object lock, replication, ownership, inventory, encryption, accelerate, request payment, tagging - **Bucket tags at create time** — `CreateBucket` honors the `Tags` tag set inside `CreateBucketConfiguration`, so a bucket comes out of `CreateBucket` already tagged and `GetBucketTagging` returns the set without a follow-up `PutBucketTagging`. This is the path the AWS Terraform provider (6.x) uses for `aws_s3_bucket`'s `tags`. Duplicate keys and `aws:`-prefixed keys are rejected with `InvalidTag` and no bucket is created, and the tags persist across a restart like any other bucket subresource. Under `--iam`, a tagged create is authorized as `s3:CreateBucket` **and** `s3:TagResource`, as on AWS — a grant of `s3:CreateBucket` alone still creates untagged buckets — and the tag set populates `aws:RequestTag/` / `aws:TagKeys` so policies can condition on it. - **CORS** — `PutBucketCors` rules drive real browser CORS: the `OPTIONS` preflight is matched on origin, `Access-Control-Request-Method` and every header in `Access-Control-Request-Headers` before returning `Access-Control-Allow-Origin/-Methods/-Headers` and `Access-Control-Max-Age`, and an actual request carrying `Origin` gets `Access-Control-Allow-Origin` plus `Access-Control-Expose-Headers`. Actual requests are matched on method as well as origin, so a rule allowing only `GET` hands no allow-origin to a `DELETE`. Multipart operations are CORS-evaluated like any other request, so browser multipart upload works. Errors from S3 itself are evaluated too, so a 404 `NoSuchKey` fetched by an allowed origin carries `Access-Control-Allow-Origin` and reaches the caller as a real 404 rather than an opaque CORS failure. Denials raised before the request reaches S3 (SigV4 or IAM, under `--verify-sigv4` / `--iam`) are not CORS-decorated, so under auth enforcement a rejected browser request still surfaces as an opaque failure. `Vary: Origin, Access-Control-Request-Headers, Access-Control-Request-Method` keeps a cache from reusing one origin's response for a different origin. **Every** preflight response carries it, since a preflight's outcome always turns on `Origin`: `200` when the rules allow it, `403 AccessForbidden` when they do not, and `400 BadRequest` ("Insufficient information. Origin request header needed.") when the request omits `Origin` altogether. On the actual-request path it goes on every response evaluated against a bucket's CORS config, successes and errors alike, while a request without `Origin` is not CORS-evaluated and is served normally with neither header, as on S3. A concrete allowed origin also gets `Access-Control-Allow-Credentials: true`, so `credentials: 'include'` works. Note that an actual request declares no headers, so it can match an earlier, broader rule than the preflight did: if a `*` rule precedes the concrete one, the actual response carries `Access-Control-Allow-Origin: *` and no credentials, and the browser blocks a credentialed request that just passed preflight. That is S3's behavior too — order the concrete rule first. `AllowedOrigin` and `AllowedHeader` each take one `*` anywhere in the value (`https://*.example.com`, `x-amz-*`). `ExposeHeader` takes no wildcard at all, as on S3. A rule missing `AllowedMethods` or `AllowedOrigins`, carrying more than one wildcard in a value, or setting a non-numeric `MaxAgeSeconds`, is rejected at write time rather than silently matching nothing. CloudFormation's `CorsConfiguration` goes through the same validation, so a template cannot deploy a bucket whose preflights all fail. -- **Bucket and object ACLs** — a canned `x-amz-acl`, the `x-amz-grant-*` headers and an `AccessControlPolicy` body are mutually exclusive, as on S3: naming an ACL two ways is `InvalidRequest` rather than one of them silently winning. Canned values are checked against the set S3 accepts for the target (bucket ACLs take `private`, `public-read`, `public-read-write`, `authenticated-read`, `aws-exec-read` and `log-delivery-write`; `bucket-owner-read` and `bucket-owner-full-control` are object-only), and a grant naming no resolvable grantee is rejected instead of being stored as an entry that matches nobody. `emailAddress=` grantees are kept as `AmazonCustomerByEmail` so they round-trip, but they are not resolved to a canonical user and so convey no access. A bucket whose `ObjectOwnership` is `BucketOwnerEnforced` rejects every ACL-setting call, `CreateMultipartUpload` and `CopyObject` included. The object-write paths (`PutObject`, `CopyObject`, `CreateMultipartUpload`) make one exception, as AWS does: an ACL that grants nothing beyond the bucket owner is accepted, whether spelled `x-amz-acl: bucket-owner-full-control` or as the equivalent explicit grant. `PutObjectAcl` and `PutBucketAcl` reject every ACL on such a bucket. `CopyObject` honors `x-amz-acl` and the `x-amz-grant-*` headers for the destination object under the same rules; a copy is a new object, so an unspecified ACL is the default owner grant rather than whatever the source carried. +- **Bucket and object ACLs** — a canned `x-amz-acl`, the `x-amz-grant-*` headers and an `AccessControlPolicy` body are mutually exclusive, as on S3: naming an ACL two ways is `InvalidRequest` rather than one of them silently winning. Canned values are checked against the set S3 accepts for the target (bucket ACLs take `private`, `public-read`, `public-read-write`, `authenticated-read`, `aws-exec-read` and `log-delivery-write`; `bucket-owner-read` and `bucket-owner-full-control` are object-only), and a grant naming no resolvable grantee is rejected instead of being stored as an entry that matches nobody. `emailAddress=` grantees are kept as `AmazonCustomerByEmail` so they round-trip, but they are not resolved to a canonical user and so convey no access. A bucket whose `ObjectOwnership` is `BucketOwnerEnforced` rejects every ACL-setting call, `CreateMultipartUpload` and `CopyObject` included. The object-write paths (`PutObject`, `CopyObject`, `CreateMultipartUpload`) make one exception, as AWS does: a bucket-owner-full-control ACL is accepted, whether spelled `x-amz-acl: bucket-owner-full-control` or as the equivalent explicit grant (`x-amz-grant-full-control` naming the owner and nobody else). The exception is that ACL specifically, not any ACL that happens to resolve to owner-only grants: `--acl private` is still `AccessControlListNotSupported` there, since AWS accepts only a request that specifies no ACL at all or one that specifies bucket-owner-full-control. `PutObjectAcl` and `PutBucketAcl` reject every ACL on such a bucket. `CopyObject` honors `x-amz-acl` and the `x-amz-grant-*` headers for the destination object under the same rules; a copy is a new object, so an unspecified ACL is the default owner grant rather than whatever the source carried. - **Object Lock** — legal hold, retention modes. A bucket created with `x-amz-bucket-object-lock-enabled` keeps its lock configuration (and its canned ACL and object-ownership rule) across a restart in persistent mode, so retention stays enforced. - **Website hosting** — index/error documents, redirect rules - **Access Points** — full control plane (`CreateAccessPoint`, `GetAccessPoint`, `DeleteAccessPoint`, `ListAccessPoints`) via the `s3-control` host prefix; data plane traffic to `s3-accesspoint.` resolves the alias to its underlying bucket so standard S3 operations work unchanged. - **S3 Select** — real `SelectObjectContent` over CSV/JSON via EventStream framing (`Records`, `Stats`, `End` messages). - **Object Lambda** — `WriteGetObjectResponse` actually stores the transformed body + metadata against the original request token; the next `GetObject` on the access point returns the transformed payload. -- **Public Access Block** — `IgnorePublicAcls` is enforced on `GetObject`; public-read ACL grants are ignored when the bucket-level block is set. +- **Public Access Block** — `IgnorePublicAcls` is enforced on `GetObject`; public-read ACL grants are ignored when the bucket-level block is set. `BlockPublicAcls` is enforced when the ACL is written, on the object-write paths as well as `PutObjectAcl` / `PutBucketAcl`: `put-object --acl public-read` against a bucket with `BlockPublicAcls` is `AccessDenied`, as on AWS, rather than quietly storing the `AllUsers` grant the block exists to prevent. - **Anonymous access** — unsigned `GET`/`HEAD` requests (`GET /bucket/key` with no SigV4) reach S3 directly, so you can serve static assets / CDN origins straight from a bucket. In the default (no-IAM) mode they're served permissively; under `--iam soft|strict` an anonymous request is authorized only when a bucket policy grants the action to `Principal:"*"` or a public-read ACL (`AllUsers`) is set, denied otherwise. - **ACL ownership modes** — `BucketOwnerEnforced` disables ACLs entirely (all ACL writes rejected, reads return owner-only).