mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-04 11:07:35 +01:00
* 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>
258 lines
9.9 KiB
Go
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
|
|
}
|
|
}
|