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.
This commit is contained in:
niksis02
2026-04-01 21:44:33 +04:00
parent e0209ebab4
commit 052f2364cc
6 changed files with 118 additions and 0 deletions
+14
View File
@@ -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,
+28
View File
@@ -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
+4
View File
@@ -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),
+30
View File
@@ -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))
})
}
+38
View File
@@ -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))
})
}
+4
View File
@@ -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,