fix: adds versionId validation for object level actions

Fixes #1630

S3 returns `InvalidArgument: Invalid version id specified` for invalid version IDs in object-level actions that accept `versionId` as a query parameter. The `versionId` in S3 follows a specific structure, and if the input string doesn’t match this structure, the error is returned. In the gateway, the `versionId` is generated using the `ulid` package, which also has a defined structure. This PR adds validation for object-level operations that work with object versions by using the ULID parser.

These actions include: `HeadObject`, `GetObject`, `PutObjectTagging`, `GetObjectTagging`, `DeleteObjectTagging`, `PutObjectLegalHold`, `GetObjectLegalHold`, `PutObjectRetention`, `GetObjectRetention`, `DeleteObject`, `CopyObject`, `UploadPartCopy`, and `GetObjectAttributes`.
This commit is contained in:
niksis02
2025-11-11 22:23:50 +04:00
parent fc03472d60
commit eae11b44c5
13 changed files with 731 additions and 35 deletions
+18
View File
@@ -56,6 +56,15 @@ func (c S3ApiController) DeleteObjectTagging(ctx *fiber.Ctx) (*Response, error)
}, err
}
err = utils.ValidateVersionId(versionId)
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
err = c.be.DeleteObjectTagging(ctx.Context(), bucket, key, versionId)
return &Response{
MetaOpts: &MetaOptions{
@@ -147,6 +156,15 @@ func (c S3ApiController) DeleteObject(ctx *fiber.Ctx) (*Response, error) {
}, err
}
err = utils.ValidateVersionId(versionId)
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
err = auth.CheckObjectAccess(
ctx.Context(),
bucket,
+38 -2
View File
@@ -45,6 +45,23 @@ func TestS3ApiController_DeleteObjectTagging(t *testing.T) {
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "invalid versionId",
input: testInput{
locals: defaultLocals,
queries: map[string]string{
"versionId": "invalid_versionId",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrInvalidVersionId),
},
},
{
name: "backend returns error",
input: testInput{
@@ -99,7 +116,8 @@ func TestS3ApiController_DeleteObjectTagging(t *testing.T) {
tt.output.response,
tt.output.err,
ctxInputs{
locals: tt.input.locals,
locals: tt.input.locals,
queries: tt.input.queries,
})
})
}
@@ -206,6 +224,23 @@ func TestS3ApiController_DeleteObject(t *testing.T) {
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "invalid versionId",
input: testInput{
locals: defaultLocals,
queries: map[string]string{
"versionId": "invalid_versionId",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrInvalidVersionId),
},
},
{
name: "object locked",
input: testInput{
@@ -289,7 +324,8 @@ func TestS3ApiController_DeleteObject(t *testing.T) {
tt.output.response,
tt.output.err,
ctxInputs{
locals: tt.input.locals,
locals: tt.input.locals,
queries: tt.input.queries,
})
})
}
+45
View File
@@ -60,6 +60,15 @@ func (c S3ApiController) GetObjectTagging(ctx *fiber.Ctx) (*Response, error) {
}, err
}
err = utils.ValidateVersionId(versionId)
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
data, err := c.be.GetObjectTagging(ctx.Context(), bucket, key, versionId)
if err != nil {
return &Response{
@@ -114,6 +123,15 @@ func (c S3ApiController) GetObjectRetention(ctx *fiber.Ctx) (*Response, error) {
}, err
}
err = utils.ValidateVersionId(versionId)
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
data, err := c.be.GetObjectRetention(ctx.Context(), bucket, key, versionId)
if err != nil {
return &Response{
@@ -161,6 +179,15 @@ func (c S3ApiController) GetObjectLegalHold(ctx *fiber.Ctx) (*Response, error) {
}, err
}
err = utils.ValidateVersionId(versionId)
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
data, err := c.be.GetObjectLegalHold(ctx.Context(), bucket, key, versionId)
return &Response{
Data: auth.ParseObjectLegalHoldOutput(data),
@@ -313,6 +340,15 @@ func (c S3ApiController) GetObjectAttributes(ctx *fiber.Ctx) (*Response, error)
}, err
}
err = utils.ValidateVersionId(versionId)
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
// parse max parts
maxParts, err := utils.ParseUint(maxPartsStr)
if err != nil {
@@ -456,6 +492,15 @@ func (c S3ApiController) GetObject(ctx *fiber.Ctx) (*Response, error) {
partNumber = &partNumberQuery
}
err = utils.ValidateVersionId(versionId)
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
// validate the checksum mode
if checksumMode != "" && checksumMode != types.ChecksumModeEnabled {
debuglogger.Logf("invalid x-amz-checksum-mode header value: %v", checksumMode)
+96 -7
View File
@@ -52,6 +52,23 @@ func TestS3ApiController_GetObjectTagging(t *testing.T) {
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "invalid versionId",
input: testInput{
locals: defaultLocals,
queries: map[string]string{
"versionId": "invalid_versionId",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrInvalidVersionId),
},
},
{
name: "backend returns error",
input: testInput{
@@ -113,8 +130,9 @@ func TestS3ApiController_GetObjectTagging(t *testing.T) {
tt.output.response,
tt.output.err,
ctxInputs{
locals: tt.input.locals,
body: tt.input.body,
locals: tt.input.locals,
body: tt.input.body,
queries: tt.input.queries,
})
})
}
@@ -147,6 +165,23 @@ func TestS3ApiController_GetObjectRetention(t *testing.T) {
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "invalid versionId",
input: testInput{
locals: defaultLocals,
queries: map[string]string{
"versionId": "invalid_versionId",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrInvalidVersionId),
},
},
{
name: "backend returns error",
input: testInput{
@@ -218,8 +253,9 @@ func TestS3ApiController_GetObjectRetention(t *testing.T) {
tt.output.response,
tt.output.err,
ctxInputs{
locals: tt.input.locals,
body: tt.input.body,
locals: tt.input.locals,
body: tt.input.body,
queries: tt.input.queries,
})
})
}
@@ -249,6 +285,23 @@ func TestS3ApiController_GetObjectLegalHold(t *testing.T) {
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "invalid versionId",
input: testInput{
locals: defaultLocals,
queries: map[string]string{
"versionId": "invalid_versionId",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrInvalidVersionId),
},
},
{
name: "backend returns error",
input: testInput{
@@ -305,8 +358,9 @@ func TestS3ApiController_GetObjectLegalHold(t *testing.T) {
tt.output.response,
tt.output.err,
ctxInputs{
locals: tt.input.locals,
body: tt.input.body,
locals: tt.input.locals,
body: tt.input.body,
queries: tt.input.queries,
})
})
}
@@ -555,6 +609,23 @@ func TestS3ApiController_GetObjectAttributes(t *testing.T) {
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "invalid versionId",
input: testInput{
locals: defaultLocals,
queries: map[string]string{
"versionId": "invalid_versionId",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrInvalidVersionId),
},
},
{
name: "invalid max parts",
input: testInput{
@@ -663,6 +734,7 @@ func TestS3ApiController_GetObjectAttributes(t *testing.T) {
locals: tt.input.locals,
body: tt.input.body,
headers: tt.input.headers,
queries: tt.input.queries,
})
})
}
@@ -693,6 +765,23 @@ func TestS3ApiController_GetObject(t *testing.T) {
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "invalid versionId",
input: testInput{
locals: defaultLocals,
queries: map[string]string{
"versionId": "invalid_versionId",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrInvalidVersionId),
},
},
{
name: "invalid checksum mode",
input: testInput{
@@ -757,7 +846,7 @@ func TestS3ApiController_GetObject(t *testing.T) {
"Range": "100-200",
},
queries: map[string]string{
"versionId": "versionId",
"versionId": "01BX5ZZKBKACTAV9WEVGEMMVRZ",
},
locals: defaultLocals,
beRes: &s3.GetObjectOutput{
+9
View File
@@ -80,6 +80,15 @@ func (c S3ApiController) HeadObject(ctx *fiber.Ctx) (*Response, error) {
partNumber = &partNumberQuery
}
err = utils.ValidateVersionId(versionId)
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
checksumMode := types.ChecksumMode(strings.ToUpper(ctx.Get("x-amz-checksum-mode")))
if checksumMode != "" && checksumMode != types.ChecksumModeEnabled {
debuglogger.Logf("invalid x-amz-checksum-mode header value: %v", checksumMode)
+18 -1
View File
@@ -51,13 +51,30 @@ func TestS3ApiController_HeadObject(t *testing.T) {
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "invalid versionId",
input: testInput{
locals: defaultLocals,
queries: map[string]string{
"versionId": "invalid_versionId",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrInvalidVersionId),
},
},
{
name: "invalid part number",
input: testInput{
locals: defaultLocals,
queries: map[string]string{
"partNumber": "-4",
"versionId": "id",
"versionId": "01BX5ZZKBKACTAV9WEVGEMMVRZ",
},
},
output: testOutput{
+34 -5
View File
@@ -61,6 +61,15 @@ func (c S3ApiController) PutObjectTagging(ctx *fiber.Ctx) (*Response, error) {
}, err
}
err = utils.ValidateVersionId(versionId)
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
tagging, err := utils.ParseTagging(ctx.Body(), utils.TagLimitObject)
if err != nil {
return &Response{
@@ -89,7 +98,7 @@ func (c S3ApiController) PutObjectRetention(ctx *fiber.Ctx) (*Response, error) {
IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
if err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
Readonly: c.readonly,
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
@@ -99,7 +108,17 @@ func (c S3ApiController) PutObjectRetention(ctx *fiber.Ctx) (*Response, error) {
Object: key,
Action: auth.PutObjectRetentionAction,
IsPublicRequest: IsBucketPublic,
}); err != nil {
})
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
err = utils.ValidateVersionId(versionId)
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
@@ -154,7 +173,7 @@ func (c S3ApiController) PutObjectLegalHold(ctx *fiber.Ctx) (*Response, error) {
IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
if err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
err := auth.VerifyAccess(ctx.Context(), c.be, auth.AccessOptions{
Readonly: c.readonly,
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
@@ -164,7 +183,17 @@ func (c S3ApiController) PutObjectLegalHold(ctx *fiber.Ctx) (*Response, error) {
Object: key,
Action: auth.PutObjectLegalHoldAction,
IsPublicRequest: IsBucketPublic,
}); err != nil {
})
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
err = utils.ValidateVersionId(versionId)
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
@@ -191,7 +220,7 @@ func (c S3ApiController) PutObjectLegalHold(ctx *fiber.Ctx) (*Response, error) {
}, s3err.GetAPIError(s3err.ErrMalformedXML)
}
err := c.be.PutObjectLegalHold(ctx.Context(), bucket, key, versionId, legalHold.Status == types.ObjectLockLegalHoldStatusOn)
err = c.be.PutObjectLegalHold(ctx.Context(), bucket, key, versionId, legalHold.Status == types.ObjectLockLegalHoldStatusOn)
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
+96 -5
View File
@@ -64,6 +64,23 @@ func TestS3ApiController_PutObjectTagging(t *testing.T) {
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "invalid versionId",
input: testInput{
locals: defaultLocals,
queries: map[string]string{
"versionId": "invalid_versionId",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrInvalidVersionId),
},
},
{
name: "invalid request body",
input: testInput{
@@ -133,8 +150,9 @@ func TestS3ApiController_PutObjectTagging(t *testing.T) {
tt.output.response,
tt.output.err,
ctxInputs{
locals: tt.input.locals,
body: tt.input.body,
locals: tt.input.locals,
body: tt.input.body,
queries: tt.input.queries,
})
})
}
@@ -171,6 +189,23 @@ func TestS3ApiController_PutObjectRetention(t *testing.T) {
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "invalid versionId",
input: testInput{
locals: defaultLocals,
queries: map[string]string{
"versionId": "invalid_versionId",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrInvalidVersionId),
},
},
{
name: "invalid request body",
input: testInput{
@@ -262,6 +297,7 @@ func TestS3ApiController_PutObjectRetention(t *testing.T) {
locals: tt.input.locals,
body: tt.input.body,
headers: tt.input.headers,
queries: tt.input.queries,
})
})
}
@@ -298,6 +334,23 @@ func TestS3ApiController_PutObjectLegalHold(t *testing.T) {
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "invalid request body",
input: testInput{
locals: defaultLocals,
queries: map[string]string{
"versionId": "invalid_versionId",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrInvalidVersionId),
},
},
{
name: "invalid request body",
input: testInput{
@@ -380,8 +433,9 @@ func TestS3ApiController_PutObjectLegalHold(t *testing.T) {
tt.output.response,
tt.output.err,
ctxInputs{
locals: tt.input.locals,
body: tt.input.body,
locals: tt.input.locals,
body: tt.input.body,
queries: tt.input.queries,
})
})
}
@@ -579,6 +633,26 @@ func TestS3ApiController_UploadPartCopy(t *testing.T) {
err: s3err.GetAPIError(s3err.ErrAccessDenied),
},
},
{
name: "invalid copy source: invalid versionId",
input: testInput{
locals: defaultLocals,
headers: map[string]string{
"X-Amz-Copy-Source": "bucket/object?versionId=invalid_versionId",
},
queries: map[string]string{
"partNumber": "2",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrInvalidVersionId),
},
},
{
name: "invalid copy source",
input: testInput{
@@ -840,7 +914,24 @@ func TestS3ApiController_CopyObject(t *testing.T) {
},
},
{
name: "invalid copy source",
name: "invalid copy source: versionId",
input: testInput{
locals: defaultLocals,
headers: map[string]string{
"X-Amz-Copy-Source": "bucket/object?versionId=invalid_versionId",
},
},
output: testOutput{
response: &Response{
MetaOpts: &MetaOptions{
BucketOwner: "root",
},
},
err: s3err.GetAPIError(s3err.ErrInvalidVersionId),
},
},
{
name: "non empty request body",
input: testInput{
locals: defaultLocals,
headers: map[string]string{
+22 -1
View File
@@ -31,6 +31,7 @@ import (
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/gofiber/fiber/v2"
"github.com/oklog/ulid/v2"
"github.com/valyala/fasthttp"
"github.com/versity/versitygw/debuglogger"
"github.com/versity/versitygw/s3err"
@@ -818,7 +819,7 @@ func ValidateCopySource(copysource string) error {
// cut till the versionId as it's the only query param
// that is recognized in copy source
object, _, _ := strings.Cut(rest, "?versionId=")
object, versionId, _ := strings.Cut(rest, "?versionId=")
// objects containing '../', '...../' ... are considered valid in AWS
// but for the security purposes these should be considered as invalid
@@ -828,6 +829,12 @@ func ValidateCopySource(copysource string) error {
return s3err.GetAPIError(s3err.ErrInvalidCopySourceObject)
}
// validate the versionId
err = ValidateVersionId(versionId)
if err != nil {
return err
}
return nil
}
@@ -847,3 +854,17 @@ func ApplyOverride(original, override *string) *string {
}
return original
}
// ValidateVersionId check if the input versionId is 'ulid' compatible
func ValidateVersionId(versionId string) error {
if versionId == "" || versionId == "null" {
return nil
}
_, err := ulid.Parse(versionId)
if err != nil {
debuglogger.Logf("invalid versionId: %s", versionId)
return s3err.GetAPIError(s3err.ErrInvalidVersionId)
}
return nil
}
+4 -1
View File
@@ -955,12 +955,15 @@ func TestValidateCopySource(t *testing.T) {
{"invalid object name 3", "bucket", s3err.GetAPIError(s3err.ErrInvalidCopySourceObject)},
{"invalid object name 4", "bucket/../foo/dir/../../../", s3err.GetAPIError(s3err.ErrInvalidCopySourceObject)},
{"invalid object name 5", "bucket/.?versionId=smth", s3err.GetAPIError(s3err.ErrInvalidCopySourceObject)},
// invalid versionId
{"invalid versionId 1", "bucket/object?versionId=invalid", s3err.GetAPIError(s3err.ErrInvalidVersionId)},
{"invalid versionId 2", "bucket/object?versionId=01BX5ZZKBKACTAV9WEVGEMMV", s3err.GetAPIError(s3err.ErrInvalidVersionId)},
// success
{"no error 1", "bucket/object", nil},
{"no error 2", "bucket/object/key", nil},
{"no error 3", "bucket/4*&(*&(89765))", nil},
{"no error 4", "bucket/foo/../bar", nil},
{"no error 5", "bucket/foo/bar/baz?versionId=id", nil},
{"no error 5", "bucket/foo/bar/baz?versionId=01BX5ZZKBKACTAV9WEVGEMMVRZ", nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {