diff --git a/crates/fakecloud-s3/src/service/mod.rs b/crates/fakecloud-s3/src/service/mod.rs index 3a8ccbb23..79403b10b 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,121 @@ 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 { + // 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)?; + } + 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", + )); + } + // 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, + }; + + // 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", + "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..d42b11948 100644 --- a/crates/fakecloud-s3/src/service/tests.rs +++ b/crates/fakecloud-s3/src/service/tests.rs @@ -3855,6 +3855,388 @@ 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 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 + // 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"); + + // 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", + ), + ] { + 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(&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] +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..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` 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: 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).