mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-15 16:28:30 +01:00
* s3api: refuse a PUT to a stored version's physical path In a Suspended bucket a PUT to <key>.versions/v_<id> replaced the stored version's body in place. Look the entry up when the key has the version file shape and deny the write when it is a version file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: refuse copy, multipart, POST and rename writes to a version file Apply the same version file check to the CopyObject, CreateMultipartUpload, CompleteMultipartUpload, POST Object and RenameObject destinations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: read a version file write target on its logical owner The guard resolved the key as an object, which on a ring reads the physical path's owner and, in a versioned bucket, may return a nested version under <key>.versions/v_<id>.versions instead of the stored version. Look up the physical entry directly, routed by the logical key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: confirm a version file miss on a skipped owner When the owner was recently unreachable the routed lookup answers from a replica, whose miss does not show the version is absent. Ask the owner directly in that case and refuse the write when it cannot answer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: confirm every routed version file miss on the owner Whether the routed lookup skipped the owner can change between the lookup and a second unreachable check, letting a replica's miss through. Ask the owner after any miss instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
354 lines
12 KiB
Go
354 lines
12 KiB
Go
package s3api
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"crypto/md5"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"hash"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gorilla/mux"
|
|
"github.com/seaweedfs/seaweedfs/weed/filer"
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
|
)
|
|
|
|
func isExtractZip(r *http.Request) bool {
|
|
if r.URL.Query().Get("extract") == "zip" ||
|
|
r.Header.Get(s3_constants.SeaweedFSExtract) == "zip" {
|
|
return true
|
|
}
|
|
// AWS Snowball auto-extract: header presence requests extraction.
|
|
if v := r.Header.Get(s3_constants.AmzMetaSnowballAutoExtract); v != "" && v != "false" {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func hasExplicitSseHeaders(r *http.Request) bool {
|
|
return r.Header.Get(s3_constants.AmzServerSideEncryption) != "" ||
|
|
r.Header.Get(s3_constants.AmzServerSideEncryptionAwsKmsKeyId) != "" ||
|
|
r.Header.Get(s3_constants.AmzServerSideEncryptionCustomerAlgorithm) != ""
|
|
}
|
|
|
|
type extractMemberResult struct {
|
|
Name string `json:"name"`
|
|
Size int64 `json:"size,omitempty"`
|
|
ETag string `json:"etag,omitempty"`
|
|
}
|
|
|
|
type extractZipResult struct {
|
|
Extracted []*extractMemberResult `json:"extracted"`
|
|
Errors []string `json:"errors,omitempty"`
|
|
}
|
|
|
|
// extractZipObject writes a PutObject zip body as one object per member under
|
|
// the object's directory, like AWS Snowball's x-amz-meta-snowball-auto-extract.
|
|
// The archive object itself is not stored.
|
|
func (s3a *S3ApiServer) extractZipObject(w http.ResponseWriter, r *http.Request, bucket, object string, dataReader io.Reader) {
|
|
if hasExplicitSseHeaders(r) {
|
|
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
|
|
return
|
|
}
|
|
|
|
versioningState, err := s3a.getVersioningState(bucket)
|
|
if err != nil {
|
|
if errors.Is(err, filer_pb.ErrNotFound) {
|
|
if !s3a.handleAutoCreateBucket(w, r, bucket, "PutObjectHandler") {
|
|
return
|
|
}
|
|
versioningState, err = s3a.getVersioningState(bucket)
|
|
}
|
|
if err != nil {
|
|
glog.Errorf("extractZipObject: versioning state for bucket %s: %v", bucket, err)
|
|
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
|
|
return
|
|
}
|
|
}
|
|
versioningConfigured := versioningState != ""
|
|
|
|
r, aclCode := s3a.preparePutObjectACL(r, bucket)
|
|
if aclCode != s3err.ErrNone {
|
|
s3err.WriteErrorResponse(w, r, aclCode)
|
|
return
|
|
}
|
|
|
|
objectLockEnabled, lockErr := s3a.isObjectLockEnabled(bucket)
|
|
if lockErr != nil && !errors.Is(lockErr, filer_pb.ErrNotFound) {
|
|
glog.Errorf("extractZipObject: object lock state for bucket %s: %v", bucket, lockErr)
|
|
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
|
|
return
|
|
}
|
|
// Malformed or unauthorized lock headers fail the request outright, the
|
|
// same as a standalone PutObject; per-member permission re-checks happen in
|
|
// authorizeMember.
|
|
if vErr := s3a.validateObjectLockHeaders(r, bucket, object, objectLockEnabled); vErr != nil {
|
|
s3err.WriteErrorResponse(w, r, mapValidationErrorToS3Error(vErr))
|
|
return
|
|
}
|
|
|
|
// Checksums describing the archive body are verified while it spools, then
|
|
// scrubbed so members are validated against their own bytes instead.
|
|
requestQuery := parseRequestQuery(r)
|
|
archiveMd5 := r.Header.Get("Content-Md5")
|
|
var archiveMd5Hash hash.Hash
|
|
if archiveMd5 != "" {
|
|
archiveMd5Hash = md5.New()
|
|
dataReader = io.TeeReader(dataReader, archiveMd5Hash)
|
|
}
|
|
archiveChecksumAlgo, archiveChecksumHeader, checksumCode := detectRequestedChecksumAlgorithmQ(r, requestQuery)
|
|
if checksumCode != s3err.ErrNone {
|
|
s3err.WriteErrorResponse(w, r, checksumCode)
|
|
return
|
|
}
|
|
var archiveChecksumHash hash.Hash
|
|
expectedArchiveChecksum := ""
|
|
if archiveChecksumAlgo != ChecksumAlgorithmNone {
|
|
if archiveChecksumHash = getCheckSumWriter(archiveChecksumAlgo); archiveChecksumHash != nil {
|
|
dataReader = io.TeeReader(dataReader, archiveChecksumHash)
|
|
}
|
|
expectedArchiveChecksum = lookupHeaderOrQuery(r, requestQuery, archiveChecksumHeader)
|
|
}
|
|
scrubArchiveChecksums(r)
|
|
// Content-Encoding describes the archive body on the wire; the members it
|
|
// fans out to are stored as-is.
|
|
r.Header.Del("Content-Encoding")
|
|
|
|
ra, size, cleanup, err := filer.SpoolZipReader(dataReader, 0)
|
|
if err != nil {
|
|
if errors.Is(err, filer.ErrZipTooLarge) {
|
|
s3err.WriteErrorResponse(w, r, s3err.ErrEntityTooLarge)
|
|
} else if strings.Contains(err.Error(), s3err.ErrMsgContentSha256Mismatch) {
|
|
s3err.WriteErrorResponse(w, r, s3err.ErrContentSHA256Mismatch)
|
|
} else {
|
|
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
|
|
}
|
|
return
|
|
}
|
|
defer cleanup()
|
|
if errCode := verifyArchiveChecksums(archiveMd5, archiveMd5Hash, expectedArchiveChecksum, archiveChecksumHash); errCode != s3err.ErrNone {
|
|
s3err.WriteErrorResponse(w, r, errCode)
|
|
return
|
|
}
|
|
zr, err := zip.NewReader(ra, size)
|
|
if err != nil {
|
|
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
|
|
return
|
|
}
|
|
if err = filer.CheckZipLimits(zr); err != nil {
|
|
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
|
|
return
|
|
}
|
|
|
|
root := object
|
|
if !strings.HasSuffix(root, "/") {
|
|
root = path.Dir(root)
|
|
}
|
|
root = strings.TrimSuffix(root, "/")
|
|
if root == "." {
|
|
root = ""
|
|
}
|
|
|
|
identity, _ := s3_constants.GetIdentityFromContext(r).(*Identity)
|
|
result := &extractZipResult{}
|
|
|
|
join := func(rel string) string {
|
|
if root == "" {
|
|
return rel
|
|
}
|
|
return root + "/" + rel
|
|
}
|
|
// authorizeMember applies to a member key the same checks a standalone
|
|
// PutObject of that key would face: IAM, table layout, bucket policy,
|
|
// object-lock header permissions, and existing-entry protections.
|
|
authorizeMember := func(memberKey string) s3err.ErrorCode {
|
|
if code := s3a.iam.AuthorizeObjectWrite(r, identity, bucket, memberKey); code != s3err.ErrNone {
|
|
return code
|
|
}
|
|
if err := s3a.validateTableBucketObjectPath(bucket, memberKey); err != nil {
|
|
return s3err.ErrAccessDenied
|
|
}
|
|
if code := s3a.checkVersionFileWrite(bucket, memberKey, "extractZipObject"); code != s3err.ErrNone {
|
|
return code
|
|
}
|
|
if code, _ := s3a.checkPolicyWithEntry(r, bucket, memberKey, string(s3_constants.ACTION_WRITE), "", nil); code != s3err.ErrNone {
|
|
return code
|
|
}
|
|
// Lock headers validated for the request still need the dedicated
|
|
// permissions against the member's own key.
|
|
if r.Header.Get(s3_constants.AmzObjectLockMode) != "" || r.Header.Get(s3_constants.AmzObjectLockRetainUntilDate) != "" {
|
|
if !s3a.checkObjectLockHeaderPermission(r, bucket, memberKey, s3_constants.ACTION_PUT_OBJECT_RETENTION) {
|
|
return s3err.ErrAccessDenied
|
|
}
|
|
}
|
|
if r.Header.Get(s3_constants.AmzObjectLockLegalHold) != "" {
|
|
if !s3a.checkObjectLockHeaderPermission(r, bucket, memberKey, s3_constants.ACTION_PUT_OBJECT_LEGAL_HOLD) {
|
|
return s3err.ErrAccessDenied
|
|
}
|
|
}
|
|
if !versioningConfigured {
|
|
if lockErr := s3a.enforceObjectLockProtections(r, bucket, memberKey, "", s3a.evaluateGovernanceBypassRequest(r, bucket, memberKey)); lockErr != nil {
|
|
return s3err.ErrAccessDenied
|
|
}
|
|
}
|
|
return s3err.ErrNone
|
|
}
|
|
writeMember := func(rel string, memberReader io.Reader, memberSize int64) {
|
|
memberKey := join(rel)
|
|
if authCode := authorizeMember(memberKey); authCode != s3err.ErrNone {
|
|
result.Errors = append(result.Errors, memberKey+": "+s3err.GetAPIError(authCode).Code)
|
|
return
|
|
}
|
|
memberContentType := mime.TypeByExtension(path.Ext(memberKey))
|
|
if memberContentType == "" {
|
|
memberContentType = "application/octet-stream"
|
|
}
|
|
// Write helpers read the object from route vars, e.g. for the SSE-KMS
|
|
// encryption context ARN; point them at the member, not the archive.
|
|
memberReq := mux.SetURLVars(r, map[string]string{
|
|
"bucket": bucket,
|
|
"object": memberKey,
|
|
})
|
|
memberReq.Header.Set("Content-Type", memberContentType)
|
|
memberReq.ContentLength = memberSize
|
|
var etag string
|
|
var errCode s3err.ErrorCode
|
|
switch versioningState {
|
|
case s3_constants.VersioningEnabled:
|
|
_, etag, errCode, _ = s3a.putVersionedObject(memberReq, bucket, memberKey, memberReader, memberContentType)
|
|
case s3_constants.VersioningSuspended:
|
|
etag, errCode, _ = s3a.putSuspendedVersioningObject(memberReq, bucket, memberKey, memberReader, memberContentType)
|
|
default:
|
|
filePath := s3a.toFilerPath(bucket, memberKey)
|
|
lifecycleTTLSec := s3a.lifecycleTTLForObjectWrite(bucket, memberKey, memberSize)
|
|
etag, errCode, _ = s3a.putToFiler(memberReq, filePath, memberReader, bucket, memberKey, 1, lifecycleTTLSec, nil, false, "")
|
|
}
|
|
if errCode != s3err.ErrNone {
|
|
result.Errors = append(result.Errors, memberKey+": "+s3err.GetAPIError(errCode).Code)
|
|
return
|
|
}
|
|
result.Extracted = append(result.Extracted, &extractMemberResult{Name: memberKey, Size: memberSize, ETag: etag})
|
|
}
|
|
mkdirMember := func(memberKey string) {
|
|
if authCode := authorizeMember(memberKey); authCode != s3err.ErrNone {
|
|
result.Errors = append(result.Errors, memberKey+": "+s3err.GetAPIError(authCode).Code)
|
|
return
|
|
}
|
|
dirPath := s3a.toFilerPath(bucket, memberKey)
|
|
dirName, entryName := path.Dir(dirPath), path.Base(dirPath)
|
|
// mkdir replaces an existing marker outright, so a lock recorded on the
|
|
// entry itself is checked under the same lock the replacement takes,
|
|
// mirroring the explicit-directory branch of PutObjectHandler.
|
|
markerCode := s3a.withObjectWriteLock(bucket, memberKey, func() s3err.ErrorCode {
|
|
if !objectLockEnabled {
|
|
return s3err.ErrNone
|
|
}
|
|
// mkdir replaces the entry on the default filer, so the lock check
|
|
// must read there too — a routed read can miss a marker the owner
|
|
// has not received yet.
|
|
existing, existErr := s3a.getEntry(dirName, entryName)
|
|
if existErr != nil {
|
|
return s3err.ErrNone
|
|
}
|
|
if lockErr := s3a.enforceObjectLockOnEntry(existing, bucket, memberKey, "", s3a.evaluateGovernanceBypassRequest(r, bucket, memberKey)); lockErr != nil {
|
|
return s3err.ErrAccessDenied
|
|
}
|
|
return s3err.ErrNone
|
|
}, func() s3err.ErrorCode {
|
|
mkErr := s3a.mkdir(dirName, entryName, func(entry *filer_pb.Entry) {
|
|
entry.Attributes.Mime = s3_constants.FolderMimeType
|
|
s3a.setObjectOwnerFromRequest(r, bucket, entry)
|
|
applyPutObjectACL(r, entry)
|
|
if lockErr := s3a.extractObjectLockMetadataFromRequest(r, entry); lockErr != nil {
|
|
glog.Errorf("extractZipObject: object lock metadata for %s/%s: %v", bucket, memberKey, lockErr)
|
|
}
|
|
})
|
|
return filerErrorToS3Error(mkErr)
|
|
})
|
|
if markerCode != s3err.ErrNone {
|
|
result.Errors = append(result.Errors, memberKey+": "+s3err.GetAPIError(markerCode).Code)
|
|
}
|
|
}
|
|
|
|
for _, zf := range zr.File {
|
|
if ctxErr := r.Context().Err(); ctxErr != nil {
|
|
result.Errors = append(result.Errors, ctxErr.Error())
|
|
break
|
|
}
|
|
rel, ok := filer.SanitizeZipMemberName(zf)
|
|
if !ok {
|
|
result.Errors = append(result.Errors, zf.Name+": unsafe member name")
|
|
continue
|
|
}
|
|
if zf.FileInfo().IsDir() {
|
|
mkdirMember(join(rel))
|
|
continue
|
|
}
|
|
memberReader, openErr := zf.Open()
|
|
if openErr != nil {
|
|
result.Errors = append(result.Errors, rel+": "+openErr.Error())
|
|
continue
|
|
}
|
|
writeMember(rel, memberReader, int64(zf.UncompressedSize64))
|
|
memberReader.Close()
|
|
}
|
|
|
|
w.Header().Set(s3_constants.SeaweedFSExtractedCount, strconv.Itoa(len(result.Extracted)))
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(result)
|
|
}
|
|
|
|
// verifyArchiveChecksums compares the archive's supplied Content-Md5 and
|
|
// x-amz-checksum-* values against the bytes consumed while spooling. A nil
|
|
// hash means the client supplied nothing to check.
|
|
func verifyArchiveChecksums(expectedMd5 string, md5Hash hash.Hash, expectedChecksum string, checksumHash hash.Hash) s3err.ErrorCode {
|
|
if md5Hash != nil {
|
|
expected, err := base64.StdEncoding.DecodeString(expectedMd5)
|
|
if err != nil {
|
|
return s3err.ErrInvalidDigest
|
|
}
|
|
if sum := md5Hash.Sum(nil); !bytes.Equal(expected, sum) {
|
|
return s3err.ErrBadDigest
|
|
}
|
|
}
|
|
if checksumHash != nil && expectedChecksum != "" {
|
|
if sum := base64.StdEncoding.EncodeToString(checksumHash.Sum(nil)); expectedChecksum != sum {
|
|
return s3err.ErrBadDigest
|
|
}
|
|
}
|
|
return s3err.ErrNone
|
|
}
|
|
|
|
// scrubArchiveChecksums removes request checksums that describe the archive
|
|
// body; members are validated against their own bytes.
|
|
func scrubArchiveChecksums(r *http.Request) {
|
|
isChecksumHeader := func(k string) bool {
|
|
k = strings.ToLower(k)
|
|
return k == "content-md5" || k == "x-amz-trailer" || k == "x-amz-content-sha256" ||
|
|
strings.HasPrefix(k, "x-amz-checksum") || strings.HasPrefix(k, "x-amz-sdk-checksum")
|
|
}
|
|
for k := range r.Header {
|
|
if isChecksumHeader(k) {
|
|
delete(r.Header, k)
|
|
}
|
|
}
|
|
query := r.URL.Query()
|
|
for k := range query {
|
|
if isChecksumHeader(k) {
|
|
query.Del(k)
|
|
}
|
|
}
|
|
r.URL.RawQuery = query.Encode()
|
|
}
|