diff --git a/s3api/controllers/object-post.go b/s3api/controllers/object-post.go index ffd0d346..1d5f456f 100644 --- a/s3api/controllers/object-post.go +++ b/s3api/controllers/object-post.go @@ -154,7 +154,6 @@ func (c S3ApiController) CreateMultipartUpload(ctx *fiber.Ctx) (*Response, error contentEncoding := ctx.Get("Content-Encoding") tagging := ctx.Get("X-Amz-Tagging") expires := ctx.Get("Expires") - metadata := utils.GetUserMetaData(&ctx.Request().Header) // context locals acct := utils.ContextKeyAccount.Get(ctx).(auth.Account) isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool) @@ -180,6 +179,15 @@ func (c S3ApiController) CreateMultipartUpload(ctx *fiber.Ctx) (*Response, error }, err } + metadata, err := utils.GetUserMetaData(&ctx.Request().Header) + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + objLockState, err := utils.ParsObjectLockHdrs(ctx) if err != nil { return &Response{ diff --git a/s3api/controllers/object-post_test.go b/s3api/controllers/object-post_test.go index 89568f48..b24aff6e 100644 --- a/s3api/controllers/object-post_test.go +++ b/s3api/controllers/object-post_test.go @@ -18,6 +18,7 @@ import ( "bufio" "context" "encoding/xml" + "strings" "testing" "github.com/aws/aws-sdk-go-v2/service/s3" @@ -235,6 +236,23 @@ func TestS3ApiController_CreateMultipartUpload(t *testing.T) { err: s3err.GetAPIError(s3err.ErrAccessDenied), }, }, + { + name: "invalid metadata header", + input: testInput{ + locals: defaultLocals, + headers: map[string]string{ + "x-amz-meta-key": strings.Repeat("a", 2048), + }, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: s3err.GetAPIError(s3err.ErrMetadataTooLarge), + }, + }, { name: "invalid object lock headers", input: testInput{ diff --git a/s3api/controllers/object-put.go b/s3api/controllers/object-put.go index 25120b94..9cf97f1b 100644 --- a/s3api/controllers/object-put.go +++ b/s3api/controllers/object-put.go @@ -551,7 +551,14 @@ func (c S3ApiController) CopyObject(ctx *fiber.Ctx) (*Response, error) { }, s3err.GetAPIError(s3err.ErrNonEmptyRequestBody) } - metadata := utils.GetUserMetaData(&ctx.Request().Header) + metadata, err := utils.GetUserMetaData(&ctx.Request().Header) + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } if metaDirective != "" && metaDirective != types.MetadataDirectiveCopy && metaDirective != types.MetadataDirectiveReplace { debuglogger.Logf("invalid metadata directive: %v", metaDirective) @@ -676,9 +683,6 @@ func (c S3ApiController) PutObject(ctx *fiber.Ctx) (*Response, error) { contentLengthStr = decodedLength } - // load the meta headers - metadata := utils.GetUserMetaData(&ctx.Request().Header) - err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{ Readonly: c.readonly, @@ -700,6 +704,16 @@ func (c S3ApiController) PutObject(ctx *fiber.Ctx) (*Response, error) { }, err } + // load the meta headers + metadata, err := utils.GetUserMetaData(&ctx.Request().Header) + if err != nil { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, err + } + err = auth.CheckObjectAccess(ctx.Context(), bucket, acct.Access, []types.ObjectIdentifier{{Key: &key}}, true, IsBucketPublic, c.be, true) if err != nil { return &Response{ diff --git a/s3api/controllers/object-put_test.go b/s3api/controllers/object-put_test.go index 13b48db9..01aa1164 100644 --- a/s3api/controllers/object-put_test.go +++ b/s3api/controllers/object-put_test.go @@ -963,6 +963,24 @@ func TestS3ApiController_CopyObject(t *testing.T) { err: s3err.GetAPIError(s3err.ErrNonEmptyRequestBody), }, }, + { + name: "invalid metadata header", + input: testInput{ + locals: defaultLocals, + headers: map[string]string{ + "X-Amz-Copy-Source": "bucket/object", + "x-amz-meta-key": strings.Repeat("b", 2048), + }, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: s3err.GetAPIError(s3err.ErrMetadataTooLarge), + }, + }, { name: "invalid metadata directive", input: testInput{ @@ -1208,6 +1226,24 @@ func TestS3ApiController_PutObject(t *testing.T) { err: s3err.GetAPIError(s3err.ErrInvalidRequest), }, }, + { + name: "invalid metadata header", + input: testInput{ + locals: defaultLocals, + extraMockErr: s3err.GetAPIError(s3err.ErrObjectLockConfigurationNotFound), + headers: map[string]string{ + "x-amz-meta-something": strings.Repeat("c", 2050), + }, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: s3err.GetAPIError(s3err.ErrMetadataTooLarge), + }, + }, { name: "invalid object lock headers", input: testInput{ diff --git a/s3api/server.go b/s3api/server.go index 0e7728b0..44f2ddb0 100644 --- a/s3api/server.go +++ b/s3api/server.go @@ -91,6 +91,10 @@ func New( DisableStartupMessage: true, ErrorHandler: globalErrorHandler, Concurrency: server.maxConnections, + // Sets buffer limit to read/parse incoming requests + // if the limit is reached, fiber/fasthttp will throw an error + // in the global error handler + ReadBufferSize: 8 * 1024, // 8 KB }) server.app = app @@ -259,13 +263,20 @@ func globalErrorHandler(ctx *fiber.Ctx, er error) error { // handle the fiber specific errors var fiberErr *fiber.Error if errors.As(er, &fiberErr) { + if errors.Is(fiberErr, fiber.ErrRequestHeaderFieldsTooLarge) { + debuglogger.Logf("total request headers size exceeds the allowed 8KB") + ctx.Status(http.StatusBadRequest) + return nil + } if strings.Contains(fiberErr.Message, "cannot parse Content-Length") { + debuglogger.Logf("failed to parse Content-Length") ctx.Status(http.StatusBadRequest) return nil } if strings.Contains(fiberErr.Message, "error when reading request headers") { // This error means fiber failed to parse the incoming request // which is a malfoedmed one. Return a BadRequest in this case + debuglogger.Logf("failed to parse the http request") err := s3err.GetAPIError(s3err.ErrCannotParseHTTPRequest) ctx.Status(err.HTTPStatusCode) return ctx.Send(s3err.GetAPIErrorResponse(err, "", "", "")) diff --git a/s3api/utils/utils.go b/s3api/utils/utils.go index 59dc1dc6..bd98621f 100644 --- a/s3api/utils/utils.go +++ b/s3api/utils/utils.go @@ -55,20 +55,52 @@ func SetBucketNameValidationStrict(strict bool) { strictBucketNameValidation.Store(strict) } -func GetUserMetaData(headers *fasthttp.RequestHeader) (metadata map[string]string) { - metadata = make(map[string]string) +// maximum allowed size (2 KB) for all user-defined +// object metadata combined, excluded the 'x-amz-meta-' prefix +const maxMetadataSize = 2048 + +// GetUserMetaData extracts user metadata from headers with the "x-amz-meta-" prefix. +// Keys are normalized to lowercase and duplicate headers are merged as +// comma-separated values. The total metadata size is validated against the +// 2 KB S3 limit. +func GetUserMetaData(headers *fasthttp.RequestHeader) (map[string]string, error) { + metadata := make(map[string]string) + var metadataSize int + headers.DisableNormalizing() + defer headers.EnableNormalizing() + for key, value := range headers.AllInOrder() { hKey := string(key) if strings.HasPrefix(strings.ToLower(hKey), "x-amz-meta-") { trimmedKey := strings.ToLower(hKey[11:]) headerValue := string(value) - metadata[trimmedKey] = headerValue + + if existingVal, ok := metadata[trimmedKey]; ok { + // S3 combines multiple metadata headers with the same key. + // For example: + // x-amz-meta-Key: value1 + // x-amz-meta-kEy: value2 + // x-amz-meta-keY: value3 + // + // These are merged into a single lowercased key with the values + // joined as a comma-separated list: + // key: value1,value2,value3 + metadataSize += len(headerValue) + 1 + metadata[trimmedKey] = existingVal + "," + headerValue + } else { + metadataSize += len(trimmedKey) + len(headerValue) + metadata[trimmedKey] = headerValue + } + + if metadataSize > maxMetadataSize { + debuglogger.Logf("total meta headers size exceeded the maximum allowed: (size): %v, (max): %v", metadataSize, maxMetadataSize) + return nil, s3err.GetAPIError(s3err.ErrMetadataTooLarge) + } } } - headers.EnableNormalizing() - return + return metadata, nil } func createHttpRequestFromCtx(ctx *fiber.Ctx, signedHdrs []string, contentLength int64, streamBody bool) (*http.Request, error) { diff --git a/s3api/utils/utils_test.go b/s3api/utils/utils_test.go index 077dc9dc..e19e783e 100644 --- a/s3api/utils/utils_test.go +++ b/s3api/utils/utils_test.go @@ -15,12 +15,14 @@ package utils import ( + "bufio" "bytes" "encoding/xml" "errors" "math/rand" "net/http" "reflect" + "strings" "testing" "time" @@ -94,35 +96,134 @@ func TestCreateHttpRequestFromCtx(t *testing.T) { } } -func TestGetUserMetaData(t *testing.T) { - type args struct { - headers *fasthttp.RequestHeader +// a helper method to construct a raw http request with the given http request headers +// to further parse with fasthttp.Request.Read and return fasthttp.RequestHeader +func createHeadersFromRawRequest(t *testing.T, hdrs [][2]string) *fasthttp.RequestHeader { + t.Helper() + + var b strings.Builder + b.WriteString("PUT / HTTP/1.1\r\n") + b.WriteString("Host: example.com\r\n") + for _, kv := range hdrs { + b.WriteString(kv[0]) + b.WriteString(": ") + b.WriteString(kv[1]) + b.WriteString("\r\n") } + b.WriteString("\r\n") - app := fiber.New() - - // Case 1 - ctx := app.AcquireCtx(&fasthttp.RequestCtx{}) - req := ctx.Request() + var req fasthttp.Request + if err := req.Read(bufio.NewReader(bytes.NewReader([]byte(b.String())))); err != nil { + t.Fatalf("failed to parse raw request: %v", err) + } + return &req.Header +} +func TestGetUserMetaData(t *testing.T) { tests := []struct { - name string - args args - wantMetadata map[string]string + name string + hdrs [][2]string + want map[string]string + wantErr error }{ { - name: "Success-empty-response", - args: args{ - headers: &req.Header, + name: "no metadata headers", + hdrs: [][2]string{ + {"Content-Type", "application/json"}, + }, + want: map[string]string{}, + }, + { + name: "single metadata header", + hdrs: [][2]string{ + {"x-amz-meta-foo", "bar"}, + }, + want: map[string]string{"foo": "bar"}, + }, + { + name: "multiple metadata headers", + hdrs: [][2]string{ + {"x-amz-meta-foo", "bar"}, + {"x-amz-meta-baz", "qux"}, + }, + want: map[string]string{"foo": "bar", "baz": "qux"}, + }, + { + name: "case-insensitive prefix and key lowercasing", + hdrs: [][2]string{ + {"X-Amz-Meta-TestKey", "Value"}, + }, + want: map[string]string{"testkey": "Value"}, + }, + { + name: "ignores non-metadata headers", + hdrs: [][2]string{ + {"authorization", "token"}, + {"x-amz-meta-foo", "bar"}, + }, + want: map[string]string{"foo": "bar"}, + }, + { + name: "metadata size exceeds limit (single header)", + hdrs: [][2]string{ + {"x-amz-meta-big", strings.Repeat("a", maxMetadataSize+1)}, + }, + wantErr: s3err.GetAPIError(s3err.ErrMetadataTooLarge), + }, + { + name: "metadata cumulative size exceeds limit (multiple headers)", + hdrs: [][2]string{ + {"x-amz-meta-a", strings.Repeat("a", maxMetadataSize/2)}, + {"x-amz-meta-b", strings.Repeat("b", maxMetadataSize/2+10)}, + }, + wantErr: s3err.GetAPIError(s3err.ErrMetadataTooLarge), + }, + { + name: "duplicate keys combined", + hdrs: [][2]string{ + {"x-amz-meta-Foo", "first"}, + {"x-amz-meta-foo", "second"}, + }, + want: map[string]string{"foo": "first,second"}, + }, + { + name: "duplicate same value keys combined", + hdrs: [][2]string{ + {"x-amz-meta-Foo", "value"}, + {"x-amz-meta-foo", "value"}, + }, + want: map[string]string{"foo": "value,value"}, + }, + { + name: "mixed keys", + hdrs: [][2]string{ + {"x-amz-meta-Foo", "value2"}, + {"x-amz-meta-fOo", "value1"}, + {"x-amz-meta-foO", "value3"}, + {"x-amz-meta-bar", "baz"}, + {"x-amz-meta-quxx", "efg"}, + {"x-amz-meta-abc", "value"}, + {"x-amz-meta-Abc", "value"}, + {"x-amz-meta-aBc", "value"}, + {"x-amz-meta-abC", "value"}, + {"x-amz-meta-ABC", "value"}, + }, + want: map[string]string{ + "foo": "value2,value1,value3", + "bar": "baz", + "quxx": "efg", + "abc": "value,value,value,value,value", }, - wantMetadata: map[string]string{}, }, } + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if gotMetadata := GetUserMetaData(tt.args.headers); !reflect.DeepEqual(gotMetadata, tt.wantMetadata) { - t.Errorf("GetUserMetaData() = %v, want %v", gotMetadata, tt.wantMetadata) - } + h := createHeadersFromRawRequest(t, tt.hdrs) + + got, err := GetUserMetaData(h) + assert.Equal(t, tt.wantErr, err) + assert.Equal(t, tt.want, got) }) } } diff --git a/s3err/s3err.go b/s3err/s3err.go index 9e6e7e3f..f3e9c01b 100644 --- a/s3err/s3err.go +++ b/s3err/s3err.go @@ -185,6 +185,7 @@ const ( ErrMalformedTrailer ErrInvalidChunkSize ErrSlowDown + ErrMetadataTooLarge // Non-AWS errors ErrExistingObjectIsDirectory @@ -836,6 +837,12 @@ var errorCodeResponse = map[ErrorCode]APIError{ Description: "Please reduce your request rate.", HTTPStatusCode: http.StatusServiceUnavailable, }, + ErrMetadataTooLarge: { + // TODO: should have 'Size' and 'MaxSizeAllowed' properties + Code: "MetadataTooLarge", + Description: "Your metadata headers exceed the maximum allowed metadata size", + HTTPStatusCode: http.StatusBadRequest, + }, // non aws errors ErrExistingObjectIsDirectory: { diff --git a/tests/integration/CopyObject.go b/tests/integration/CopyObject.go index b66d22e8..187b7065 100644 --- a/tests/integration/CopyObject.go +++ b/tests/integration/CopyObject.go @@ -424,6 +424,33 @@ func CopyObject_to_itself_with_new_metadata(s *S3Conf) error { }) } +func CopyObject_long_metadata(s *S3Conf) error { + testName := "CopyObject_long_metadata" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + srcObj, dstObj := "source-object", "destination-object" + _, err := putObjectWithData(9, &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)), + Metadata: map[string]string{ + strings.Repeat("s", 2048): "value", + }, + }) + cancel() + + return checkApiErr(err, s3err.GetAPIError(s3err.ErrMetadataTooLarge)) + }) +} + func CopyObject_copy_source_starting_with_slash(s *S3Conf) error { testName := "CopyObject_CopySource_starting_with_slash" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { diff --git a/tests/integration/CreateMultipartUpload.go b/tests/integration/CreateMultipartUpload.go index df7bd1c7..b7b7c529 100644 --- a/tests/integration/CreateMultipartUpload.go +++ b/tests/integration/CreateMultipartUpload.go @@ -38,6 +38,24 @@ func CreateMultipartUpload_non_existing_bucket(s *S3Conf) error { }) } +func CreateMultipartUpload_long_metadata(s *S3Conf) error { + testName := "CreateMultipartUpload_long_metadata" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + _, err := s3client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{ + Bucket: &bucket, + Key: getPtr("obj"), + Metadata: map[string]string{ + "key": "value", + "key2": strings.Repeat("b", 2040), + }, + }) + cancel() + + return checkApiErr(err, s3err.GetAPIError(s3err.ErrMetadataTooLarge)) + }) +} + func CreateMultipartUpload_with_metadata(s *S3Conf) error { testName := "CreateMultipartUpload_with_metadata" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { diff --git a/tests/integration/PutObject.go b/tests/integration/PutObject.go index 4a102e30..e40c58ff 100644 --- a/tests/integration/PutObject.go +++ b/tests/integration/PutObject.go @@ -23,6 +23,7 @@ import ( "time" "github.com/aws/aws-sdk-go-v2/aws" + v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/versity/versitygw/s3err" @@ -466,6 +467,94 @@ func PutObject_conditional_writes(s *S3Conf) error { }) } +func PutObject_should_combine_metadata(s *S3Conf) error { + testName := "PutObject_should_combine_metadata" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + obj := "my-object" + ctx, cancel := context.WithTimeout(context.Background(), shortTimeout) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, fmt.Sprintf("%s/%s/%s", s.endpoint, bucket, obj), strings.NewReader("dummy data")) + if err != nil { + return err + } + + req.Header.Set("x-amz-content-sha256", "UNSIGNED-PAYLOAD") + req.Header.Add("x-amz-meta-key", "value-1") + req.Header.Add("x-amz-meta-key", "value-2") + req.Header.Add("x-amz-meta-key", "value-3") + req.Header.Add("x-amz-meta-Key", "value-4") + req.Header.Add("x-amz-meta-keY", "value-5") + req.Header.Add("x-amz-meta-foo", "bar") + req.Header.Add("x-amz-meta-baz", "abc") + req.Header.Add("x-amz-meta-xyzz", "xxx-3") + req.Header.Add("x-amz-meta-Xyzz", "xxx-1") + req.Header.Add("x-amz-meta-xyzz", "xxx-2") + req.Header.Add("x-amz-meta-hello", "world") + req.Header.Add("x-amz-meta-boo", "bar") + req.Header.Add("x-amz-meta-asdf", "ghk") + + signer := v4.NewSigner() + + err = signer.SignHTTP(req.Context(), aws.Credentials{AccessKeyID: s.awsID, SecretAccessKey: s.awsSecret}, req, "UNSIGNED-PAYLOAD", "s3", s.awsRegion, time.Now()) + if err != nil { + return fmt.Errorf("failed to sign the request: %w", err) + } + + resp, err := s.httpClient.Do(req) + cancel() + if err != nil { + return fmt.Errorf("send request: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("expected the response status code to be %v, instead got %v", http.StatusOK, resp.StatusCode) + } + + ctx, cancel = context.WithTimeout(context.Background(), shortTimeout) + out, err := s3client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: &bucket, + Key: &obj, + }) + cancel() + if err != nil { + return err + } + + expectedMeta := map[string]string{ + "key": "value-1,value-2,value-3,value-4,value-5", + "foo": "bar", + "baz": "abc", + "xyzz": "xxx-3,xxx-1,xxx-2", + "hello": "world", + "boo": "bar", + "asdf": "ghk", + } + + if !areMapsSame(expectedMeta, out.Metadata) { + return fmt.Errorf("expected the object metadata to be %v, instead got %v", expectedMeta, out.Metadata) + } + + return nil + }) +} + +func PutObject_long_metadata(s *S3Conf) error { + testName := "PutObject_long_metadata" + return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { + _, err := putObjectWithData(10, &s3.PutObjectInput{ + Bucket: &bucket, + Key: getPtr("obj"), + Metadata: map[string]string{ + "key": "value", + "foo": "bar", + "baz": "quxx", + "something_long": strings.Repeat("a", 2048), + }, + }, s3client) + + return checkApiErr(err, s3err.GetAPIError(s3err.ErrMetadataTooLarge)) + }) +} + func PutObject_with_metadata(s *S3Conf) error { testName := "PutObject_with_metadata" 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 a53c962b..c6035624 100644 --- a/tests/integration/group-tests.go +++ b/tests/integration/group-tests.go @@ -171,6 +171,7 @@ func TestPutObject(ts *TestState) { ts.Run(PutObject_past_retain_until_date) ts.Run(PutObject_invalid_retain_until_date) ts.Run(PutObject_conditional_writes) + ts.Run(PutObject_should_combine_metadata) //TODO: remove the condition after implementing checksums in azure if !ts.conf.azureTests { ts.Run(PutObject_checksum_algorithm_and_header_mismatch) @@ -194,6 +195,7 @@ func TestPutObject(ts *TestState) { ts.Run(PutObject_invalid_credentials) ts.Run(PutObject_invalid_object_names) ts.Run(PutObject_object_acl_not_supported) + ts.Run(PutObject_long_metadata) } func TestHeadObject(ts *TestState) { @@ -331,6 +333,7 @@ func TestCopyObject(ts *TestState) { ts.Run(CopyObject_should_copy_tagging) ts.Run(CopyObject_invalid_tagging_directive) ts.Run(CopyObject_to_itself_with_new_metadata) + ts.Run(CopyObject_long_metadata) ts.Run(CopyObject_copy_source_starting_with_slash) ts.Run(CopyObject_invalid_copy_source) ts.Run(CopyObject_non_existing_dir_object) @@ -383,6 +386,7 @@ func TestDeleteObjectTagging(ts *TestState) { func TestCreateMultipartUpload(ts *TestState) { ts.Run(CreateMultipartUpload_non_existing_bucket) + ts.Run(CreateMultipartUpload_long_metadata) ts.Run(CreateMultipartUpload_with_metadata) ts.Run(CreateMultipartUpload_with_tagging) ts.Run(CreateMultipartUpload_with_object_lock) @@ -847,6 +851,7 @@ func TestFullFlow(ts *TestState) { TestVersioning(ts) } TestIAM(ts) + TestServer(ts) } func TestPosix(ts *TestState) { @@ -1135,6 +1140,11 @@ func TestRouter(ts *TestState) { ts.Run(RouterListVersionsWithKey) } +func TestServer(ts *TestState) { + // TestServer tests s3 api server specific behaviors + ts.Run(Server_large_http_header) +} + func TestUnsignedStreaminPayloadTrailer(ts *TestState) { // azure doesn't support checksums if !ts.conf.azureTests { @@ -1241,6 +1251,8 @@ func GetIntTests() IntTests { "PutObject_past_retain_until_date": PutObject_past_retain_until_date, "PutObject_invalid_retain_until_date": PutObject_invalid_retain_until_date, "PutObject_conditional_writes": PutObject_conditional_writes, + "PutObject_should_combine_metadata": PutObject_should_combine_metadata, + "PutObject_long_metadata": PutObject_long_metadata, "PutObject_with_metadata": PutObject_with_metadata, "PutObject_invalid_credentials": PutObject_invalid_credentials, "PutObject_checksum_algorithm_and_header_mismatch": PutObject_checksum_algorithm_and_header_mismatch, @@ -1410,6 +1422,7 @@ func GetIntTests() IntTests { "CopyObject_should_replace_tagging": CopyObject_should_replace_tagging, "CopyObject_should_copy_tagging": CopyObject_should_copy_tagging, "CopyObject_invalid_tagging_directive": CopyObject_invalid_tagging_directive, + "CopyObject_long_metadata": CopyObject_long_metadata, "CopyObject_to_itself_with_new_metadata": CopyObject_to_itself_with_new_metadata, "CopyObject_copy_source_starting_with_slash": CopyObject_copy_source_starting_with_slash, "CopyObject_invalid_copy_source": CopyObject_invalid_copy_source, @@ -1447,6 +1460,7 @@ func GetIntTests() IntTests { "DeleteObjectTagging_success": DeleteObjectTagging_success, "DeleteObjectTagging_expected_bucket_owner": DeleteObjectTagging_expected_bucket_owner, "CreateMultipartUpload_non_existing_bucket": CreateMultipartUpload_non_existing_bucket, + "CreateMultipartUpload_long_metadata": CreateMultipartUpload_long_metadata, "CreateMultipartUpload_with_metadata": CreateMultipartUpload_with_metadata, "CreateMultipartUpload_with_tagging": CreateMultipartUpload_with_tagging, "CreateMultipartUpload_with_object_lock": CreateMultipartUpload_with_object_lock, @@ -1887,5 +1901,6 @@ func GetIntTests() IntTests { "SignedStreamingPayloadTrailer_success": SignedStreamingPayloadTrailer_success, "NoAclMode_CreateBucket_with_acl": NoAclMode_CreateBucket_with_acl, "NoAclMode_PutBucketAcl": NoAclMode_PutBucketAcl, + "Server_large_http_header": Server_large_http_header, } } diff --git a/tests/integration/server.go b/tests/integration/server.go new file mode 100644 index 00000000..887387dd --- /dev/null +++ b/tests/integration/server.go @@ -0,0 +1,58 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +func Server_large_http_header(s *S3Conf) error { + testName := "Server_large_http_header" + return actionHandlerNoSetup(s, testName, func(s3client *s3.Client, bucket string) error { + req, err := createSignedReq(http.MethodPut, s.endpoint, "/bucket/object", s.awsID, s.awsSecret, "s3", s.awsRegion, nil, time.Now(), map[string]string{ + "x-amz-custom-header": strings.Repeat("d", 1024*8), + }) + if err != nil { + return err + } + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusBadRequest { + return fmt.Errorf("expected the response status to be %v, instead got %v", http.StatusBadRequest, resp.StatusCode) + } + + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + if len(body) != 0 { + return fmt.Errorf("expected empty response body, instead got %s", body) + } + + return nil + }) +}