diff --git a/weed/s3api/filer_multipart_initiate_acl_test.go b/weed/s3api/filer_multipart_initiate_acl_test.go index 8f5dc43e3b..e05a0e29c4 100644 --- a/weed/s3api/filer_multipart_initiate_acl_test.go +++ b/weed/s3api/filer_multipart_initiate_acl_test.go @@ -123,4 +123,18 @@ func TestNewMultipartUploadInitiateACL(t *testing.T) { } require.True(t, publicRead, "public-read grants should persist on the upload entry: %s", string(uploadEntry(t, f).Extended[s3_constants.ExtAmzAclKey])) }) + + t.Run("blocked public acl is refused", func(t *testing.T) { + s3a, f := setup(t, true) + config, _ := s3a.bucketConfigCache.Get(bucket) + config.PublicAccessBlock = &PublicAccessBlockConfiguration{BlockPublicAcls: true} + rr := initiate(t, s3a, map[string]string{s3_constants.AmzCannedAcl: "public-read"}) + require.Equal(t, http.StatusForbidden, rr.Code, rr.Body.String()) + require.Contains(t, rr.Body.String(), "AccessDenied") + f.mu.Lock() + defer f.mu.Unlock() + for key := range f.entries { + require.False(t, strings.HasPrefix(key, "/buckets/"+bucket+"/"+s3_constants.MultipartUploadsFolder+"/"), key) + } + }) } diff --git a/weed/s3api/s3_constants/extend_key.go b/weed/s3api/s3_constants/extend_key.go index 959a962473..00c256e1ef 100644 --- a/weed/s3api/s3_constants/extend_key.go +++ b/weed/s3api/s3_constants/extend_key.go @@ -5,6 +5,7 @@ const ( ExtAmzOwnerKey = "Seaweed-X-Amz-Owner" ExtAmzAclKey = "Seaweed-X-Amz-Acl" ExtOwnershipKey = "Seaweed-X-Amz-Ownership" + ExtPublicAccessBlockKey = "Seaweed-X-Amz-Public-Access-Block" ExtVersioningKey = "Seaweed-X-Amz-Versioning" ExtVersionIdKey = "Seaweed-X-Amz-Version-Id" ExtDeleteMarkerKey = "Seaweed-X-Amz-Delete-Marker" diff --git a/weed/s3api/s3api_bucket_config.go b/weed/s3api/s3api_bucket_config.go index 427e58dcf5..5b5a884376 100644 --- a/weed/s3api/s3api_bucket_config.go +++ b/weed/s3api/s3api_bucket_config.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "encoding/xml" "errors" "fmt" "path/filepath" @@ -60,6 +61,7 @@ type BucketConfig struct { DeclaredEncryption *s3_pb.EncryptionConfiguration // declared by a table bucket's record KMSKeyCache *BucketKMSCache // Per-bucket KMS key cache for SSE-KMS operations LastModified time.Time + PublicAccessBlock *PublicAccessBlockConfiguration } // BucketKMSCache represents per-bucket KMS key caching for SSE-KMS operations @@ -430,10 +432,18 @@ func (s3a *S3ApiServer) newBucketConfigFromEntry(bucket string, entry *filer_pb. if ownership, exists := entry.Extended[s3_constants.ExtOwnershipKey]; exists { config.Ownership = string(ownership) } + if data, exists := entry.Extended[s3_constants.ExtPublicAccessBlockKey]; exists && len(data) > 0 { + if pab, err := parsePublicAccessBlock(data); err == nil { + config.PublicAccessBlock = pab + } else { + glog.Errorf("newBucketConfigFromEntry: bucket %s public access block, blocking all public access: %v", bucket, err) + config.PublicAccessBlock = &PublicAccessBlockConfiguration{BlockPublicAcls: true, IgnorePublicAcls: true, BlockPublicPolicy: true, RestrictPublicBuckets: true} + } + } if acl, exists := entry.Extended[s3_constants.ExtAmzAclKey]; exists { config.ACL = acl // Parse ACL once and cache public-read status. - config.IsPublicRead = parseAndCachePublicReadStatus(acl) + config.IsPublicRead = !config.PublicAccessBlock.ignoresPublicAcls() && parseAndCachePublicReadStatus(acl) } config.Owner = bucketOwnerAccountId(s3a.iam, entry) if identityId, exists := entry.Extended[s3_constants.AmzIdentityId]; exists { @@ -509,6 +519,8 @@ func (s3a *S3ApiServer) updateBucketConfig(bucket string, updateFn func(*BucketC } config := s3a.newBucketConfigFromEntry(bucket, entry) + loadedPAB := config.PublicAccessBlock + loadedPABXML, _ := xml.Marshal(loadedPAB) // Apply update function if err := updateFn(config); err != nil { @@ -525,6 +537,10 @@ func (s3a *S3ApiServer) updateBucketConfig(bucket string, updateFn func(*BucketC glog.Errorf("updateBucketConfig: failed to serialize config for bucket %s: %v", bucket, err) return s3err.ErrInternalError } + // Keep the stored bytes, readable or not, unless updateFn changed the setting. + if loadedPAB != nil && config.PublicAccessBlock == loadedPAB && bytes.Equal(newExt[s3_constants.ExtPublicAccessBlockKey], loadedPABXML) { + newExt[s3_constants.ExtPublicAccessBlockKey] = oldExt[s3_constants.ExtPublicAccessBlockKey] + } // Patch only the changed/removed extended keys, leaving Entry.content // untouched so a concurrent content write (e.g. encryption) is preserved. @@ -571,6 +587,15 @@ func applyBucketConfigToExtended(config *BucketConfig, ext map[string][]byte) er } setOrDelete(s3_constants.ExtVersioningKey, []byte(config.Versioning)) setOrDelete(s3_constants.ExtOwnershipKey, []byte(config.Ownership)) + if config.PublicAccessBlock != nil { + data, err := xml.Marshal(config.PublicAccessBlock) + if err != nil { + return err + } + ext[s3_constants.ExtPublicAccessBlockKey] = data + } else { + delete(ext, s3_constants.ExtPublicAccessBlockKey) + } setOrDelete(s3_constants.ExtAmzAclKey, config.ACL) setOrDelete(s3_constants.ExtAmzOwnerKey, []byte(config.Owner)) setOrDelete(bucketLifecycleConfigurationXMLKey, config.LifecycleXML) diff --git a/weed/s3api/s3api_bucket_handlers.go b/weed/s3api/s3api_bucket_handlers.go index b6886c0954..7a86457ff2 100644 --- a/weed/s3api/s3api_bucket_handlers.go +++ b/weed/s3api/s3api_bucket_handlers.go @@ -1122,8 +1122,13 @@ func (s3a *S3ApiServer) PutBucketAclHandler(w http.ResponseWriter, r *http.Reque // the bucket has one, else the request's account — an anonymous request // resolves to the admin account, the registry's default bucket owner. ownerId := aclAccountId - if config, errCode := s3a.getBucketConfig(bucket); errCode == s3err.ErrNone && config.Owner != "" { - ownerId = config.Owner + bucketConfig, errCode := s3a.getBucketConfig(bucket) + if errCode != s3err.ErrNone { + s3err.WriteErrorResponse(w, r, errCode) + return + } + if bucketConfig.Owner != "" { + ownerId = bucketConfig.Owner } // Get bucket ownership settings (these would be used for ownership validation in a full implementation) @@ -1132,6 +1137,9 @@ func (s3a *S3ApiServer) PutBucketAclHandler(w http.ResponseWriter, r *http.Reque // Use the existing ACL parsing logic to handle both canned ACLs and XML body grants, errCode := ExtractAcl(r, s3a.iam, bucketOwnership, bucketOwnerId, ownerId, aclAccountId) + if errCode == s3err.ErrNone { + errCode = rejectPublicGrants(bucketConfig, grants) + } if errCode != s3err.ErrNone { s3err.WriteErrorResponse(w, r, errCode) return diff --git a/weed/s3api/s3api_bucket_policy_handlers.go b/weed/s3api/s3api_bucket_policy_handlers.go index 6477a18f45..95620ee772 100644 --- a/weed/s3api/s3api_bucket_policy_handlers.go +++ b/weed/s3api/s3api_bucket_policy_handlers.go @@ -479,13 +479,61 @@ func bucketPolicyMirrorOps(oldEntry, newEntry *filer_pb.Entry) (removeName, upda // GetPublicAccessBlockHandler Retrieves the PublicAccessBlock configuration for an S3 bucket // https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetPublicAccessBlock.html func (s3a *S3ApiServer) GetPublicAccessBlockHandler(w http.ResponseWriter, r *http.Request) { - s3err.WriteErrorResponse(w, r, s3err.ErrNotImplemented) + bucket, _ := s3_constants.GetBucketAndObject(r) + config, errCode := s3a.getBucketConfig(bucket) + if errCode != s3err.ErrNone { + s3err.WriteErrorResponse(w, r, errCode) + return + } + if config.PublicAccessBlock == nil { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchPublicAccessBlockConfiguration) + return + } + response := *config.PublicAccessBlock + response.XMLNS = s3_constants.S3Namespace + writeSuccessResponseXML(w, r, response) } +// PutPublicAccessBlockHandler https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutPublicAccessBlock.html func (s3a *S3ApiServer) PutPublicAccessBlockHandler(w http.ResponseWriter, r *http.Request) { - s3err.WriteErrorResponse(w, r, s3err.ErrNotImplemented) + bucket, _ := s3_constants.GetBucketAndObject(r) + if err := s3a.checkBucket(r, bucket); err != s3err.ErrNone { + s3err.WriteErrorResponse(w, r, err) + return + } + body, err := readRequestBody(r, maxPublicAccessBlockConfigurationSize) + if err != nil { + if errors.Is(err, errRequestBodyTooLarge) { + s3err.WriteErrorResponse(w, r, s3err.ErrEntityTooLarge) + return + } + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest) + return + } + config, err := parsePublicAccessBlock(body) + if err != nil { + s3err.WriteErrorResponse(w, r, s3err.ErrMalformedXML) + return + } + if errCode := s3a.updateBucketConfig(bucket, func(bucketConfig *BucketConfig) error { + bucketConfig.PublicAccessBlock = config + return nil + }); errCode != s3err.ErrNone { + s3err.WriteErrorResponse(w, r, errCode) + return + } + writeSuccessResponseEmpty(w, r) } +// DeletePublicAccessBlockHandler https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeletePublicAccessBlock.html func (s3a *S3ApiServer) DeletePublicAccessBlockHandler(w http.ResponseWriter, r *http.Request) { - s3err.WriteErrorResponse(w, r, s3err.ErrNotImplemented) + bucket, _ := s3_constants.GetBucketAndObject(r) + if errCode := s3a.updateBucketConfig(bucket, func(bucketConfig *BucketConfig) error { + bucketConfig.PublicAccessBlock = nil + return nil + }); errCode != s3err.ErrNone { + s3err.WriteErrorResponse(w, r, errCode) + return + } + s3err.WriteEmptyResponse(w, r, http.StatusNoContent) } diff --git a/weed/s3api/s3api_object_anonymous_read.go b/weed/s3api/s3api_object_anonymous_read.go index 6fd86001aa..1e16c73d71 100644 --- a/weed/s3api/s3api_object_anonymous_read.go +++ b/weed/s3api/s3api_object_anonymous_read.go @@ -87,8 +87,10 @@ func (s3a *S3ApiServer) authorizeAnonymousObjectRead(r *http.Request, bucket, ob return s3err.ErrNone } // Match the upload/ACL path: legacy buckets without recorded ownership - // controls still accept ACLs. An enforced control explicitly disables them. - if config.Ownership == "" || s3_constants.EffectiveOwnership(config.Ownership) != s3_constants.OwnershipBucketOwnerEnforced { + // controls still accept ACLs. An enforced control explicitly disables them, + // and IgnorePublicAcls leaves an object ACL nothing it could grant here. + if !config.PublicAccessBlock.ignoresPublicAcls() && + (config.Ownership == "" || s3_constants.EffectiveOwnership(config.Ownership) != s3_constants.OwnershipBucketOwnerEnforced) { if data, exists := extended[s3_constants.ExtAmzAclKey]; exists { // An explicitly stored ACL replaces the legacy bucket-ACL fallback. // Empty, malformed and non-public ACLs must not grant anonymous read. diff --git a/weed/s3api/s3api_object_anonymous_read_test.go b/weed/s3api/s3api_object_anonymous_read_test.go index 2f27c78acf..0efac46b93 100644 --- a/weed/s3api/s3api_object_anonymous_read_test.go +++ b/weed/s3api/s3api_object_anonymous_read_test.go @@ -288,6 +288,38 @@ func TestAnonymousObjectACLLegacyOwnership(t *testing.T) { require.Equal(t, 200, rr.Code, rr.Body.String()) } +func TestAnonymousObjectACLIgnorePublicAcls(t *testing.T) { + publicBucket := &filer_pb.Entry{Name: "b", IsDirectory: true, Extended: map[string][]byte{ + s3_constants.ExtAmzAclKey: anonymousReadGrant("READ"), + }} + ignoring := proto.Clone(publicBucket).(*filer_pb.Entry) + ignoring.Extended[s3_constants.ExtPublicAccessBlockKey] = []byte(`true`) + for _, tc := range []struct { + name string + bucket *filer_pb.Entry + acl []byte + want int + }{ + {"public object", publicBucket, anonymousReadGrant("READ"), 200}, + {"public object ignored", ignoring, anonymousReadGrant("READ"), 403}, + {"public bucket", publicBucket, nil, 200}, + {"public bucket ignored", ignoring, nil, 403}, + } { + for _, method := range []string{http.MethodGet, http.MethodHead} { + t.Run(tc.name+"/"+method, func(t *testing.T) { + s3a, f := newAnonymousReadTestServer(t) + s3a.bucketConfigCache.Set("b", s3a.newBucketConfigFromEntry("b", tc.bucket)) + f.entries["/buckets/b/o"] = anonymousReadEntry(tc.acl) + rr := serveAnonymousRead(s3a, method, "o", "", nil) + require.Equal(t, tc.want, rr.Code, rr.Body.String()) + if tc.want == 403 { + require.NotContains(t, rr.Body.String(), "hello world") + } + }) + } + } +} + func TestAnonymousObjectACLConfiguredIdentity(t *testing.T) { for _, tc := range []struct { name string diff --git a/weed/s3api/s3api_object_handlers_acl.go b/weed/s3api/s3api_object_handlers_acl.go index 1cee201d84..f910b3a394 100644 --- a/weed/s3api/s3api_object_handlers_acl.go +++ b/weed/s3api/s3api_object_handlers_acl.go @@ -289,6 +289,9 @@ func (s3a *S3ApiServer) PutObjectAclHandler(w http.ResponseWriter, r *http.Reque // Extract ACL from request (either canned ACL or XML body) // This function also validates that the owner in the request matches the object owner grants, errCode := ExtractAcl(r, s3a.iam, bucketOwnership, bucketOwnerId, objectOwner, amzAccountId) + if errCode == s3err.ErrNone { + errCode = rejectPublicGrants(bucketConfig, grants) + } if errCode != s3err.ErrNone { s3err.WriteErrorResponse(w, r, errCode) return diff --git a/weed/s3api/s3api_object_handlers_copy_acl_test.go b/weed/s3api/s3api_object_handlers_copy_acl_test.go index ef2997e582..583b27aa08 100644 --- a/weed/s3api/s3api_object_handlers_copy_acl_test.go +++ b/weed/s3api/s3api_object_handlers_copy_acl_test.go @@ -153,6 +153,7 @@ func TestCopyObjectDestinationACL(t *testing.T) { mime string race, condition string prefix bool + blockPublicAcls bool }{ {name: "default private"}, {name: "replace metadata also defaults private", replace: true}, @@ -204,6 +205,9 @@ func TestCopyObjectDestinationACL(t *testing.T) { {name: "prefix private control", self: true, replace: true, routed: true, prefix: true}, {name: "prefix mode changes before commit", self: true, replace: true, routed: true, prefix: true, race: "commit"}, {name: "self copy reads owner ahead of replica", self: true, replace: true, routed: true, race: "replica"}, + {name: "block public acls private", acl: "private", blockPublicAcls: true}, + {name: "block public acls rejects public", acl: "public-read", blockPublicAcls: true, errorCode: "AccessDenied"}, + {name: "block public acls rejects public self copy", self: true, replace: true, routed: true, acl: "public-read", blockPublicAcls: true, errorCode: "AccessDenied"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -234,7 +238,11 @@ func TestCopyObjectDestinationACL(t *testing.T) { }} filer.entries["/buckets/"+bucket] = bucketEntry s3a.bucketConfigCache = NewBucketConfigCache(time.Minute) - s3a.bucketConfigCache.Set(bucket, &BucketConfig{Name: bucket, Owner: bucketOwner, Ownership: ownership, Versioning: tt.versioning}) + bucketConfig := &BucketConfig{Name: bucket, Owner: bucketOwner, Ownership: ownership, Versioning: tt.versioning} + if tt.blockPublicAcls { + bucketConfig.PublicAccessBlock = &PublicAccessBlockConfiguration{BlockPublicAcls: true} + } + s3a.bucketConfigCache.Set(bucket, bucketConfig) s3a.bucketRegistry = NewBucketRegistry(s3a) s3a.bucketRegistry.LoadBucketMetadata(bucketEntry) mode := tt.sourceMode diff --git a/weed/s3api/s3api_object_handlers_postpolicy.go b/weed/s3api/s3api_object_handlers_postpolicy.go index 51d21c4bf8..69139ce72c 100644 --- a/weed/s3api/s3api_object_handlers_postpolicy.go +++ b/weed/s3api/s3api_object_handlers_postpolicy.go @@ -158,6 +158,21 @@ func (s3a *S3ApiServer) PostPolicyBucketHandler(w http.ResponseWriter, r *http.R s3err.WriteErrorResponse(w, r, refineDenialForBucketName(r, identity, errCode)) return } + config, errCode := s3a.getBucketConfig(bucket) + if errCode != s3err.ErrNone && errCode != s3err.ErrNoSuchBucket { + s3err.WriteErrorResponse(w, r, errCode) + return + } + if errCode == s3err.ErrNone && config.PublicAccessBlock.blocksPublicAcls() { + _, grants, aclCode := ParseAclHeaders(r, config.Ownership, config.Owner, "", false) + if aclCode == s3err.ErrNone { + aclCode = rejectPublicGrants(config, grants) + } + if aclCode != s3err.ErrNone { + s3err.WriteErrorResponse(w, r, aclCode) + return + } + } } versioningState, err := s3a.getVersioningState(bucket) diff --git a/weed/s3api/s3api_object_handlers_postpolicy_authz_test.go b/weed/s3api/s3api_object_handlers_postpolicy_authz_test.go index c341979181..0206710067 100644 --- a/weed/s3api/s3api_object_handlers_postpolicy_authz_test.go +++ b/weed/s3api/s3api_object_handlers_postpolicy_authz_test.go @@ -495,3 +495,48 @@ func TestPostPolicyBucketHandlerAutoCreateDeniedForNonAdmin(t *testing.T) { defer filer.mu.Unlock() assert.Empty(t, filer.entries, "a non-admin POST must not create the bucket") } + +// A bucket that blocks public ACLs refuses a POST upload whose acl or grant +// fields would make the object public, and still accepts a private one. +func TestPostPolicyBucketHandlerBlocksPublicAcls(t *testing.T) { + newServer := func(t *testing.T) (*S3ApiServer, *ambiguousPutFiler) { + filer := &ambiguousPutFiler{ + volume: startFakeVolumeServer(t), + entries: map[string]*filer_pb.Entry{"/buckets/" + postAuthzBucket: { + Name: postAuthzBucket, + IsDirectory: true, + Attributes: &filer_pb.FuseAttributes{}, + Extended: map[string][]byte{ + s3_constants.ExtAmzOwnerKey: []byte("000000000000"), + s3_constants.ExtPublicAccessBlockKey: []byte(`true`), + }, + }}, + apply: true, + } + return newPostAutoCreateServer(t, filer), filer + } + const key = "uploads/pic.png" + for _, fields := range []map[string]string{ + {"acl": "public-read"}, + {"acl": "authenticated-read"}, + {"x-amz-grant-read": `uri="` + s3_constants.GranteeGroupAllUsers + `"`}, + } { + s3a, filer := newServer(t) + rec := httptest.NewRecorder() + s3a.PostPolicyBucketHandler(rec, newSignedPostRequestFor(t, key, postAdminAccessKey, postAdminSecretKey, fields)) + assert.Equal(t, http.StatusForbidden, rec.Code, "%v must be refused, body: %s", fields, rec.Body.String()) + assert.Contains(t, rec.Body.String(), "AccessDenied") + filer.mu.Lock() + assert.Nil(t, filer.entries["/buckets/"+postAuthzBucket+"/"+key], "%v must not write the object", fields) + filer.mu.Unlock() + } + + s3a, filer := newServer(t) + rec := httptest.NewRecorder() + s3a.PostPolicyBucketHandler(rec, newSignedPostRequestFor(t, key, postAdminAccessKey, postAdminSecretKey, + map[string]string{"acl": "private", "success_action_status": "201"})) + assert.Equal(t, http.StatusCreated, rec.Code, "a private POST must succeed, body: %s", rec.Body.String()) + filer.mu.Lock() + defer filer.mu.Unlock() + assert.NotNil(t, filer.entries["/buckets/"+postAuthzBucket+"/"+key]) +} diff --git a/weed/s3api/s3api_object_upload_acl.go b/weed/s3api/s3api_object_upload_acl.go index 0fa9f44025..4c02ed3c24 100644 --- a/weed/s3api/s3api_object_upload_acl.go +++ b/weed/s3api/s3api_object_upload_acl.go @@ -212,6 +212,9 @@ func (s3a *S3ApiServer) preparePutObjectACL(r *http.Request, bucket string) (*ht 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 } diff --git a/weed/s3api/s3api_object_upload_acl_test.go b/weed/s3api/s3api_object_upload_acl_test.go index 067145376f..b24ed15c68 100644 --- a/weed/s3api/s3api_object_upload_acl_test.go +++ b/weed/s3api/s3api_object_upload_acl_test.go @@ -45,6 +45,7 @@ func TestPutObjectUploadACL(t *testing.T) { copySource string grantAccount, grantEmail string unregisteredWriter bool + blockPublicAcls bool } tests := []uploadACLTest{ {name: "default private", status: 200}, @@ -151,6 +152,17 @@ func TestPutObjectUploadACL(t *testing.T) { {name: "single line grants preserve condition deny", grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner",id="upload-writer"`, policy: "bucket-condition-deny", conditionValue: `id="bucket-owner",id="upload-writer"`, status: 403, errorCode: "AccessDenied"}, {name: "presigned grants preserve condition deny", grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner",id="upload-writer"`, presigned: true, policy: "bucket-condition-deny", conditionValue: `id="bucket-owner",id="upload-writer"`, status: 403, errorCode: "AccessDenied"}, {name: "extra grantee defeats allow condition", grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner"`, repeatedGrant: `id="upload-writer"`, writeOnly: true, policy: "bucket-condition-allow", conditionValue: `id="bucket-owner"`, status: 403, errorCode: "AccessDenied"}, + {name: "block public acls default", blockPublicAcls: true, status: 200}, + {name: "block public acls private", acl: "private", blockPublicAcls: true, status: 200}, + {name: "block public acls bucket owner full control", acl: "bucket-owner-full-control", blockPublicAcls: true, status: 200}, + {name: "block public acls account grant", grantHeader: s3_constants.AmzAclRead, grant: `id="bucket-owner"`, blockPublicAcls: true, status: 200}, + {name: "block public acls rejects public read", acl: "public-read", blockPublicAcls: true, status: 403, errorCode: "AccessDenied"}, + {name: "block public acls rejects public read write", acl: "public-read-write", blockPublicAcls: true, status: 403, errorCode: "AccessDenied"}, + {name: "block public acls rejects authenticated read", acl: "authenticated-read", blockPublicAcls: true, status: 403, errorCode: "AccessDenied"}, + {name: "block public acls rejects presigned public read", acl: "public-read", presigned: true, blockPublicAcls: true, status: 403, errorCode: "AccessDenied"}, + {name: "block public acls rejects all users grant", grantHeader: s3_constants.AmzAclRead, grant: `uri="` + s3_constants.GranteeGroupAllUsers + `"`, blockPublicAcls: true, status: 403, errorCode: "AccessDenied"}, + {name: "block public acls rejects authenticated users grant", grantHeader: s3_constants.AmzAclFullControl, grant: `id="bucket-owner",uri="` + s3_constants.GranteeGroupAuthenticatedUsers + `"`, blockPublicAcls: true, status: 403, errorCode: "AccessDenied"}, + {name: "block public acls rejected overwrite preserves acl", overwrite: true, acl: "public-read", blockPublicAcls: true, status: 403, errorCode: "AccessDenied"}, } // Invalid multipart parameters or copy headers must not bypass policy normalization on the actual regular-upload route. for _, policy := range []string{"bucket", "iam"} { @@ -373,7 +385,11 @@ func TestPutObjectUploadACL(t *testing.T) { } filer.entries["/buckets/"+bucket] = bucketEntry s3a.bucketConfigCache = NewBucketConfigCache(time.Minute) - s3a.bucketConfigCache.Set(bucket, &BucketConfig{Name: bucket, Owner: bucketOwner, Ownership: storedOwnership, Versioning: tt.versioning}) + bucketConfig := &BucketConfig{Name: bucket, Owner: bucketOwner, Ownership: storedOwnership, Versioning: tt.versioning} + if tt.blockPublicAcls { + bucketConfig.PublicAccessBlock = &PublicAccessBlockConfiguration{BlockPublicAcls: true} + } + s3a.bucketConfigCache.Set(bucket, bucketConfig) s3a.bucketRegistry = NewBucketRegistry(s3a) s3a.bucketRegistry.LoadBucketMetadata(bucketEntry) if tt.versioning == s3_constants.VersioningEnabled { diff --git a/weed/s3api/s3api_public_access_block.go b/weed/s3api/s3api_public_access_block.go new file mode 100644 index 0000000000..7e9ee204d3 --- /dev/null +++ b/weed/s3api/s3api_public_access_block.go @@ -0,0 +1,67 @@ +package s3api + +import ( + "encoding/xml" + "fmt" + + "github.com/aws/aws-sdk-go/service/s3" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" +) + +const maxPublicAccessBlockConfigurationSize = 4 << 10 + +// PublicAccessBlockConfiguration is a bucket's S3 Block Public Access setting. +type PublicAccessBlockConfiguration struct { + XMLNS string `xml:"xmlns,attr,omitempty"` + XMLName xml.Name `xml:"PublicAccessBlockConfiguration"` + BlockPublicAcls bool `xml:"BlockPublicAcls"` + IgnorePublicAcls bool `xml:"IgnorePublicAcls"` + BlockPublicPolicy bool `xml:"BlockPublicPolicy"` + RestrictPublicBuckets bool `xml:"RestrictPublicBuckets"` + Unknown []xml.Name `xml:",any"` +} + +func parsePublicAccessBlock(data []byte) (*PublicAccessBlockConfiguration, error) { + var config PublicAccessBlockConfiguration + if err := xml.Unmarshal(data, &config); err != nil { + return nil, err + } + if len(config.Unknown) > 0 { + return nil, fmt.Errorf("unknown element %s", config.Unknown[0].Local) + } + config.XMLNS, config.XMLName = "", xml.Name{} + return &config, nil +} + +func (c *PublicAccessBlockConfiguration) blocksPublicAcls() bool { + return c != nil && c.BlockPublicAcls +} + +func (c *PublicAccessBlockConfiguration) ignoresPublicAcls() bool { + return c != nil && c.IgnorePublicAcls +} + +// hasPublicGrant reports whether any grant names the AllUsers or +// AuthenticatedUsers group, which is what S3 treats as a public ACL. +func hasPublicGrant(grants []*s3.Grant) bool { + for _, grant := range grants { + if grant == nil || grant.Grantee == nil || grant.Grantee.URI == nil { + continue + } + switch *grant.Grantee.URI { + case s3_constants.GranteeGroupAllUsers, s3_constants.GranteeGroupAuthenticatedUsers: + return true + } + } + return false +} + +// rejectPublicGrants denies grants that would make an object or bucket +// public when the bucket blocks public ACLs. +func rejectPublicGrants(config *BucketConfig, grants []*s3.Grant) s3err.ErrorCode { + if config != nil && config.PublicAccessBlock.blocksPublicAcls() && hasPublicGrant(grants) { + return s3err.ErrAccessDenied + } + return s3err.ErrNone +} diff --git a/weed/s3api/s3api_public_access_block_test.go b/weed/s3api/s3api_public_access_block_test.go new file mode 100644 index 0000000000..b5bd4ff16d --- /dev/null +++ b/weed/s3api/s3api_public_access_block_test.go @@ -0,0 +1,251 @@ +package s3api + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/mux" + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" + "github.com/stretchr/testify/require" +) + +const testPublicAccessBlockXML = `` + + `truetrue` + + `falsetrue` + + `` + +func newPublicAccessBlockTestServer(t *testing.T, entry *filer_pb.Entry) (*S3ApiServer, *fakeLookupFiler) { + t.Helper() + t.Setenv("AWS_ACCESS_KEY_ID", "") + t.Setenv("AWS_SECRET_ACCESS_KEY", "") + filer := &fakeLookupFiler{entry: entry} + s3a := newHeadBucketTestServer(t, filer) + s3a.bucketConfigCache = NewBucketConfigCache(time.Minute) + s3a.iam = NewIdentityAccessManagementWithStore(s3a.option, nil, "memory") + t.Cleanup(s3a.iam.Shutdown) + return s3a, filer +} + +func publicAccessBlockRequest(method, bucket, body string) *http.Request { + req := httptest.NewRequest(method, "http://s3/"+bucket+"?publicAccessBlock", strings.NewReader(body)) + return mux.SetURLVars(req, map[string]string{"bucket": bucket}) +} + +func TestPutPublicAccessBlockStoresConfiguration(t *testing.T) { + const bucket = "pab-put" + s3a, filer := newPublicAccessBlockTestServer(t, &filer_pb.Entry{Name: bucket, IsDirectory: true}) + + rr := httptest.NewRecorder() + s3a.PutPublicAccessBlockHandler(rr, publicAccessBlockRequest(http.MethodPut, bucket, testPublicAccessBlockXML)) + require.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + + require.Len(t, filer.txnSeen, 1) + stored := filer.txnSeen[0].Mutations[0].SetExtended[s3_constants.ExtPublicAccessBlockKey] + config, err := parsePublicAccessBlock(stored) + require.NoError(t, err) + require.Equal(t, PublicAccessBlockConfiguration{ + BlockPublicAcls: true, + IgnorePublicAcls: true, + RestrictPublicBuckets: true, + }, *config) + + // The PUT loaded the bucket into the cache; a GET must not serve that copy. + filer.entry.Extended = filer.txnSeen[0].Mutations[0].SetExtended + rr = httptest.NewRecorder() + s3a.GetPublicAccessBlockHandler(rr, publicAccessBlockRequest(http.MethodGet, bucket, "")) + require.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + require.Contains(t, rr.Body.String(), "true") +} + +func TestUnreadablePublicAccessBlockBlocksPublicAccess(t *testing.T) { + s3a, _ := newPublicAccessBlockTestServer(t, nil) + config := s3a.newBucketConfigFromEntry("b", &filer_pb.Entry{Name: "b", IsDirectory: true, Extended: map[string][]byte{ + s3_constants.ExtPublicAccessBlockKey: []byte(""), + s3_constants.ExtAmzAclKey: []byte(`[{"Grantee":{"Type":"Group","URI":"` + s3_constants.GranteeGroupAllUsers + `"},"Permission":"READ"}]`), + }}) + require.True(t, config.PublicAccessBlock.blocksPublicAcls()) + require.True(t, config.PublicAccessBlock.ignoresPublicAcls()) + require.False(t, config.IsPublicRead) +} + +func TestBucketUpdateKeepsStoredPublicAccessBlock(t *testing.T) { + const bucket = "pab-keep" + unreadable := []byte("") + for _, stored := range [][]byte{unreadable, []byte(testPublicAccessBlockXML)} { + s3a, filer := newPublicAccessBlockTestServer(t, &filer_pb.Entry{ + Name: bucket, + IsDirectory: true, + Extended: map[string][]byte{s3_constants.ExtPublicAccessBlockKey: stored}, + }) + require.Equal(t, s3err.ErrNone, s3a.updateBucketConfig(bucket, func(config *BucketConfig) error { + config.Versioning = s3_constants.VersioningEnabled + return nil + })) + require.Len(t, filer.txnSeen, 1) + require.NotContains(t, filer.txnSeen[0].Mutations[0].SetExtended, s3_constants.ExtPublicAccessBlockKey) + require.NotContains(t, filer.txnSeen[0].Mutations[0].DeleteExtended, s3_constants.ExtPublicAccessBlockKey) + } + + s3a, filer := newPublicAccessBlockTestServer(t, &filer_pb.Entry{ + Name: bucket, + IsDirectory: true, + Extended: map[string][]byte{s3_constants.ExtPublicAccessBlockKey: unreadable}, + }) + all := `truetrue` + + `truetrue` + rr := httptest.NewRecorder() + s3a.PutPublicAccessBlockHandler(rr, publicAccessBlockRequest(http.MethodPut, bucket, all)) + require.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + require.Len(t, filer.txnSeen, 1) + written, err := parsePublicAccessBlock(filer.txnSeen[0].Mutations[0].SetExtended[s3_constants.ExtPublicAccessBlockKey]) + require.NoError(t, err) + require.True(t, written.BlockPublicAcls && written.IgnorePublicAcls && written.BlockPublicPolicy && written.RestrictPublicBuckets) +} + +func TestPutPublicAccessBlockRejectsMalformedXML(t *testing.T) { + const bucket = "pab-malformed" + s3a, filer := newPublicAccessBlockTestServer(t, &filer_pb.Entry{Name: bucket, IsDirectory: true}) + + for _, body := range []string{ + "", + "yes", + "true", + "true", + } { + rr := httptest.NewRecorder() + s3a.PutPublicAccessBlockHandler(rr, publicAccessBlockRequest(http.MethodPut, bucket, body)) + require.Equal(t, http.StatusBadRequest, rr.Code, rr.Body.String()) + require.Contains(t, rr.Body.String(), "MalformedXML") + } + require.Empty(t, filer.txnSeen) +} + +func TestGetPublicAccessBlock(t *testing.T) { + const bucket = "pab-get" + s3a, _ := newPublicAccessBlockTestServer(t, &filer_pb.Entry{Name: bucket, IsDirectory: true}) + + rr := httptest.NewRecorder() + s3a.GetPublicAccessBlockHandler(rr, publicAccessBlockRequest(http.MethodGet, bucket, "")) + require.Equal(t, http.StatusNotFound, rr.Code, rr.Body.String()) + require.Contains(t, rr.Body.String(), "NoSuchPublicAccessBlockConfiguration") + + s3a, _ = newPublicAccessBlockTestServer(t, &filer_pb.Entry{ + Name: bucket, + IsDirectory: true, + Extended: map[string][]byte{s3_constants.ExtPublicAccessBlockKey: []byte(testPublicAccessBlockXML)}, + }) + rr = httptest.NewRecorder() + s3a.GetPublicAccessBlockHandler(rr, publicAccessBlockRequest(http.MethodGet, bucket, "")) + require.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + body := rr.Body.String() + require.Equal(t, 1, strings.Count(body, "xmlns="), body) + require.Contains(t, body, ``) + require.Contains(t, body, "true") + require.Contains(t, body, "true") + require.Contains(t, body, "false") + require.Contains(t, body, "true") +} + +func TestDeletePublicAccessBlockRemovesConfiguration(t *testing.T) { + const bucket = "pab-delete" + s3a, filer := newPublicAccessBlockTestServer(t, &filer_pb.Entry{ + Name: bucket, + IsDirectory: true, + Extended: map[string][]byte{s3_constants.ExtPublicAccessBlockKey: []byte(testPublicAccessBlockXML)}, + }) + + rr := httptest.NewRecorder() + s3a.DeletePublicAccessBlockHandler(rr, publicAccessBlockRequest(http.MethodDelete, bucket, "")) + require.Equal(t, http.StatusNoContent, rr.Code, rr.Body.String()) + require.Len(t, filer.txnSeen, 1) + require.Contains(t, filer.txnSeen[0].Mutations[0].DeleteExtended, s3_constants.ExtPublicAccessBlockKey) +} + +func TestPublicAccessBlockMissingBucket(t *testing.T) { + s3a, _ := newPublicAccessBlockTestServer(t, nil) + for _, tc := range []struct { + method string + body string + handler http.HandlerFunc + }{ + {http.MethodGet, "", s3a.GetPublicAccessBlockHandler}, + {http.MethodPut, testPublicAccessBlockXML, s3a.PutPublicAccessBlockHandler}, + {http.MethodDelete, "", s3a.DeletePublicAccessBlockHandler}, + } { + rr := httptest.NewRecorder() + tc.handler(rr, publicAccessBlockRequest(tc.method, "pab-missing", tc.body)) + require.Equal(t, http.StatusNotFound, rr.Code, tc.method) + require.Contains(t, rr.Body.String(), "NoSuchBucket", tc.method) + } +} + +func TestBlockPublicAclsRejectsPublicBucketAndObjectAcls(t *testing.T) { + const bucket, object = "pab-acl", "doc.txt" + newServer := func(t *testing.T) (*S3ApiServer, *fakeLookupFiler) { + // The fake filer answers every lookup with this entry, so it serves + // as both the bucket and the object. + return newPublicAccessBlockTestServer(t, &filer_pb.Entry{ + Name: object, + Attributes: &filer_pb.FuseAttributes{}, + Extended: map[string][]byte{ + s3_constants.ExtPublicAccessBlockKey: []byte(`true`), + s3_constants.ExtAmzOwnerKey: []byte(s3_constants.AccountAdminId), + }, + }) + } + for _, tc := range []struct { + acl string + status int + }{ + {s3_constants.CannedAclPrivate, http.StatusOK}, + {s3_constants.CannedAclPublicRead, http.StatusForbidden}, + {s3_constants.CannedAclPublicReadWrite, http.StatusForbidden}, + {s3_constants.CannedAclAuthenticatedRead, http.StatusForbidden}, + } { + t.Run("bucket "+tc.acl, func(t *testing.T) { + s3a, filer := newServer(t) + req := httptest.NewRequest(http.MethodPut, "http://s3/"+bucket+"?acl", http.NoBody) + req = mux.SetURLVars(req, map[string]string{"bucket": bucket}) + req.Header.Set(s3_constants.AmzCannedAcl, tc.acl) + rr := httptest.NewRecorder() + s3a.PutBucketAclHandler(rr, req) + require.Equal(t, tc.status, rr.Code, rr.Body.String()) + if tc.status == http.StatusForbidden { + require.Contains(t, rr.Body.String(), "AccessDenied") + require.Empty(t, filer.txnSeen) + } + }) + t.Run("object "+tc.acl, func(t *testing.T) { + s3a, filer := newServer(t) + req := httptest.NewRequest(http.MethodPut, "http://s3/"+bucket+"/"+object+"?acl", http.NoBody) + req = mux.SetURLVars(req, map[string]string{"bucket": bucket, "object": object}) + req.Header.Set(s3_constants.AmzCannedAcl, tc.acl) + rr := httptest.NewRecorder() + s3a.PutObjectAclHandler(rr, req) + require.Equal(t, tc.status, rr.Code, rr.Body.String()) + if tc.status == http.StatusForbidden { + require.Contains(t, rr.Body.String(), "AccessDenied") + require.Equal(t, s3_constants.AccountAdminId, string(filer.entry.Extended[s3_constants.ExtAmzOwnerKey])) + require.NotContains(t, filer.entry.Extended, s3_constants.ExtAmzAclKey) + } + }) + } + + t.Run("bucket xml all users grant", func(t *testing.T) { + s3a, filer := newServer(t) + body := `admin` + + `` + s3_constants.GranteeGroupAllUsers + `READ` + + `` + req := httptest.NewRequest(http.MethodPut, "http://s3/"+bucket+"?acl", strings.NewReader(body)) + req = mux.SetURLVars(req, map[string]string{"bucket": bucket}) + rr := httptest.NewRecorder() + s3a.PutBucketAclHandler(rr, req) + require.Equal(t, http.StatusForbidden, rr.Code, rr.Body.String()) + require.Empty(t, filer.txnSeen) + }) +} diff --git a/weed/s3api/s3err/s3api_errors.go b/weed/s3api/s3err/s3api_errors.go index f0c8bc9479..67358067d0 100644 --- a/weed/s3api/s3err/s3api_errors.go +++ b/weed/s3api/s3err/s3api_errors.go @@ -178,6 +178,7 @@ const ( ErrAccessControlListNotSupported // The request's snapshot changed during a conditional write; clients can retry. ErrConditionalRequestConflict + ErrNoSuchPublicAccessBlockConfiguration ) // Error message constants for checksum validation @@ -765,6 +766,12 @@ var errorCodeResponse = map[ErrorCode]APIError{ Description: "The specified configuration does not exist.", HTTPStatusCode: http.StatusNotFound, }, + + ErrNoSuchPublicAccessBlockConfiguration: { + Code: "NoSuchPublicAccessBlockConfiguration", + Description: "The public access block configuration was not found", + HTTPStatusCode: http.StatusNotFound, + }, } // GetAPIError provides API Error for input API error code.