From aed46173723de9473c3bb880db05e9413f8aeb22 Mon Sep 17 00:00:00 2001 From: dualfroz Date: Mon, 12 Oct 2026 14:30:16 +0200 Subject: [PATCH] fix: reject an empty start-after and continuation-token in ListObjects (V1) --- s3api/controllers/bucket-get.go | 13 ++++++-- s3api/controllers/bucket-get_test.go | 36 ++++++++++++++++++++++ s3err/invalid-argument.go | 5 ++++ tests/integration/ListObjects.go | 45 +++++++++++++++++++--------- 4 files changed, 83 insertions(+), 16 deletions(-) diff --git a/s3api/controllers/bucket-get.go b/s3api/controllers/bucket-get.go index d1393f53..9269cf54 100644 --- a/s3api/controllers/bucket-get.go +++ b/s3api/controllers/bucket-get.go @@ -615,8 +615,17 @@ func (c S3ApiController) ListObjects(ctx fiber.Ctx) (*Response, error) { region = defaultRegion } - // start-after is a ListObjectsV2 argument, S3 rejects it on a V1 listing - if ctx.Query("start-after") != "" { + // continuation-token and start-after are ListObjectsV2 arguments, S3 + // rejects either one on a V1 listing, with or without a value + queryArgs := ctx.Request().URI().QueryArgs() + if queryArgs.Has("continuation-token") { + return &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: parsedAcl.Owner, + }, + }, s3err.GetInvalidArgumentErr(s3err.InvalidArgContinuationToken, "") + } + if queryArgs.Has("start-after") { return &Response{ MetaOpts: &MetaOptions{ BucketOwner: parsedAcl.Owner, diff --git a/s3api/controllers/bucket-get_test.go b/s3api/controllers/bucket-get_test.go index 87701507..49142047 100644 --- a/s3api/controllers/bucket-get_test.go +++ b/s3api/controllers/bucket-get_test.go @@ -1213,6 +1213,42 @@ func TestS3ApiController_ListObjects(t *testing.T) { err: s3err.GetInvalidArgumentErr(s3err.InvalidArgStartAfter, ""), }, }, + { + name: "empty start-after is not supported", + input: testInput{ + locals: defaultLocals, + beRes: listResult, + queries: map[string]string{ + "start-after": "", + }, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: s3err.GetInvalidArgumentErr(s3err.InvalidArgStartAfter, ""), + }, + }, + { + name: "continuation-token is not supported", + input: testInput{ + locals: defaultLocals, + beRes: listResult, + queries: map[string]string{ + "continuation-token": "my-token", + }, + }, + output: testOutput{ + response: &Response{ + MetaOpts: &MetaOptions{ + BucketOwner: "root", + }, + }, + err: s3err.GetInvalidArgumentErr(s3err.InvalidArgContinuationToken, ""), + }, + }, { name: "invalid max keys", input: testInput{ diff --git a/s3err/invalid-argument.go b/s3err/invalid-argument.go index cd22a057..1abbb5b5 100644 --- a/s3err/invalid-argument.go +++ b/s3err/invalid-argument.go @@ -27,6 +27,7 @@ const ( InvalidArgMaxBuckets InvalidArgErrorCode = iota InvalidArgNegativeMaxKeys InvalidArgStartAfter + InvalidArgContinuationToken InvalidArgObjectAttributes InvalidArgPartNumber InvalidArgCompleteMpPartNumber @@ -95,6 +96,10 @@ var invalidArgErrResponses = map[InvalidArgErrorCode]InvalidArgumentError{ Description: "startAfter only supported in REST.GET.BUCKET with list-type=2", ArgumentName: "start-after", }, + InvalidArgContinuationToken: { + Description: "continuation-token only supported in REST.GET.BUCKET with list-type=2", + ArgumentName: "continuation-token", + }, InvalidArgObjectAttributes: { Description: "Invalid attribute name specified.", ArgumentName: "x-amz-object-attributes", diff --git a/tests/integration/ListObjects.go b/tests/integration/ListObjects.go index 59fdfe41..479cb5bd 100644 --- a/tests/integration/ListObjects.go +++ b/tests/integration/ListObjects.go @@ -195,9 +195,9 @@ func ListObjects_invalid_max_keys(s *S3Conf) error { }) } -// start-after belongs to ListObjectsV2, so S3 rejects it on a V1 listing -// instead of ignoring it. The SDK ListObjects input has no StartAfter field, -// so the request is signed and sent by hand. +// start-after and continuation-token belong to ListObjectsV2, so S3 rejects +// either one on a V1 listing, with or without a value. The SDK ListObjects +// input has neither field, so the requests are signed and sent by hand. func ListObjects_start_after(s *S3Conf) error { testName := "ListObjects_start_after" return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error { @@ -206,19 +206,36 @@ func ListObjects_start_after(s *S3Conf) error { return err } - req, err := createSignedReq(http.MethodGet, s.endpoint, - fmt.Sprintf("%v?start-after=bar", bucket), - s.awsID, s.awsSecret, "s3", s.awsRegion, "", nil, time.Now(), nil) - if err != nil { - return err + startAfterErr := s3err.GetInvalidArgumentErr(s3err.InvalidArgStartAfter, "") + contTokenErr := s3err.GetInvalidArgumentErr(s3err.InvalidArgContinuationToken, "") + for _, test := range []struct { + query string + expected s3err.S3Error + }{ + {"start-after=bar", startAfterErr}, + {"start-after=", startAfterErr}, + {"start-after", startAfterErr}, + {"continuation-token=bar", contTokenErr}, + {"continuation-token=", contTokenErr}, + } { + req, err := createSignedReq(http.MethodGet, s.endpoint, + fmt.Sprintf("%v?%v", bucket, test.query), + s.awsID, s.awsSecret, "s3", s.awsRegion, "", nil, time.Now(), nil) + if err != nil { + return err + } + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + + if err := checkHTTPResponseApiErr(resp, test.expected); err != nil { + return fmt.Errorf("%v: %w", test.query, err) + } } - resp, err := s.httpClient.Do(req) - if err != nil { - return err - } - - return checkHTTPResponseApiErr(resp, s3err.GetInvalidArgumentErr(s3err.InvalidArgStartAfter, "")) + return nil }) }