From 21a636b3b568ce144db122093e7838255184b954 Mon Sep 17 00:00:00 2001 From: niksis02 Date: Thu, 5 Mar 2026 01:42:02 +0400 Subject: [PATCH] fix: add request headers and metadata headers limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #1606 According to AWS documentation: > *“The PUT request header is limited to 8 KB in size. Within the PUT request header, the user-defined metadata is limited to 2 KB in size. The size of user-defined metadata is measured by taking the sum of the number of bytes in the UTF-8 encoding of each key and value.”* Based on this, object metadata size is now limited to **2 KB** for all object upload operations (`PutObject`, `CopyObject`, and `CreateMultipartUpload`). Fixes handling of metadata HTTP headers when the same header appears multiple times with different casing or even if they are identical. According to S3 behavior, these headers must be merged into a single lower-cased metadata key, with values concatenated using commas. Example: ``` x-amz-meta-Key: value1 x-amz-meta-kEy: value2 x-amz-meta-keY: value3 ``` Translated to: ``` key: value1,value2,value3 ``` This PR also introduces an **8 KB limit for request headers**. Although the S3 documentation explicitly mentions the 8 KB limit only for **PUT requests**, in practice this limit applies to **all requests**. To enforce the header size limit, the Fiber configuration option `ReadBufferSize` is used. This parameter defines the maximum number of bytes read when parsing an incoming request. Note that this limit does not apply strictly to request headers only, since request parsing also includes other parts of the request line (e.g., the HTTP method, protocol string, and version such as `HTTP/1.1`). So `ReadBufferSize` is effectively a limit for request headers size, but not the exact limit. --- s3api/controllers/object-post.go | 10 +- s3api/controllers/object-post_test.go | 18 +++ s3api/controllers/object-put.go | 22 +++- s3api/controllers/object-put_test.go | 36 ++++++ s3api/server.go | 11 ++ s3api/utils/utils.go | 42 ++++++- s3api/utils/utils_test.go | 137 ++++++++++++++++++--- s3err/s3err.go | 7 ++ tests/integration/CopyObject.go | 27 ++++ tests/integration/CreateMultipartUpload.go | 18 +++ tests/integration/PutObject.go | 89 +++++++++++++ tests/integration/group-tests.go | 15 +++ tests/integration/server.go | 58 +++++++++ 13 files changed, 462 insertions(+), 28 deletions(-) create mode 100644 tests/integration/server.go 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 8a4320d0..8c178762 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 63be4931..f5880821 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 65e61dde..16b14e42 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 + }) +}