From 46bcc8af35c0168a409523343a9b82e350a7dfad Mon Sep 17 00:00:00 2001 From: niksis02 Date: Wed, 18 Feb 2026 01:44:05 +0400 Subject: [PATCH] fix: fixes object default Content-Type Fixes #1849 If no `Content-Type` is provided during object upload, S3 defaults it to `application/octet-stream`. This behavior was missing in the gateway, causing backends to persist an empty `Content-Type`, which Fiber then overrides with its default `text/plain`. The behavior has now been corrected for the object upload operations: `PutObject`, `CreateMultipartUpload`, and `CopyObject`. --- backend/azure/azure.go | 11 +---- backend/common.go | 3 +- s3api/controllers/base.go | 3 +- s3api/controllers/object-post.go | 2 +- s3api/controllers/object-put.go | 4 +- tests/integration/CompleteMultipartUpload.go | 51 ++++++++++++++++++++ tests/integration/CopyObject.go | 44 +++++++++++++++++ tests/integration/PutObject.go | 31 ++++++++++++ tests/integration/group-tests.go | 6 +++ tests/integration/utils.go | 26 +++++++++- 10 files changed, 164 insertions(+), 17 deletions(-) diff --git a/backend/azure/azure.go b/backend/azure/azure.go index 25e23719..a454a36b 100644 --- a/backend/azure/azure.go +++ b/backend/azure/azure.go @@ -352,10 +352,6 @@ func (az *Azure) PutObject(ctx context.Context, po s3response.PutObjectInput) (s opts.HTTPHeaders.BlobContentType = po.ContentType } - if opts.HTTPHeaders.BlobContentType == nil { - opts.HTTPHeaders.BlobContentType = backend.GetPtrFromString(backend.DefaultContentType) - } - uploadResp, err := az.client.UploadStream(ctx, *po.Bucket, *po.Key, po.Body, opts) if err != nil { return s3response.PutObjectOutput{}, azureErrToS3Err(err) @@ -487,16 +483,11 @@ func (az *Azure) GetObject(ctx context.Context, input *s3.GetObjectInput) (*s3.G tagcount = int32(*blobDownloadResponse.TagCount) } - contentType := blobDownloadResponse.ContentType - if contentType == nil { - contentType = backend.GetPtrFromString(backend.DefaultContentType) - } - return &s3.GetObjectOutput{ AcceptRanges: backend.GetPtrFromString("bytes"), ContentLength: blobDownloadResponse.ContentLength, ContentEncoding: blobDownloadResponse.ContentEncoding, - ContentType: contentType, + ContentType: blobDownloadResponse.ContentType, ContentDisposition: blobDownloadResponse.ContentDisposition, ContentLanguage: blobDownloadResponse.ContentLanguage, CacheControl: blobDownloadResponse.CacheControl, diff --git a/backend/common.go b/backend/common.go index 4a9e4473..06d1a430 100644 --- a/backend/common.go +++ b/backend/common.go @@ -38,8 +38,7 @@ import ( const ( // this is the media type for directories in AWS and Nextcloud - DirContentType = "application/x-directory" - DefaultContentType = "binary/octet-stream" + DirContentType = "application/x-directory" // this is the minimum allowed size for mp parts MinPartSize = 5 * 1024 * 1024 diff --git a/s3api/controllers/base.go b/s3api/controllers/base.go index e257b0d4..cc8be7eb 100644 --- a/s3api/controllers/base.go +++ b/s3api/controllers/base.go @@ -51,7 +51,8 @@ const ( minPartNumber = 1 maxPartNumber = 10000 - defaultRegion = "us-east-1" + defaultRegion = "us-east-1" + defaultContentType = "binary/octet-stream" ) var ( diff --git a/s3api/controllers/object-post.go b/s3api/controllers/object-post.go index 844d4479..1e87274c 100644 --- a/s3api/controllers/object-post.go +++ b/s3api/controllers/object-post.go @@ -145,7 +145,7 @@ func (c S3ApiController) SelectObjectContent(ctx *fiber.Ctx) (*Response, error) func (c S3ApiController) CreateMultipartUpload(ctx *fiber.Ctx) (*Response, error) { bucket := ctx.Params("bucket") key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket)) - contentType := ctx.Get("Content-Type") + contentType := ctx.Get("Content-Type", defaultContentType) contentDisposition := ctx.Get("Content-Disposition") contentLanguage := ctx.Get("Content-Language") cacheControl := ctx.Get("Cache-Control") diff --git a/s3api/controllers/object-put.go b/s3api/controllers/object-put.go index ce46580c..caccab47 100644 --- a/s3api/controllers/object-put.go +++ b/s3api/controllers/object-put.go @@ -497,7 +497,7 @@ func (c S3ApiController) CopyObject(ctx *fiber.Ctx) (*Response, error) { copySource := strings.TrimPrefix(ctx.Get("X-Amz-Copy-Source"), "/") 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") + contentType := ctx.Get("Content-Type", defaultContentType) contentEncoding := ctx.Get("Content-Encoding") contentDisposition := ctx.Get("Content-Disposition") contentLanguage := ctx.Get("Content-Language") @@ -655,7 +655,7 @@ func (c S3ApiController) CopyObject(ctx *fiber.Ctx) (*Response, error) { func (c S3ApiController) PutObject(ctx *fiber.Ctx) (*Response, error) { bucket := ctx.Params("bucket") key := strings.TrimPrefix(ctx.Path(), fmt.Sprintf("/%s/", bucket)) - contentType := ctx.Get("Content-Type") + contentType := ctx.Get("Content-Type", defaultContentType) contentEncoding := ctx.Get("Content-Encoding") contentDisposition := ctx.Get("Content-Disposition") contentLanguage := ctx.Get("Content-Language") diff --git a/tests/integration/CompleteMultipartUpload.go b/tests/integration/CompleteMultipartUpload.go index e0b1380a..18643946 100644 --- a/tests/integration/CompleteMultipartUpload.go +++ b/tests/integration/CompleteMultipartUpload.go @@ -1607,6 +1607,57 @@ func CompleteMultipartUpload_invalid_part_number(s *S3Conf) error { }) } +func CompleteMultipartUpload_defualt_content_type(s *S3Conf) error { + testName := "CompleteMultipartUpload_defualt_content_type" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + obj := "my-object" + mp, err := createMp(s3client, bucket, obj) + if err != nil { + return err + } + + parts, _, err := uploadParts(s3client, 10, 1, bucket, obj, *mp.UploadId) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = s3client.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{ + Bucket: &bucket, + Key: &obj, + UploadId: mp.UploadId, + MultipartUpload: &types.CompletedMultipartUpload{ + Parts: []types.CompletedPart{ + { + ETag: parts[0].ETag, + PartNumber: parts[0].PartNumber, + }, + }, + }, + }) + cancel() + if err != nil { + return err + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + res, err := s3client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: &bucket, + Key: &obj, + }) + cancel() + if err != nil { + return err + } + + if getString(res.ContentType) != defaultContentType { + return fmt.Errorf("expected default %s Content-Type, instead got %s", defaultContentType, getString(res.ContentType)) + } + + return nil + }) +} + func CompleteMultipartUpload_success(s *S3Conf) error { testName := "CompleteMultipartUpload_success" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { diff --git a/tests/integration/CopyObject.go b/tests/integration/CopyObject.go index 9952708c..87d145d4 100644 --- a/tests/integration/CopyObject.go +++ b/tests/integration/CopyObject.go @@ -746,6 +746,49 @@ func CopyObject_should_replace_meta_props(s *S3Conf) error { }) } +func CopyObject_default_content_type_with_replace_metadata(s *S3Conf) error { + testName := "CopyObject_default_content_type_with_replace_metadata" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + srcObj, dstObj := "source-object", "destination-object" + _, err := putObjectWithData(1, &s3.PutObjectInput{ + Bucket: &bucket, + Key: &srcObj, + }, s3client) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err = s3client.CopyObject(ctx, &s3.CopyObjectInput{ + Bucket: &bucket, + Key: &dstObj, + CopySource: getPtr(fmt.Sprintf("%s/%s", bucket, srcObj)), + // with metadata directive REPLACE, Content-Type should default to binary/octet-stream + MetadataDirective: types.MetadataDirectiveReplace, + }) + cancel() + if err != nil { + return err + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + res, err := s3client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: &bucket, + Key: &dstObj, + }) + cancel() + if err != nil { + return err + } + + if getString(res.ContentType) != defaultContentType { + return fmt.Errorf("expected default %s Content-Type, instead got %s", defaultContentType, getString(res.ContentType)) + } + + return nil + }) +} + func CopyObject_missing_bucket_lock(s *S3Conf) error { testName := "CopyObject_missing_bucket_lock" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { @@ -808,6 +851,7 @@ func CopyObject_invalid_legal_hold(s *S3Conf) error { return checkApiErr(err, s3err.GetAPIError(s3err.ErrInvalidLegalHoldStatus)) }, withLock()) } + func CopyObject_invalid_object_lock_mode(s *S3Conf) error { testName := "CopyObject_invalid_object_lock_mode" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { diff --git a/tests/integration/PutObject.go b/tests/integration/PutObject.go index 6298fd89..3618ca8c 100644 --- a/tests/integration/PutObject.go +++ b/tests/integration/PutObject.go @@ -913,6 +913,37 @@ func PutObject_success(s *S3Conf) error { }) } +func PutObject_default_content_type(s *S3Conf) error { + testName := "PutObject_default_content_type" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + key := "my-object" + _, err := putObjectWithData(10, &s3.PutObjectInput{ + Bucket: &bucket, + Key: &key, + ContentType: nil, + }, s3client) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + res, err := s3client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: &bucket, + Key: &key, + }) + cancel() + if err != nil { + return err + } + + if getString(res.ContentType) != defaultContentType { + return fmt.Errorf("expected default %s Content-Type, instead got %s", defaultContentType, getString(res.ContentType)) + } + + return nil + }) +} + func PutObject_invalid_credentials(s *S3Conf) error { testName := "PutObject_invalid_credentials" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go index 5e9dd124..93ad0274 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -183,6 +183,7 @@ func TestPutObject(ts *TestState) { ts.Run(PutObject_with_metadata) } ts.Run(PutObject_success) + ts.Run(PutObject_default_content_type) if !ts.conf.versioningEnabled { ts.Run(PutObject_racey_success) } @@ -330,6 +331,7 @@ func TestCopyObject(ts *TestState) { ts.Run(CopyObject_non_existing_dir_object) ts.Run(CopyObject_should_copy_meta_props) ts.Run(CopyObject_should_replace_meta_props) + ts.Run(CopyObject_default_content_type_with_replace_metadata) ts.Run(CopyObject_missing_bucket_lock) ts.Run(CopyObject_invalid_legal_hold) ts.Run(CopyObject_invalid_object_lock_mode) @@ -487,6 +489,7 @@ func TestCompleteMultipartUpload(ts *TestState) { ts.Run(CompletedMultipartUpload_non_existing_bucket) ts.Run(CompleteMultipartUpload_incorrect_part_number) ts.Run(CompleteMultipartUpload_invalid_part_number) + ts.Run(CompleteMultipartUpload_defualt_content_type) ts.Run(CompleteMultipartUpload_invalid_ETag) ts.Run(CompleteMultipartUpload_small_upload_size) ts.Run(CompleteMultipartUpload_empty_parts) @@ -1292,6 +1295,7 @@ func GetIntTests() IntTests { "PutObject_special_chars": PutObject_special_chars, "PutObject_tagging": PutObject_tagging, "PutObject_success": PutObject_success, + "PutObject_default_content_type": PutObject_default_content_type, "PutObject_invalid_object_names": PutObject_invalid_object_names, "PutObject_object_acl_not_supported": PutObject_object_acl_not_supported, "PutObject_false_negative_object_names": PutObject_false_negative_object_names, @@ -1392,6 +1396,7 @@ func GetIntTests() IntTests { "CopyObject_non_existing_dir_object": CopyObject_non_existing_dir_object, "CopyObject_should_copy_meta_props": CopyObject_should_copy_meta_props, "CopyObject_should_replace_meta_props": CopyObject_should_replace_meta_props, + "CopyObject_default_content_type_with_replace_metadata": CopyObject_default_content_type_with_replace_metadata, "CopyObject_missing_bucket_lock": CopyObject_missing_bucket_lock, "CopyObject_invalid_legal_hold": CopyObject_invalid_legal_hold, "CopyObject_invalid_object_lock_mode": CopyObject_invalid_object_lock_mode, @@ -1497,6 +1502,7 @@ func GetIntTests() IntTests { "AbortMultipartUpload_if_match_initiated_time": AbortMultipartUpload_if_match_initiated_time, "CompletedMultipartUpload_non_existing_bucket": CompletedMultipartUpload_non_existing_bucket, "CompleteMultipartUpload_invalid_part_number": CompleteMultipartUpload_invalid_part_number, + "CompleteMultipartUpload_defualt_content_type": CompleteMultipartUpload_defualt_content_type, "CompleteMultipartUpload_invalid_ETag": CompleteMultipartUpload_invalid_ETag, "CompleteMultipartUpload_small_upload_size": CompleteMultipartUpload_small_upload_size, "CompleteMultipartUpload_empty_parts": CompleteMultipartUpload_empty_parts, diff --git a/tests/integration/utils.go b/tests/integration/utils.go index c9d69cdc..2091b2e3 100644 --- a/tests/integration/utils.go +++ b/tests/integration/utils.go @@ -49,6 +49,8 @@ import ( "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/aws/smithy-go" + "github.com/aws/smithy-go/middleware" + smithyhttp "github.com/aws/smithy-go/transport/http" "github.com/versity/versitygw/s3err" "golang.org/x/sync/errgroup" "golang.org/x/sync/semaphore" @@ -560,7 +562,29 @@ func putObjectWithData(lgth int64, input *s3.PutObjectInput, client *s3.Client) } ctx, cancel := context.WithTimeout(context.Background(), longTimeout) - res, err := client.PutObject(ctx, input) + res, err := client.PutObject(ctx, input, func(o *s3.Options) { + // if input.Body is not nil, aws sdk hardcodes Content-Type: application/octet-stream + // this adds a new middleware in the stack to remove the Content-Type header, if + // it isn't explicitly provided as 'PutObject' input. Place the middleware + // right before "Signing" middleware to avoid incorrect request signature calculation + if input.ContentType == nil { + o.APIOptions = append(o.APIOptions, func(stack *middleware.Stack) error { + return stack.Finalize.Insert( + middleware.FinalizeMiddlewareFunc("UnsetContentType", + func(ctx context.Context, in middleware.FinalizeInput, next middleware.FinalizeHandler) ( + out middleware.FinalizeOutput, md middleware.Metadata, err error, + ) { + if req, ok := in.Request.(*smithyhttp.Request); ok { + req.Header.Del("Content-Type") + } + return next.HandleFinalize(ctx, in) + }), + "Signing", + middleware.Before, + ) + }) + } + }) cancel() if err != nil { return nil, err