Files
seaweedfs/weed/s3api/s3api_object_upload_acl.go
T
Chris LuandClaude Opus 5.5 b0c6297041 s3api: per-bucket Block Public Access (BlockPublicAcls, IgnorePublicAcls) (#12381)
* s3api: store and serve a bucket's PublicAccessBlock configuration

PutPublicAccessBlock, GetPublicAccessBlock and DeletePublicAccessBlock
answered NotImplemented. Store the configuration in the bucket entry's
extended attributes next to the ownership controls, carry it in the
cached BucketConfig, and answer Get on an unset bucket with
NoSuchPublicAccessBlockConfiguration like S3 does.

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

* s3api: refuse public ACLs when a bucket blocks them

With BlockPublicAcls set, a PutObject, CopyObject, CreateMultipartUpload,
POST Object, PutObjectAcl or PutBucketAcl whose canned ACL or grants name
the AllUsers or AuthenticatedUsers group is refused with AccessDenied.
Private and account grants keep working, so clients that send
x-amz-acl: private on every upload are unaffected.

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

* s3api: ignore public ACL grants for anonymous reads under IgnorePublicAcls

A bucket with IgnorePublicAcls no longer lets an AllUsers grant on the
bucket or on an object answer an anonymous read or listing. Bucket
policies and identity permissions are unaffected.

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

* s3api: reject PublicAccessBlock bodies with unknown elements

A misspelled element such as BlockPublicAcl was ignored, so the request
succeeded and stored the flag as false. Answer MalformedXML instead, and
store the configuration without the request's namespace.

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

* s3api: fail closed when a bucket's public access block cannot be read

A stored PublicAccessBlock that does not parse now blocks and ignores
public ACLs instead of acting as if none were set. PutBucketAcl and POST
Object return the bucket lookup error rather than skipping the
BlockPublicAcls check when the bucket config cannot be loaded.

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

* s3api: test BlockPublicAcls on copy, multipart initiate and a PUT-then-GET

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

* s3api: keep stored public access block bytes on unrelated bucket updates

An unreadable stored configuration was replaced with the in-memory
all-true fallback whenever another bucket setting was updated, losing
the original value. Write the key only when the update changes the
public access block.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:52:20 -08:00

258 lines
9.9 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/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.authRequest(r.Clone(r.Context()), s3_constants.ACTION_WRITE_ACP)
if authCode != s3err.ErrNone {
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
}
for _, action := range []Action{s3_constants.ACTION_WRITE, s3_constants.ACTION_WRITE_ACP} {
if policyCode, _ := s3a.checkPolicyWithEntry(policyRequest, bucket, object, string(action), "", nil); policyCode != s3err.ErrNone {
return r, policyCode
}
}
}
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 and POST form uploads 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
}
}