fix(s3): authorize a configuring CreateBucket per setting, and populate the ACL condition keys - #2566
Merged
Merged
Conversation
…te the ACL condition keys CreateBucket was authorized as s3:CreateBucket alone (plus s3:TagResource for a tag set), even though it applies x-amz-acl / x-amz-grant-*, x-amz-object-ownership and object-lock enablement. On AWS each of those needs its own permission. This got worse when those settings started persisting: a principal holding only s3:CreateBucket could create a `public-read` bucket that used to lose the grant on restart and now keeps it, so a transient gap became durable over-permission. The create now returns one action per setting the request configures, following the permissions section of CreateBucket in the vendored model -- s3:PutBucketAcl, s3:PutBucketOwnershipControls, and s3:PutBucketObjectLockConfiguration plus s3:PutBucketVersioning (object lock turns versioning on, so AWS wants both). Dispatch already requires every returned action to be allowed, so a principal with only s3:CreateBucket can still create a plain bucket. The s3:x-amz-acl and s3:x-amz-grant-* condition keys are also populated now. They are how a policy pins down WHICH ACL a write may ask for, and while they were never emitted a guardrail like `Deny s3:CreateBucket when s3:x-amz-acl != private` matched nothing and silently authorized exactly what it was written to block. They are emitted from the headers on any request that carries them rather than gated on an action list that would drift, and an absent header emits nothing so a condition on it safe-fails to "does not apply". E2E covers the least-privilege principal being refused and then unblocked for both the ACL and the object-lock cases, and a condition-keyed policy admitting `private` while denying `public-read`. The ACL rule follows that documentation precisely: an ACL set to public-read, public-read-write, authenticated-read "or any other custom ACLs" needs s3:PutBucketAcl, while "if you set the ACL to private, or if you don't specify any ACLs, only the s3:CreateBucket permission is required". It is judged by the grants the request resolves to, so a least-privilege caller that always sends `--acl private` -- Terraform's legacy `acl` attribute, SDK wrappers that always set one -- is not refused where real S3 succeeds. A blank `x-amz-grant-*` header does not emit its condition key, for the same reason has_grant_headers treats it as absent: the request asks for no grant, and a present key would make a Null-based guardrail deny it.
…ization - `aws-exec-read` escaped the new `s3:PutBucketAcl` requirement. Its READ grant to the EC2 service's canonical user is not modeled, so it resolves to owner-only grants -- while the BucketOwnerEnforced conflict check has always treated it as an ACL reaching outside the owner, and CloudFormation sends it for `AccessControl: AwsExecRead`. Both sites now share one `acl_reaches_past_owner` helper instead of each deriving the answer, which is how they came to disagree. - The four newly required authorizations were evaluated against an EMPTY `aws:RequestTag` context. Dispatch rebuilds the context per action, so a tag-scoped guardrail that permitted the create then denied the `s3:PutBucketAcl` it implies: a 403 on a request AWS allows, since AWS builds one request context for the whole request. - `s3:x-amz-object-ownership` and `s3:x-amz-bucket-object-lock-enabled` are populated too. Gating a setting on a permission while its condition key stays absent is the same hazard this PR was written to fix: a guardrail that matches nothing authorizes exactly what it was written to block. - A doc comment carrying the `#2553` provenance had been displaced onto the wrong test, leaving `s3_create_bucket_with_tags_needs_tag_resource` undocumented, and two comments this change falsified still described the old behavior. Tests, each verified failing without its fix: `aws-exec-read` requires PutBucketAcl; every implied action sees the create's tags (and carries none on its own operation); the two new condition keys are emitted, and skipped when blank. Plus an e2e proving a tag-scoped `Deny` that permits the create does not then deny its ACL.
…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.
`s3:x-amz-bucket-object-lock-enabled` is not an AWS condition key. S3's object-lock keys are `s3:object-lock-mode`, `s3:object-lock-legal-hold`, `s3:object-lock-remaining-retention-days` and `s3:object-lock-retain-until-date`, all about an object's retention rather than a bucket's creation; there is no key for the `x-amz-bucket-object-lock-enabled` header. The vendored model agrees as far as it can: it carries that string only as the HTTP header, and the one `s3:x-amz-*` condition key it names anywhere is `s3:x-amz-metadata-directive`. Emitting it inverted the point of this PR. A policy author writing `Deny CreateBucket unless s3:x-amz-bucket-object-lock-enabled == true` would see it work against fakecloud and silently do nothing against AWS -- the same "guardrail that matches nothing" this PR exists to remove, pointed the other way. The key, its `security.md` row and its assertion are gone; the test now asserts it is NOT emitted. `s3:x-amz-object-ownership` stays: AWS defines that one, for CreateBucket. The object-lock PERMISSIONS requirement is untouched -- that comes from the model's own Permissions text, not from a condition key. Three comments that were false, one of them load-bearing: - The blank-`x-amz-acl` skip justified itself with "the write paths treat a present-but-empty canned ACL as absent". Only one of the four canned-ACL paths did, and #2581 reverts even that, so the justification would have aged into a lie. It now rests on what is actually invariant -- consistency with the other key families -- and says plainly that three paths answer 400 for a blank value, so nobody reads the skip as "blank is accepted". - `iam_actions_for`'s blank comment claimed the `x-amz-acl` branch skipped blanks. It did not; a blank merely resolved to owner-only because `canned_acl_grants` falls through to the owner for anything unrecognized. Now filtered explicitly, so an arm that ever resolved an unknown value past the owner cannot silently start demanding `s3:PutBucketAcl` for an empty header. - The `BUCKET_CANNED_ACLS` doc said `bucket-owner-full-control` is "not even a legal bucket ACL" while the constant it sits beside accepts it. The accurate statement is that the model's `BucketCannedACL` omits it, so the bucket-side question never has that answer -- the header is still accepted and resolves to the owner's FULL_CONTROL. Also: the `security.md` ownership row claimed a `CreateBucket` gate the emitter does not implement (it is populated from the header on any request, like the rows above it). It now says so, and why AWS scopes the key to CreateBucket anyway. One correction to the previous commit message: it claimed an unpopulated condition key safe-fails every non-`IfExists`, non-`ForAllValues` operator. The `ForAllValues` carve-out applies only to a key the service POPULATED with an empty value list; a key that was never populated safe-fails regardless of qualifier.
…/fakecloud into worktree-s3-create-iam-actions
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
CreateBucketwas authorized ass3:CreateBucketalone (pluss3:TagResourcefor a tag set), even though it appliesx-amz-acl/x-amz-grant-*,x-amz-object-ownershipand object-lock enablement. On AWS each of those needs its own permission.This got worse when #2558 made those settings persist: a principal holding only
s3:CreateBucketcould create apublic-readbucket that used to lose the grant on restart and now keeps it — a transient gap became durable over-permission.Second of the three gaps documented as out of scope in #2558.
What it does
The create returns one action per setting the request configures, following the Permissions section of
CreateBucketin the vendored Smithy model:CreateBucketConfigurationtag sets3:TagResources3:PutBucketAclx-amz-object-ownerships3:PutBucketOwnershipControlsx-amz-bucket-object-lock-enabled: trues3:PutBucketObjectLockConfiguration+s3:PutBucketVersioningDispatch already requires every returned action to be allowed, so a principal with only
s3:CreateBucketcan still create a plain bucket.The ACL rule follows that documentation precisely rather than keying on header presence: an ACL set to
public-read,public-read-write,authenticated-read"or any other custom ACLs" needss3:PutBucketAcl, while "if you set the ACL toprivate, or if you don't specify any ACLs, only thes3:CreateBucketpermission is required". It's judged by the grants the request resolves to, so a least-privilege caller that always sends--acl private— Terraform's legacyaclattribute, SDK wrappers that always set one — is not refused where real S3 succeeds.Condition keys
s3:x-amz-acland thes3:x-amz-grant-*family are populated now. They're how a policy pins down which ACL a write may ask for, and while they were never emitted a guardrail like{"Effect":"Allow","Action":"s3:CreateBucket","Resource":"*", "Condition":{"StringEquals":{"s3:x-amz-acl":"private"}}}matched nothing and silently authorized exactly what it was written to block. They're emitted from the headers on any request that carries them, rather than gated on an action list that would drift. An absent header emits nothing, so a condition on it safe-fails to "does not apply" — and a blank
x-amz-grant-*header emits nothing either, for the same reasonhas_grant_headerstreats it as absent: the request asks for no grant, and a present key would make aNull-based guardrail deny it.Test plan
privateadding none, afalseobject-lock header adding none, and all four settings at once in a single authorization set; condition keys populated fromx-amz-acland a grant header, and absent headers emitting nothing.iam_enforcement): a least-privilege principal refused for apublic-readcreate and unblocked by addings3:PutBucketAcl, with the resulting bucket verified public-read;--acl privateaccepted with onlys3:CreateBucket; the object-lock pair refused then unblocked; and a condition-keyed policy admittingprivatewhile denyingpublic-read.fakecloud-s3481 passed; e2eiam_enforcement+iam_enforcement_abac65 passed; e2es3+s3_persistence119 passed;fakecloud-conformance --test s351 passed.cargo clippy --all-targets -- -D warningsandcargo fmt --checkclean.iam_actions_for/iam_condition_keys_forare only reached when IAM is enabled, and the conformance runner never enables--iam.Surface sync
website/content/docs/reference/security.mdaddss3:x-amz-acland thes3:x-amz-grant-*family to the supported service-condition-key table;website/content/docs/services/s3.mddocuments the per-setting permissions, including theprivateexemption./_fakecloud/*surface.Summary by cubic
CreateBucketwas authorized bys3:CreateBucketalone, so a principal holding only that permission could configure ACLs, object ownership, or object lock at create time. Since those settings persist, that transient gap became durable over-permission. The create now requires one action per setting it configures, and the associateds3:x-amz-*condition keys are populated from headers.s3:PutBucketAcl,s3:PutBucketOwnershipControls, ands3:PutBucketObjectLockConfigurationpluss3:PutBucketVersioningfor object lock;--acl privateand no-ACL creates still need onlys3:CreateBucket.BucketOwnerEnforcedconflict check share oneacl_reaches_past_ownerhelper, soaws-exec-readno longer escapes thes3:PutBucketAclrequirement; on its own operations the implied actions carry none of the create's tags.aws:RequestTagfor every implied action, so a tag-scoped guardrail that permits the create doesn't then deny the ACL or object-lock action it implies.s3:x-amz-acl, thes3:x-amz-grant-*family,s3:x-amz-object-ownership); absent or blank values emit nothing, so aNull-based guardrail can't deny a request that asks for nothing. The object-lock header emits no key, since AWS defines none for it; that setting is gated by permissions only.Written for commit be39a83. Summary will update on new commits.