From 052f2364cc55faf0357cffaf2967b27e7fe1f66e Mon Sep 17 00:00:00 2001 From: niksis02 Date: Wed, 1 Apr 2026 21:44:33 +0400 Subject: [PATCH] feat: implement x-amz-source-expected-bucket-owner for CopyObject and UploadPartCopy Closes #1897 Extract the `X-Amz-Source-Expected-Bucket-Owner` header for CopyObject and UploadPartCopy. Verify the source bucket owner in the backend and if the provided access key id doesn't match, return an `AccessDenied` error. --- backend/azure/azure.go | 14 +++++++++++ backend/posix/posix.go | 28 +++++++++++++++++++++ s3api/controllers/object-put.go | 4 +++ tests/integration/CopyObject.go | 30 +++++++++++++++++++++++ tests/integration/UploadPartCopy.go | 38 +++++++++++++++++++++++++++++ tests/integration/group-tests.go | 4 +++ 6 files changed, 118 insertions(+) diff --git a/backend/azure/azure.go b/backend/azure/azure.go index db20615d..2dcc79e9 100644 --- a/backend/azure/azure.go +++ b/backend/azure/azure.go @@ -956,6 +956,20 @@ func (az *Azure) CopyObject(ctx context.Context, input s3response.CopyObjectInpu return s3response.CopyObjectOutput{}, err } + if input.ExpectedSourceBucketOwner != nil && *input.ExpectedSourceBucketOwner != "" { + aclData, err := az.GetBucketAcl(ctx, &s3.GetBucketAclInput{Bucket: &srcBucket}) + if err != nil { + return s3response.CopyObjectOutput{}, err + } + srcAcl, err := auth.ParseACL(aclData) + if err != nil { + return s3response.CopyObjectOutput{}, err + } + if srcAcl.Owner != *input.ExpectedSourceBucketOwner { + return s3response.CopyObjectOutput{}, s3err.GetAPIError(s3err.ErrAccessDenied) + } + } + if !areNils(input.CopySourceIfMatch, input.CopySourceIfNoneMatch) || !areNils(input.CopySourceIfModifiedSince, input.CopySourceIfUnmodifiedSince) { _, err = az.HeadObject(ctx, &s3.HeadObjectInput{ Bucket: &srcBucket, diff --git a/backend/posix/posix.go b/backend/posix/posix.go index 3b635a01..469b2bf6 100644 --- a/backend/posix/posix.go +++ b/backend/posix/posix.go @@ -3053,6 +3053,20 @@ func (p *Posix) UploadPartCopy(ctx context.Context, upi *s3.UploadPartCopyInput) return s3response.CopyPartResult{}, fmt.Errorf("stat bucket: %w", err) } + if upi.ExpectedSourceBucketOwner != nil && *upi.ExpectedSourceBucketOwner != "" { + aclData, err := p.meta.RetrieveAttribute(nil, srcBucket, "", aclkey) + if err != nil { + return s3response.CopyPartResult{}, fmt.Errorf("get src bucket acl: %w", err) + } + srcAcl, err := auth.ParseACL(aclData) + if err != nil { + return s3response.CopyPartResult{}, err + } + if srcAcl.Owner != *upi.ExpectedSourceBucketOwner { + return s3response.CopyPartResult{}, s3err.GetAPIError(s3err.ErrAccessDenied) + } + } + vStatus, err := p.getBucketVersioningStatus(ctx, srcBucket) if err != nil { return s3response.CopyPartResult{}, err @@ -4674,6 +4688,20 @@ func (p *Posix) CopyObject(ctx context.Context, input s3response.CopyObjectInput return s3response.CopyObjectOutput{}, fmt.Errorf("stat bucket: %w", err) } + if input.ExpectedSourceBucketOwner != nil && *input.ExpectedSourceBucketOwner != "" { + aclData, err := p.meta.RetrieveAttribute(nil, srcBucket, "", aclkey) + if err != nil && !errors.Is(err, meta.ErrNoSuchKey) { + return s3response.CopyObjectOutput{}, fmt.Errorf("get src bucket acl: %w", err) + } + srcAcl, err := auth.ParseACL(aclData) + if err != nil { + return s3response.CopyObjectOutput{}, err + } + if srcAcl.Owner != *input.ExpectedSourceBucketOwner { + return s3response.CopyObjectOutput{}, s3err.GetAPIError(s3err.ErrAccessDenied) + } + } + vStatus, err := p.getBucketVersioningStatus(ctx, srcBucket) if err != nil { return s3response.CopyObjectOutput{}, err diff --git a/s3api/controllers/object-put.go b/s3api/controllers/object-put.go index e489ec00..0aa7657d 100644 --- a/s3api/controllers/object-put.go +++ b/s3api/controllers/object-put.go @@ -360,6 +360,7 @@ func (c S3ApiController) UploadPartCopy(ctx *fiber.Ctx) (*Response, error) { key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket)) copySource := strings.TrimPrefix(ctx.Get("X-Amz-Copy-Source"), "/") copySrcRange := ctx.Get("X-Amz-Copy-Source-Range") + expectedSrcBucketOwnerUPC := ctx.Get("X-Amz-Source-Expected-Bucket-Owner") partNumber := int32(ctx.QueryInt("partNumber", -1)) uploadId := ctx.Query("uploadId") // context locals @@ -429,6 +430,7 @@ func (c S3ApiController) UploadPartCopy(ctx *fiber.Ctx) (*Response, error) { CopySourceIfNoneMatch: preconditionHdrs.IfNoneMatch, CopySourceIfModifiedSince: preconditionHdrs.IfModSince, CopySourceIfUnmodifiedSince: preconditionHdrs.IfUnmodeSince, + ExpectedSourceBucketOwner: &expectedSrcBucketOwnerUPC, }) var headers map[string]*string if err == nil && resp.CopySourceVersionId != "" { @@ -500,6 +502,7 @@ func (c S3ApiController) CopyObject(ctx *fiber.Ctx) (*Response, error) { bucket := ctx.Params("bucket") key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket)) copySource := strings.TrimPrefix(ctx.Get("X-Amz-Copy-Source"), "/") + expectedSrcBucketOwner := ctx.Get("X-Amz-Source-Expected-Bucket-Owner") metaDirective := types.MetadataDirective(ctx.Get("X-Amz-Metadata-Directive", string(types.MetadataDirectiveCopy))) taggingDirective := types.TaggingDirective(ctx.Get("X-Amz-Tagging-Directive", string(types.TaggingDirectiveCopy))) contentType := ctx.Get("Content-Type", defaultContentType) @@ -626,6 +629,7 @@ func (c S3ApiController) CopyObject(ctx *fiber.Ctx) (*Response, error) { CopySourceIfModifiedSince: preconditionHdrs.IfModSince, CopySourceIfUnmodifiedSince: preconditionHdrs.IfUnmodeSince, ExpectedBucketOwner: &acct.Access, + ExpectedSourceBucketOwner: &expectedSrcBucketOwner, Metadata: metadata, MetadataDirective: metaDirective, StorageClass: types.StorageClass(storageClass), diff --git a/tests/integration/CopyObject.go b/tests/integration/CopyObject.go index 187b7065..4de9eb38 100644 --- a/tests/integration/CopyObject.go +++ b/tests/integration/CopyObject.go @@ -1703,3 +1703,33 @@ func CopyObject_object_acl_not_supported(s *S3Conf) error { return nil }) } + +func CopyObject_incorrect_source_bucket_expected_owner(s *S3Conf) error { + testName := "CopyObject_incorrect_source_bucket_expected_owner" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + srcBucket := getBucketName() + err := setup(s, srcBucket) + if err != nil { + return err + } + defer func() { + _ = teardown(s, srcBucket) + }() + + srcObj := "my-obj" + _, err = putObjects(s3client, []string{srcObj}, srcBucket) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = s3client.CopyObject(ctx, &s3.CopyObjectInput{ + Bucket: &bucket, + Key: getPtr("dst-obj"), + CopySource: getPtr(fmt.Sprintf("%v/%v", srcBucket, srcObj)), + ExpectedSourceBucketOwner: getPtr("incorrect-owner"), + }) + cancel() + return checkApiErr(err, s3err.GetAPIError(s3err.ErrAccessDenied)) + }) +} diff --git a/tests/integration/UploadPartCopy.go b/tests/integration/UploadPartCopy.go index 54ababd5..8201323b 100644 --- a/tests/integration/UploadPartCopy.go +++ b/tests/integration/UploadPartCopy.go @@ -1020,3 +1020,41 @@ func UploadPartCopy_should_calculate_the_checksum(s *S3Conf) error { return nil }) } + +func UploadPartCopy_incorrect_source_bucket_expected_owner(s *S3Conf) error { + testName := "UploadPartCopy_incorrect_source_bucket_expected_owner" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + srcBucket := getBucketName() + err := setup(s, srcBucket) + if err != nil { + return err + } + defer func() { + _ = teardown(s, srcBucket) + }() + + srcObj := "src-obj" + _, err = putObjects(s3client, []string{srcObj}, srcBucket) + if err != nil { + return err + } + + obj := "my-obj" + mp, err := createMp(s3client, bucket, obj) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = s3client.UploadPartCopy(ctx, &s3.UploadPartCopyInput{ + Bucket: &bucket, + Key: &obj, + CopySource: getPtr(fmt.Sprintf("%v/%v", srcBucket, srcObj)), + UploadId: mp.UploadId, + PartNumber: getPtr(int32(1)), + ExpectedSourceBucketOwner: getPtr("incorrect-owner"), + }) + cancel() + return checkApiErr(err, s3err.GetAPIError(s3err.ErrAccessDenied)) + }) +} diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index 588643fd..868fdf26 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -362,6 +362,7 @@ func TestCopyObject(ts *TestState) { } ts.Run(CopyObject_with_special_characters) ts.Run(CopyObject_success) + ts.Run(CopyObject_incorrect_source_bucket_expected_owner) } func TestPutObjectTagging(ts *TestState) { @@ -451,6 +452,7 @@ func TestUploadPartCopy(ts *TestState) { ts.Run(UploadPartCopy_should_calculate_the_checksum) ts.Run(UploadPartCopy_conditional_reads) } + ts.Run(UploadPartCopy_incorrect_source_bucket_expected_owner) } func TestListParts(ts *TestState) { @@ -1489,6 +1491,7 @@ func GetIntTests() IntTests { "CopyObject_to_itself_by_replacing_the_checksum": CopyObject_to_itself_by_replacing_the_checksum, "CopyObject_with_special_characters": CopyObject_with_special_characters, "CopyObject_success": CopyObject_success, + "CopyObject_incorrect_source_bucket_expected_owner": CopyObject_incorrect_source_bucket_expected_owner, "PutObjectTagging_non_existing_object": PutObjectTagging_non_existing_object, "PutObjectTagging_long_tags": PutObjectTagging_long_tags, "PutObjectTagging_duplicate_keys": PutObjectTagging_duplicate_keys, @@ -1548,6 +1551,7 @@ func GetIntTests() IntTests { "UploadPartCopy_greater_range_than_obj_size": UploadPartCopy_greater_range_than_obj_size, "UploadPartCopy_by_range_success": UploadPartCopy_by_range_success, "UploadPartCopy_conditional_reads": UploadPartCopy_conditional_reads, + "UploadPartCopy_incorrect_source_bucket_expected_owner": UploadPartCopy_incorrect_source_bucket_expected_owner, "UploadPartCopy_should_copy_the_checksum": UploadPartCopy_should_copy_the_checksum, "UploadPartCopy_should_not_copy_the_checksum": UploadPartCopy_should_not_copy_the_checksum, "UploadPartCopy_should_calculate_the_checksum": UploadPartCopy_should_calculate_the_checksum,