Files
seaweedfs/weed/s3api/s3api_object_upload_acl.go
T
Chris LuandClaude Opus 5.5 6c1eec8703 s3: store the POST Object acl on the uploaded object (#12567)
* s3api: split upload ACL assembly out of preparePutObjectACL

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: store POST Object acl and grant fields on the object

The form acl and x-amz-grant-* fields were authorized but never assembled,
so the object kept no ACL: acl=private on a public-read bucket stayed
anonymously readable and invalid values were accepted. Build them with the
same rules as PutObject, which also covers the Block Public Access check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: test POST Object stores its upload ACL

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: take the POST Object ACL from the signed form only

ACL request headers are not covered by the POST policy, so a holder of a
signed form could add one and have it stored with the upload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: reject a POST acl field the policy does not cover

The acl form field now sets the stored object ACL, so like the x-amz-*
fields it needs a policy condition, as AWS requires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: test unsigned ACL headers and uncovered acl fields on POST

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: evaluate POST upload policies on the canonical ACL

Policy conditions on the grant headers saw the raw form value, so an
equivalent spelling of a denied grantee passed. Use the PutObject request
shape that the PUT path evaluates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: check bucket policy for POST ACLs with auth disabled

PutObject applies the bucket policy to an explicit upload ACL when IAM is
off; POST now does the same before storing it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: reject differing acl and x-amz-acl POST fields

Both promote to the canned ACL header, so map order picked the stored ACL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: test POST grant canonicalization, auth-off ACL policy, acl conflicts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: check the auth-off POST ACL policy after loading the bucket

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: reject an empty acl or x-amz-acl POST field beside a differing one

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: test the auth-off POST ACL policy on a cold bucket cache and empty acl aliases

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: load the bucket before per-key object authorization

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: test the POST ACL policy on a cold bucket cache with auth on

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: stop per-key object authorization when the bucket fails to load

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: test the cold-bucket POST ACL allow path and a failed bucket load

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 10:36:51 -08:00

296 lines
12 KiB
Go

package s3api
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
// putObjectACLContextKey carries validated ACL metadata to each object creation
// path without exposing an internal header that a client could forge.
type putObjectACLContextKey struct{}
type putObjectACLMetadata struct {
extended map[string][]byte
canned string
}
// putObjectACLValue ignores unsigned V2 query ACLs and rejects ambiguity.
// Headers stay untouched because authentication verifies the original request.
func putObjectACLValue(r *http.Request, query url.Values, header string) (string, s3err.ErrorCode) {
// Preserve SigV2's header-only ACL behavior: arbitrary query parameters
// are not in its canonical resource and must have no effect on grants.
switch getRequestAuthType(r) {
case authTypeSignedV2, authTypePresignedV2:
query = nil
}
var queryValues []string
queryPresent := false
for key, values := range query {
if strings.EqualFold(key, header) {
queryPresent = true
queryValues = append(queryValues, values...)
}
}
if queryPresent {
if len(queryValues) != 1 {
// V4 sorts duplicate values when signing. Choosing the first value
// would let reordering change the effective ACL without resigning.
return "", s3err.ErrInvalidRequest
}
}
values := r.Header.Values(header)
if header == s3_constants.AmzCannedAcl && len(values) > 1 {
return "", s3err.ErrInvalidRequest
}
value := strings.Join(values, ",")
if queryPresent {
if len(values) > 0 && value != queryValues[0] {
return "", s3err.ErrInvalidRequest
}
value = queryValues[0]
}
return value, s3err.ErrNone
}
// putObjectACLPolicyRequest exposes upload and copy ACLs to policy conditions only
// after authentication. Other operations keep their original request semantics.
func putObjectACLPolicyRequest(r *http.Request, action Action, bucket, object string) (*http.Request, s3err.ErrorCode) {
if (action != s3_constants.ACTION_WRITE && action != s3_constants.ACTION_WRITE_ACP) ||
r.Method != http.MethodPut || object == "" || object == "/" ||
ResolveS3Action(r, string(s3_constants.ACTION_WRITE), bucket, object) != s3_constants.S3_ACTION_PUT_OBJECT {
return r, s3err.ErrNone
}
// Rechecks reuse the normalized internal request, preserving signed original values without false query conflicts.
if len(policy_engine.OriginalGrantConditionsFromRequest(r)) != 0 {
return r, s3err.ErrNone
}
policyRequest := r.Clone(r.Context())
query := parseRequestQuery(r)
originalGrants := make(map[string][]string)
for _, header := range []string{s3_constants.AmzCannedAcl, s3_constants.AmzAclFullControl, s3_constants.AmzAclRead, s3_constants.AmzAclReadAcp, s3_constants.AmzAclWrite, s3_constants.AmzAclWriteAcp} {
value, code := putObjectACLValue(r, query, header)
if code != s3err.ErrNone {
return r, code
}
if value == "" {
continue
}
if header == s3_constants.AmzCannedAcl {
policyRequest.Header.Set(header, value)
continue
}
// Preserve only the complete effective list for original-string denies, not individual header lines as separate lists.
originalGrants["s3:"+strings.ToLower(header)] = []string{value}
// Policy conditions see the canonical grant list: one comma-separated
// value covering every persisted grantee, identical for a single line,
// repeated lines, or a signed query parameter. Sneaking an extra grantee
// past a StringEquals allow or a StringNotEquals allowlist deny requires
// changing this value, which a signed request cannot do.
pairs, pairCode := parseAclGranteePairs(value)
if pairCode != s3err.ErrNone {
return r, pairCode
}
var tokens []string
for _, pair := range pairs {
// Grant conditions use JSON quoting without HTML escaping, so valid
// literal characters in an account or email still match the policy.
var encoded strings.Builder
encoder := json.NewEncoder(&encoded)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(pair[1]); err != nil {
return r, s3err.ErrInvalidRequest
}
tokens = append(tokens, pair[0]+"="+strings.TrimSuffix(encoded.String(), "\n"))
}
policyRequest.Header.Set(header, strings.Join(tokens, ","))
}
if len(originalGrants) != 0 {
policyRequest = policy_engine.WithOriginalGrantConditions(policyRequest, originalGrants)
}
return policyRequest, s3err.ErrNone
}
// preparePutObjectACL validates and authorizes ACLs before upload or copy data
// is written. The metadata is committed in the same entry as the new object.
func (s3a *S3ApiServer) preparePutObjectACL(r *http.Request, bucket string) (*http.Request, s3err.ErrorCode) {
metadata, code := s3a.getBucketConfig(bucket)
if code != s3err.ErrNone {
return r, code
}
if metadata == nil || s3a.iam == nil {
return r, s3err.ErrInternalError
}
// Presigners can hoist ACL headers into the signed query string. Normalize a
// separate request for parsing, preserving the original for signature checks.
aclRequest := r.Clone(r.Context())
query := parseRequestQuery(r)
custom := false
for _, header := range []string{s3_constants.AmzAclFullControl, s3_constants.AmzAclRead, s3_constants.AmzAclReadAcp, s3_constants.AmzAclWrite, s3_constants.AmzAclWriteAcp} {
value, code := putObjectACLValue(r, query, header)
if code != s3err.ErrNone {
return r, code
}
if value != "" {
custom = true
aclRequest.Header.Set(header, value)
}
}
canned, code := putObjectACLValue(r, query, s3_constants.AmzCannedAcl)
if code != s3err.ErrNone {
return r, code
}
aclRequest.Header.Set(s3_constants.AmzCannedAcl, canned)
explicit := canned != "" || custom
accountID := s3a.requestAccountId(r)
if explicit && s3a.iam.isEnabled() {
// Setting an ACL during upload or copy also requires s3:PutObjectAcl. Use the
// unified authorization path so bucket-policy allows and explicit denies
// retain the same semantics as standalone ACL requests.
identity, authCode, _ := s3a.iam.authRequestWithAuthType(r.Clone(r.Context()), s3_constants.ACTION_WRITE_ACP)
if authCode != s3err.ErrNone {
if authCode == s3err.ErrAccessDenied && identity != nil && identity.Name != s3_constants.AccountAnonymousId {
_, object := s3_constants.GetBucketAndObject(r)
glog.Infof("denied %s %q for %q: the request sets an ACL and failed the %s (s3:PutObjectAcl) check", r.Method, bucket+"/"+object, identity.Name, s3_constants.ACTION_WRITE_ACP)
}
return r, authCode
}
if identity == nil || identity.Account == nil {
return r, s3err.ErrAccessDenied
}
accountID = identity.Account.Id
}
if explicit && !s3a.iam.isEnabled() {
_, object := s3_constants.GetBucketAndObject(r)
policyRequest, policyCode := putObjectACLPolicyRequest(r, s3_constants.ACTION_WRITE, bucket, object)
if policyCode != s3err.ErrNone {
return r, policyCode
}
if policyCode = s3a.checkPutObjectACLPolicy(policyRequest, bucket, object); policyCode != s3err.ErrNone {
return r, policyCode
}
}
return s3a.attachPutObjectACL(r, aclRequest, metadata, accountID, canned, custom)
}
// checkPutObjectACLPolicy applies bucket policies to an upload ACL when
// authentication is off and no IAM check covered it.
func (s3a *S3ApiServer) checkPutObjectACLPolicy(policyRequest *http.Request, bucket, object string) s3err.ErrorCode {
for _, action := range []Action{s3_constants.ACTION_WRITE, s3_constants.ACTION_WRITE_ACP} {
if code, _ := s3a.checkPolicyWithEntry(policyRequest, bucket, object, string(action), "", nil); code != s3err.ErrNone {
return code
}
}
return s3err.ErrNone
}
// preparePostObjectACL builds the upload ACL from the acl and x-amz-grant-*
// form fields promoted onto the request. PostPolicyBucketHandler has already
// authorized them for the policy signer.
func (s3a *S3ApiServer) preparePostObjectACL(r *http.Request, bucket string) (*http.Request, s3err.ErrorCode) {
metadata, code := s3a.getBucketConfig(bucket)
if code != s3err.ErrNone {
return r, code
}
if metadata == nil || s3a.iam == nil {
return r, s3err.ErrInternalError
}
custom := false
for _, header := range []string{s3_constants.AmzAclFullControl, s3_constants.AmzAclRead, s3_constants.AmzAclReadAcp, s3_constants.AmzAclWrite, s3_constants.AmzAclWriteAcp} {
if r.Header.Get(header) != "" {
custom = true
}
}
return s3a.attachPutObjectACL(r, r.Clone(r.Context()), metadata, s3a.requestAccountId(r), r.Header.Get(s3_constants.AmzCannedAcl), custom)
}
func (s3a *S3ApiServer) attachPutObjectACL(r, aclRequest *http.Request, metadata *BucketConfig, accountID, canned string, custom bool) (*http.Request, s3err.ErrorCode) {
if accountID == "" {
return r, s3err.ErrAccessDenied
}
if canned != "" && custom {
return r, s3err.ErrInvalidRequest
}
bucketOwner := metadata.Owner
if bucketOwner == "" {
// Buckets created outside S3 can have no recorded owner, matching the
// bucket registry's existing admin fallback for these entries.
bucketOwner = AccountAdmin.Id
}
ownership := s3_constants.EffectiveOwnership(metadata.Ownership)
if ownership == s3_constants.OwnershipBucketOwnerEnforced {
if metadata.Ownership == s3_constants.OwnershipBucketOwnerEnforced {
// Keep legacy buckets without recorded ownership controls accepting
// ACLs; only an explicitly configured enforced control disables them.
if custom || (canned != "" && canned != s3_constants.CannedAclBucketOwnerFullControl) {
return r, s3err.ErrAccessControlListNotSupported
}
aclRequest.Header.Set(s3_constants.AmzCannedAcl, s3_constants.CannedAclPrivate)
}
accountID = bucketOwner
}
if aclRequest.Header.Get(s3_constants.AmzCannedAcl) == "" && !custom {
aclRequest.Header.Set(s3_constants.AmzCannedAcl, s3_constants.CannedAclPrivate)
}
// Canned grants contain only authenticated writer and recorded bucket-owner
// IDs. Dynamic IAM/JWT accounts need not exist in the static account directory.
// Client-supplied custom grantees must still pass directory validation.
owner, grants, code := ParseAclHeaders(aclRequest, ownership, bucketOwner, accountID, false)
if code == s3err.ErrNone && custom {
grants, code = ValidateAndTransferGrants(s3a.iam, grants)
}
if code == s3err.ErrNone {
code = rejectPublicGrants(metadata, grants)
}
if code != s3err.ErrNone {
return r, code
}
if custom {
// Custom upload grants supplement the owner's default full control.
// Check after email resolution to avoid duplicating an explicit owner
// grant; the authenticated owner need not be in the static directory.
ownerFullControl := false
for _, grant := range grants {
if grant.Grantee != nil && grant.Grantee.Type != nil &&
*grant.Grantee.Type == s3_constants.GrantTypeCanonicalUser &&
grant.Grantee.ID != nil && *grant.Grantee.ID == owner &&
grant.Permission != nil && *grant.Permission == s3_constants.PermissionFullControl {
ownerFullControl = true
break
}
}
if !ownerFullControl {
grants = append(grants, &s3.Grant{
Grantee: &s3.Grantee{Type: &s3_constants.GrantTypeCanonicalUser, ID: &owner},
Permission: &s3_constants.PermissionFullControl,
})
}
}
entry := &filer_pb.Entry{}
if code = AssembleEntryWithAcp(entry, owner, grants); code != s3err.ErrNone {
return r, code
}
prepared := putObjectACLMetadata{extended: entry.Extended, canned: canned}
return r.WithContext(context.WithValue(r.Context(), putObjectACLContextKey{}, prepared)), s3err.ErrNone
}
// applyPutObjectACL adds prevalidated ownership and grants before CreateEntry.
// Multipart parts do not carry this PutObject context.
func applyPutObjectACL(r *http.Request, entry *filer_pb.Entry) {
metadata, _ := r.Context().Value(putObjectACLContextKey{}).(putObjectACLMetadata)
for key, value := range metadata.extended {
entry.Extended[key] = value
}
}