Files
versity-versitygw/s3api/controllers/bucket-post.go
T
dualfroz 62d77006d2 fix: return MalformedXML for a DeleteObjects request without objects
A DeleteObjects request whose Delete element names no objects was
answered with 200 and an empty DeleteResult. S3 rejects it with
MalformedXML, as the schema requires at least one Object.

Reject the empty list right after the body is parsed, next to the
1000 key limit check. Update the unit and integration tests that
expected the empty result and enable the REST test that covers it.

Fixes #2109
2026-10-03 16:01:57 +02:00

395 lines
13 KiB
Go

// Copyright 2023 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 controllers
import (
"encoding/xml"
"net/http"
"net/url"
"strings"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/gofiber/fiber/v3"
"github.com/versity/versitygw/auth"
"github.com/versity/versitygw/backend"
"github.com/versity/versitygw/debuglogger"
"github.com/versity/versitygw/s3api/middlewares"
"github.com/versity/versitygw/s3api/utils"
"github.com/versity/versitygw/s3err"
"github.com/versity/versitygw/s3event"
"github.com/versity/versitygw/s3response"
)
func (c S3ApiController) DeleteObjects(ctx fiber.Ctx) (*Response, error) {
bucket := ctx.Params("bucket")
bypass := auth.BypassModeForRequest(strings.EqualFold(ctx.Get("X-Amz-Bypass-Governance-Retention"), "true"))
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
// The body has to be parsed before authorization, not after: real AWS
// authorizes s3:DeleteObject against each object's own ARN, so the keys
// are part of what is being authorized. The parsed objects go straight
// to VerifyObjectsAccess, which checks policy and object locks for all
// of them in one pass.
var dObj s3response.DeleteObjects
err := xml.Unmarshal(ctx.BodyRaw(), &dObj)
if err != nil {
debuglogger.Logf("error unmarshalling delete objects: %v", err)
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, s3err.GetAPIError(s3err.ErrInvalidRequest)
}
// S3 requires at least one object in a DeleteObjects request and
// rejects an empty delete list as malformed.
if len(dObj.Objects) == 0 {
debuglogger.Logf("delete objects: no keys in the request")
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, s3err.GetAPIError(s3err.ErrMalformedXML)
}
// S3 caps a single DeleteObjects request at 1000 keys. Reject an
// over-limit request before authorization and before anything reaches
// the backend, so a too-large batch can't be applied partially.
if len(dObj.Objects) > maxDeleteObjects {
debuglogger.Logf("delete objects: %d keys exceeds the limit of %d",
len(dObj.Objects), maxDeleteObjects)
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, s3err.GetAPIError(s3err.ErrMalformedXML)
}
// The keys come from the body, so they skip the object name check the
// request path gets. A key resolving outside the bucket ("../key") names
// a path outside it on a filesystem backend, so the whole request is
// rejected before any key is authorized or deleted.
for _, obj := range dObj.Objects {
if obj.Key == nil || !utils.IsObjectNameValid(*obj.Key) {
debuglogger.Logf("delete objects: invalid object key: %q",
backend.GetStringFromPtr(obj.Key))
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, s3err.GetAPIError(s3err.ErrBadRequest)
}
}
// checkErrs holds one entry per requested object — nil where it may
// proceed to the backend, an AWS-shaped denial otherwise. DeleteObjects
// supports partial success, so a denial on one object (policy or object
// lock) must not fail any other: only the objects that clear this check
// are sent to the backend, and the rest are reported as per-object
// errors directly from checkErrs. err here is a whole-request failure
// (readonly mode, or an error resolving policy/lock state), not about
// any one object.
checkErrs, err := c.verifyObjectsAccess(ctx,
auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
Acc: acct,
Bucket: bucket,
IsPublicRequest: IsBucketPublic,
}, dObj.Objects, bypass)
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
toDelete := make([]types.ObjectIdentifier, 0, len(dObj.Objects))
for i, obj := range dObj.Objects {
if checkErrs[i] == nil {
toDelete = append(toDelete, obj)
}
}
var backendResult s3response.DeleteResult
if len(toDelete) > 0 {
backendResult, err = c.be.DeleteObjects(ctx.RequestCtx(),
&s3.DeleteObjectsInput{
Bucket: &bucket,
Delete: &types.Delete{
Objects: toDelete,
},
})
}
result := utils.MergeDeleteObjectsResult(dObj.Objects, checkErrs, backendResult)
// In quiet mode the response lists only the objects that could not be
// deleted.
if dObj.Quiet {
result.Deleted = nil
}
return &Response{
Data: result,
MetaOpts: &MetaOptions{
ObjectCount: int64(len(dObj.Objects)),
BucketOwner: parsedAcl.Owner,
EventName: s3event.EventObjectRemovedDeleteObjects,
},
}, err
}
func (c S3ApiController) POSTObject(ctx fiber.Ctx) (*Response, error) {
acct := utils.ContextKeyAccount.Get(ctx).(auth.Account)
isRoot := utils.ContextKeyIsRoot.Get(ctx).(bool)
parsedAcl := utils.ContextKeyParsedAcl.Get(ctx).(auth.ACL)
IsBucketPublic := utils.ContextKeyPublicBucket.IsSet(ctx)
parsed := utils.ContextKeyObjectPostResult.Get(ctx).(middlewares.PostObjectResult)
bucket := ctx.Params("bucket")
contentType := parsed.Fields["content-type"]
if contentType == "" {
contentType = defaultContentType
}
contentEncoding := parsed.Fields["content-encoding"]
contentDisposition := parsed.Fields["content-disposition"]
contentLanguage := parsed.Fields["content-language"]
cacheControl := parsed.Fields["cache-control"]
expires := parsed.Fields["expires"]
websiteRedirectLocation := parsed.Fields["x-amz-website-redirect-location"]
storageClass := parsed.Fields["x-amz-storage-class"]
key := parsed.Fields["key"]
// parse POST policy — absent for anonymous uploads to public buckets
if !IsBucketPublic {
policyBase64 := parsed.Fields["policy"]
policy, err := auth.ParsePOSTPolicyBase64(policyBase64)
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
// Evaluate post policy
err = policy.Evaluate(auth.PostPolicyEvalInput{
Bucket: bucket,
Key: key,
ContentLength: parsed.ContentLength,
Fields: parsed.Fields,
})
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
}
// convert object tagging from raw XML to Query string
// to pass PutObject, which expects the tagging to be a query string
var tagging string
if taggingXML, ok := parsed.Fields["tagging"]; ok {
var err error
tagging, err = utils.ConvertTaggingXMLToQueryString([]byte(taggingXML))
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
}
// parse checksum headers
checksums, err := utils.ParseCalculatedChecksumFields(parsed.Fields)
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
// extract metadata
metadata, err := utils.ExtractMetadataFromFields(parsed.Fields)
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
err = utils.ValidateWebsiteRedirectLocation(websiteRedirectLocation)
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
// A POST upload is an s3:PutObject on the object named by the form's
// key field, so it is authorized against that object's ARN — the same
// resource PutObject is — not the bucket's. Tagging the object also
// takes s3:PutObjectTagging, but only for a non-empty tag set
actions := auth.ObjectUploadActions(tagging, "", "", "")
err = c.verifyAccess(ctx,
auth.AccessOptions{
Acl: parsedAcl,
AclPermission: auth.PermissionWrite,
IsRoot: isRoot,
Acc: acct,
Bucket: bucket,
Object: key,
Actions: actions,
IsPublicRequest: IsBucketPublic,
})
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
err = auth.CheckObjectAccess(ctx, bucket, acct, []types.ObjectIdentifier{{Key: &key}}, auth.BypassOverwrite, IsBucketPublic, c.be, c.iam, true)
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
res, err := c.be.PutObject(ctx.RequestCtx(), s3response.PutObjectInput{
Bucket: &bucket,
Key: &key,
ContentType: &contentType,
ContentEncoding: &contentEncoding,
ContentDisposition: &contentDisposition,
ContentLanguage: &contentLanguage,
CacheControl: &cacheControl,
Expires: &expires,
WebsiteRedirectLocation: &websiteRedirectLocation,
Body: parsed.FileRdr,
ContentLength: &parsed.ContentLength,
Tagging: &tagging,
Metadata: metadata,
StorageClass: types.StorageClass(storageClass),
ChecksumCRC32: utils.GetStringPtr(checksums[types.ChecksumAlgorithmCrc32]),
ChecksumCRC32C: utils.GetStringPtr(checksums[types.ChecksumAlgorithmCrc32c]),
ChecksumSHA1: utils.GetStringPtr(checksums[types.ChecksumAlgorithmSha1]),
ChecksumSHA256: utils.GetStringPtr(checksums[types.ChecksumAlgorithmSha256]),
ChecksumCRC64NVME: utils.GetStringPtr(checksums[types.ChecksumAlgorithmCrc64nvme]),
ChecksumSHA512: utils.GetStringPtr(checksums[types.ChecksumAlgorithmSha512]),
ChecksumMD5: utils.GetStringPtr(checksums[types.ChecksumAlgorithmMd5]),
ChecksumXXHASH64: utils.GetStringPtr(checksums[types.ChecksumAlgorithmXxhash64]),
ChecksumXXHASH3: utils.GetStringPtr(checksums[types.ChecksumAlgorithmXxhash3]),
ChecksumXXHASH128: utils.GetStringPtr(checksums[types.ChecksumAlgorithmXxhash128]),
})
if err != nil {
return &Response{
MetaOpts: &MetaOptions{
BucketOwner: parsedAcl.Owner,
},
}, err
}
if successActionRedirect, ok := parsed.Fields["success_action_redirect"]; ok {
u, err := url.Parse(successActionRedirect)
if err == nil {
q := u.Query()
q.Set("bucket", bucket)
q.Set("key", key)
q.Set("etag", res.ETag)
u.RawQuery = q.Encode()
redirectURI := u.String()
return &Response{
Headers: map[string]*string{
"Location": &redirectURI,
},
MetaOpts: &MetaOptions{
ContentLength: parsed.FileRdr.Length(),
BucketOwner: parsedAcl.Owner,
ObjectETag: &res.ETag,
ObjectSize: parsed.FileRdr.Length(),
EventName: s3event.EventObjectCreatedPost,
Status: http.StatusSeeOther,
},
}, nil
}
}
respStatus := http.StatusNoContent
var respBody any
location := utils.GenerateObjectLocation(ctx, c.virtualDomain, bucket, key)
if successStatus, ok := parsed.Fields["success_action_status"]; ok {
switch successStatus {
case "200":
respStatus = http.StatusOK
case "201":
respStatus = http.StatusCreated
respBody = &s3response.PostResponse{
Bucket: bucket,
Key: key,
ETag: res.ETag,
Location: location,
}
}
}
return &Response{
Headers: map[string]*string{
"Etag": &res.ETag,
"Location": &location,
"x-amz-checksum-crc32": res.ChecksumCRC32,
"x-amz-checksum-crc32c": res.ChecksumCRC32C,
"x-amz-checksum-crc64nvme": res.ChecksumCRC64NVME,
"x-amz-checksum-sha1": res.ChecksumSHA1,
"x-amz-checksum-sha256": res.ChecksumSHA256,
"x-amz-checksum-sha512": res.ChecksumSHA512,
"x-amz-checksum-md5": res.ChecksumMD5,
"x-amz-checksum-xxhash64": res.ChecksumXXHASH64,
"x-amz-checksum-xxhash3": res.ChecksumXXHASH3,
"x-amz-checksum-xxhash128": res.ChecksumXXHASH128,
"x-amz-checksum-type": utils.ConvertToStringPtr(res.ChecksumType),
"x-amz-version-id": utils.GetStringPtr(res.VersionID),
},
Data: respBody,
MetaOpts: &MetaOptions{
ContentLength: parsed.FileRdr.Length(),
BucketOwner: parsedAcl.Owner,
ObjectETag: &res.ETag,
ObjectSize: parsed.FileRdr.Length(),
EventName: s3event.EventObjectCreatedPost,
Status: respStatus,
},
}, nil
}