Repository navigation
fix(s3): honor the ACL headers on CopyObject, and share one resolver - #2565
Merged
Merged
Conversation
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.
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
vieiralucas
added a commit
that referenced
this pull request
Sep 28, 2026
…where The e2e added last commit passed against the bug it was written for. Its Deny used `StringNotEquals`, and an unpopulated condition key safe-fails EVERY non-`IfExists`, non-`ForAllValues` operator to false, so the Deny did not apply on the broken code either and both assertions held with or without the fix. The claim "verified failing without its fix" was true of the unit test and false of this one. `Null` is the one operator that reads an unpopulated key AS null, so it is the one that can see a per-action context that carries no tags. With the Deny rewritten that way, the test fails on the unfixed code and passes on the fixed one -- verified both directions. Blank values, which the same commit handled in two places out of three: - `s3:x-amz-acl` was emitted for a present-but-empty header while both neighbouring families skipped blanks, and while the write paths treat such a header as absent. A `Null`- or `StringEquals`-gated policy therefore denied a request that asks for no ACL at all. - `x-amz-object-ownership` demanded `s3:PutBucketOwnershipControls` on a blank value, whose condition key is deliberately not emitted -- so a `StringEquals`-gated Allow answered 403 where the handler answers 400. Also: `security.md` claimed `s3:x-amz-object-ownership` covers `PutBucketOwnershipControls`, which carries that value in its XML body rather than a header, so the key is never populated there -- the guardrail the row promises would match nothing, which is the hazard this PR exists to remove. The row now says `CreateBucket` and why. And two comments: the `BUCKET_CANNED_ACLS` doc still said `create_bucket` special-cases `aws-exec-read` (it is `acl_reaches_past_owner` now, driving both callers), and `acl_reaches_past_owner` now says explicitly that it is NOT the same question as the object-write check added by #2565, so the two are not "unified" later: a BucketOwnerEnforced bucket refuses an ACL granting another account anything, while a BucketOwnerEnforced bucket accepts an object PUT only with no ACL or bucket-owner-full-control, which is not even a legal bucket ACL.
vieiralucas
added a commit
to Sorttech/fakecloud
that referenced
this pull request
Sep 29, 2026
…wner Follow-up to faiscadev#2565, found by reviewing that PR's own follow-up commit after it had merged. A BucketOwnerEnforced bucket accepts an object write that specifies no ACL, or one that specifies bucket owner full control -- "an equivalent form of this ACL" included, per the model. The check compared the grantee against the CALLER instead of the bucket owner. Those coincide for a bucket the caller created, so the common path was right, and they diverge for a bucket persisted by another account: the loader hydrates every persisted bucket into the configured default account while each keeps its own stored `acl_owner_id`. There: - `x-amz-grant-full-control: id=<caller>` was accepted, storing an object ACL that gives the bucket owner NOTHING on a bucket whose whole purpose is that the owner controls every object; - `x-amz-grant-full-control: id=<owner>`, which AWS accepts, was refused. `S3Store`-side nothing changes; the resolver now asks the bucket for its `acl_owner_id` and falls back to the caller when the bucket is not in memory, which is the case where the ACL headers are validated before the operation reports the missing bucket. The test asserted the bug. It granted full control to the caller while `seed_bucket` owns the bucket as "owner", so it passed only because of the wrong comparison -- fixing the code would have failed it. It now asserts both directions against the real owner, and a grant to anyone else (the caller included) is `AccessControlListNotSupported`. Also reverts the blank `x-amz-acl` leniency from that same commit. `""` is not a member of `ObjectCannedACL`; `main` answered 400 for it on PutObject and CreateMultipartUpload before faiscadev#2565, and PutObjectAcl, PutBucketAcl and CreateBucket all still do. Treating it as "no ACL asked for" on the object-write paths alone made one operation disagree with four rather than fixing anything, and the `x-amz-grant-*` precedent cited for it is a different case: an empty value there names no grantee, not an invalid ACL. Its test now pins the agreement instead of the exception. - drops a `!g.is_empty()` guard that could never be false (`resolved_grant_headers` errors on an empty clause set rather than returning one) and read as though it were load-bearing - docs: the "ACL ownership modes" bullet still said BucketOwnerEnforced disables ACLs entirely, which the exception falsified
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
CopyObjectread neitherx-amz-aclnor thex-amz-grant-*headers and hardcoded the destination's ACL to the owner's FULL_CONTROL. Socopy-object --acl public-readreturned 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 whoseObjectOwnershipdisables them, and the only one where--acl pubic-read(a typo) was silently ignored rather than refused.The destination now takes its ACL from the request. 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".
This was the first of the three gaps documented as out of scope in #2558.
One resolver instead of three copies
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
InvalidArgumentrather thanInvalidRequest. 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.S3Service::resolve_write_acl_headersnow holds that order once, andPutObject,CreateMultipartUploadandCopyObjectall call it. Having one copy let two divergences be fixed in one place rather than three:BucketOwnerEnforcedbucket — spelledx-amz-acl: bucket-owner-full-controlor, in the AWS wording, "an equivalent form of this ACL", so an explicit full-control grant to the owner counts too. All three paths refused both.BlockPublicAclswas enforced only byPutObjectAcl/PutBucketAcl, soput-object --acl public-readstored anAllUsersgrant on a bucket that blocks exactly that, whileput-object-acl --acl public-readwas refused. AWS refuses both.One normalization worth flagging:
CreateMultipartUploadchecked ownership before validating the canned value, so an invalid value on aBucketOwnerEnforcedbucket answeredAccessControlListNotSupported. It now answersInvalidArgument, matching whatPutObjectalready did — the value is wrong whatever the bucket allows. Both orders are pinned by tests.Test plan
BucketOwnerEnforceddestination; the owner-only exception accepted both as the canned value and as the equivalent explicit grant, with anything reaching past the owner still refused;BlockPublicAclsrefusing a public ACL at write time whileprivatepasses; and the multipart error-code ordering pinned in both directions.copy_object_honors_*tests fail.fakecloud-s3486 passed; e2es3,s3_anonymous_access,s3_copy_source_if_match,s3_persistenceall green (127);fakecloud-conformance --test s351 passed; the s3 probe reports 4040/4068 variants, unchanged.cargo clippy --all-targets -- -D warningsandcargo fmt --checkclean.Surface sync
website/content/docs/services/s3.md: the ACL bullet now coversCopyObject, and states the owner-only exception and that it applies to the object-write paths rather than toPutObjectAcl/PutBucketAcl./_fakecloud/*surface.conformance-baseline.json, the ops index, the README headline and the repo description all stay as they are.Summary by cubic
CopyObjectnow honors thex-amz-aclandx-amz-grant-*headers instead of hardcoding the destination's ACL to the owner's FULL_CONTROL, socopy-object --acl public-readno longer returns 200 while producing a private object. A copy is a new object, so an unspecified ACL stays the default owner grant rather than inheriting the source's.The three object-write paths (
PutObject,CreateMultipartUpload,CopyObject) each kept their own copy of the same four ACL checks; they now share one resolver,resolve_write_acl_headers, which fixed three divergences. OnBucketOwnerEnforcedbuckets the resolver accepts only the one ACL AWS names — the cannedbucket-owner-full-controlor the equivalent explicit grants giving the owner full control and nobody anything — rather than any ACL that resolves to owner-only grants;private,bucket-owner-readandaws-exec-readare still refused.BlockPublicAclsnow refuses a public ACL at write time on all three paths rather than only throughPutObjectAcl/PutBucketAcl.CreateMultipartUploaderror order is normalized too: an invalid canned value on aBucketOwnerEnforcedbucket reportsInvalidArgumentinstead ofAccessControlListNotSupported, and a present-but-blankx-amz-aclheader is treated as absent, like thex-amz-grant-*family.Written for commit cfb010f. Summary will update on new commits.