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
395 lines
13 KiB
Go
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
|
|
}
|