From 56fa63bfd155154157cd7e1fb6dc295a3b3104ed Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Wed, 15 Apr 2026 18:36:49 +0800 Subject: [PATCH] fix: CVE-2026-34204 block replication metadata injection Close the replication-header trust flaw that allowed ordinary PutObject and CopyObject requests to smuggle X-Minio-Replication-* headers into X-Minio-Internal-* SSE metadata and write objects into an unreadable state. Stop accepting replication-only metadata in the default extraction path, restore it only after a trusted replication write has passed ReplicateObjectAction, and tighten CopyObject by sanitizing replication-only request headers before metadata, precondition, and SSE-C source handling consume them. Also gate replica status writes on the same trusted replication path and restore replication SSE metadata in multipart and snowball upload flows so legitimate replication continues to work. Add focused regression coverage for untrusted PUT and COPY header poisoning at the handler layer, plus helper tests for trusted vs untrusted metadata extraction and CopyObject header sanitization. Validate the new tests against both the patched tree and the vulnerable HEAD baseline, and confirm with live server before/after runs that malicious PUT/COPY requests no longer turn objects unreadable. Co-authored-by: Codex Co-Authored-By: Claude --- cmd/handler-utils.go | 13 +++ cmd/handler-utils_test.go | 116 +++++++++++++++++++++++ cmd/object-handlers.go | 75 +++++++++++++-- cmd/object-handlers_test.go | 157 +++++++++++++++++++++++++++++++ cmd/object-multipart-handlers.go | 9 ++ 5 files changed, 361 insertions(+), 9 deletions(-) diff --git a/cmd/handler-utils.go b/cmd/handler-utils.go index 7752cfece..7e70d5cb2 100644 --- a/cmd/handler-utils.go +++ b/cmd/handler-utils.go @@ -192,6 +192,16 @@ func extractMetadata(ctx context.Context, mimesHeader ...textproto.MIMEHeader) ( // extractMetadata extracts metadata from map values. func extractMetadataFromMime(ctx context.Context, v textproto.MIMEHeader, m map[string]string) error { + return extractMetadataFromMimeWithReplication(ctx, v, m, false) +} + +// extractReplicationMetadataFromMime restores replication-only metadata after the +// caller has validated that the request is a trusted replication write. +func extractReplicationMetadataFromMime(ctx context.Context, v textproto.MIMEHeader, m map[string]string) error { + return extractMetadataFromMimeWithReplication(ctx, v, m, true) +} + +func extractMetadataFromMimeWithReplication(ctx context.Context, v textproto.MIMEHeader, m map[string]string, allowReplication bool) error { if v == nil { bugLogIf(ctx, errInvalidArgument) return errInvalidArgument @@ -208,6 +218,9 @@ func extractMetadataFromMime(ctx context.Context, v textproto.MIMEHeader, m map[ value, ok := nv[http.CanonicalHeaderKey(supportedHeader)] if ok { if v, ok := replicationToInternalHeaders[supportedHeader]; ok { + if !allowReplication { + continue + } m[v] = strings.Join(value, ",") } else { m[supportedHeader] = strings.Join(value, ",") diff --git a/cmd/handler-utils_test.go b/cmd/handler-utils_test.go index 517f93fcc..f6691d329 100644 --- a/cmd/handler-utils_test.go +++ b/cmd/handler-utils_test.go @@ -24,11 +24,13 @@ import ( "io" "net/http" "net/textproto" + "net/url" "os" "reflect" "testing" "github.com/minio/minio/internal/config" + xhttp "github.com/minio/minio/internal/http" ) // Tests validate bucket LocationConstraint. @@ -152,6 +154,22 @@ func TestExtractMetadataHeaders(t *testing.T) { }, shouldFail: false, }, + // Replication-only headers must not be accepted on ordinary requests. + { + header: http.Header{ + "Content-Type": []string{"image/png"}, + "X-Minio-Replication-Server-Side-Encryption-Sealed-Key": []string{"sealed-key"}, + "X-Minio-Replication-Server-Side-Encryption-Seal-Algorithm": []string{"DAREv2-HMAC-SHA256"}, + "X-Minio-Replication-Server-Side-Encryption-Iv": []string{"iv"}, + "X-Minio-Replication-Encrypted-Multipart": []string{""}, + "X-Minio-Replication-Actual-Object-Size": []string{"1"}, + ReplicationSsecChecksumHeader: []string{"checksum"}, + }, + metadata: map[string]string{ + "content-type": "image/png", + }, + shouldFail: false, + }, // Empty header input returns empty metadata. { header: nil, @@ -176,6 +194,104 @@ func TestExtractMetadataHeaders(t *testing.T) { } } +func TestExtractReplicationMetadataHeaders(t *testing.T) { + header := http.Header{ + "X-Minio-Replication-Server-Side-Encryption-Sealed-Key": []string{"sealed-key"}, + "X-Minio-Replication-Server-Side-Encryption-Seal-Algorithm": []string{"DAREv2-HMAC-SHA256"}, + "X-Minio-Replication-Server-Side-Encryption-Iv": []string{"iv"}, + "X-Minio-Replication-Encrypted-Multipart": []string{""}, + "X-Minio-Replication-Actual-Object-Size": []string{"1"}, + ReplicationSsecChecksumHeader: []string{"checksum"}, + } + + metadata := make(map[string]string) + if err := extractReplicationMetadataFromMime(t.Context(), textproto.MIMEHeader(header), metadata); err != nil { + t.Fatalf("failed to extract replication metadata: %v", err) + } + + expected := map[string]string{ + "X-Minio-Internal-Server-Side-Encryption-Sealed-Key": "sealed-key", + "X-Minio-Internal-Server-Side-Encryption-Seal-Algorithm": "DAREv2-HMAC-SHA256", + "X-Minio-Internal-Server-Side-Encryption-Iv": "iv", + "X-Minio-Internal-Encrypted-Multipart": "", + "X-Minio-Internal-Actual-Object-Size": "1", + ReplicationSsecChecksumHeader: "checksum", + } + + if !reflect.DeepEqual(metadata, expected) { + t.Fatalf("unexpected replication metadata: expected %#v, got %#v", expected, metadata) + } +} + +func TestGetCopyObjectMetadataFromHeaderReplication(t *testing.T) { + req, err := http.NewRequest(http.MethodPut, "http://localhost/test", nil) + if err != nil { + t.Fatal(err) + } + req.Form = make(url.Values) + req.Header.Set("X-Amz-Metadata-Directive", replaceDirective) + req.Header.Set("X-Minio-Replication-Server-Side-Encryption-Sealed-Key", "sealed-key") + + metadata, err := getCpObjMetadataFromHeader(t.Context(), req, nil, false) + if err != nil { + t.Fatalf("copy metadata extraction failed: %v", err) + } + if _, ok := metadata["X-Minio-Internal-Server-Side-Encryption-Sealed-Key"]; ok { + t.Fatalf("unexpected replication metadata without validation: %#v", metadata) + } + + metadata, err = getCpObjMetadataFromHeader(t.Context(), req, nil, true) + if err != nil { + t.Fatalf("copy metadata extraction with replication failed: %v", err) + } + if got := metadata["X-Minio-Internal-Server-Side-Encryption-Sealed-Key"]; got != "sealed-key" { + t.Fatalf("expected restored replication metadata, got %#v", metadata) + } +} + +func TestCloneRequestWithoutCopyReplicationHeaders(t *testing.T) { + req, err := http.NewRequest(http.MethodPut, "http://localhost/test", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set(xhttp.MinIOSourceReplicationRequest, "true") + req.Header.Set(xhttp.MinIOSourceETag, "etag") + req.Header.Set(xhttp.MinIOSourceMTime, "2026-04-15T10:00:00Z") + req.Header.Set(xhttp.MinIOSourceTaggingTimestamp, "2026-04-15T10:00:00Z") + req.Header.Set(xhttp.MinIOSourceObjectRetentionTimestamp, "2026-04-15T10:00:00Z") + req.Header.Set(xhttp.MinIOSourceObjectLegalHoldTimestamp, "2026-04-15T10:00:00Z") + req.Header.Set(xhttp.MinIOReplicationActualObjectSize, "123") + req.Header.Set(ReplicationSsecChecksumHeader, "checksum") + req.Header.Set("Content-Type", "application/octet-stream") + + clone := cloneRequestWithoutCopyReplicationHeaders(req) + if clone == req { + t.Fatal("expected cloned request") + } + + for _, header := range []string{ + xhttp.MinIOSourceReplicationRequest, + xhttp.MinIOSourceETag, + xhttp.MinIOSourceMTime, + xhttp.MinIOSourceTaggingTimestamp, + xhttp.MinIOSourceObjectRetentionTimestamp, + xhttp.MinIOSourceObjectLegalHoldTimestamp, + xhttp.MinIOReplicationActualObjectSize, + ReplicationSsecChecksumHeader, + } { + if got := clone.Header.Get(header); got != "" { + t.Fatalf("expected %s to be stripped, got %q", header, got) + } + if got := req.Header.Get(header); got == "" { + t.Fatalf("expected original request to preserve %s", header) + } + } + + if got := clone.Header.Get("Content-Type"); got != "application/octet-stream" { + t.Fatalf("expected non-replication headers to be preserved, got %q", got) + } +} + // Test getResource() func TestGetResource(t *testing.T) { testCases := []struct { diff --git a/cmd/object-handlers.go b/cmd/object-handlers.go index 6542c4655..0afd8f740 100644 --- a/cmd/object-handlers.go +++ b/cmd/object-handlers.go @@ -1036,7 +1036,7 @@ func (api objectAPIHandlers) HeadObjectHandler(w http.ResponseWriter, r *http.Re // Extract metadata relevant for an CopyObject operation based on conditional // header values specified in X-Amz-Metadata-Directive. -func getCpObjMetadataFromHeader(ctx context.Context, r *http.Request, userMeta map[string]string) (map[string]string, error) { +func getCpObjMetadataFromHeader(ctx context.Context, r *http.Request, userMeta map[string]string, allowReplication bool) (map[string]string, error) { // Make a copy of the supplied metadata to avoid // to change the original one. defaultMeta := make(map[string]string, len(userMeta)) @@ -1067,6 +1067,11 @@ func getCpObjMetadataFromHeader(ctx context.Context, r *http.Request, userMeta m if err != nil { return nil, err } + if allowReplication { + if err = extractReplicationMetadataFromMime(ctx, textproto.MIMEHeader(r.Header), emetadata); err != nil { + return nil, err + } + } if sc != "" { emetadata[xhttp.AmzStorageClass] = sc } @@ -1087,6 +1092,31 @@ func getCpObjMetadataFromHeader(ctx context.Context, r *http.Request, userMeta m return defaultMeta, nil } +func cloneRequestWithoutCopyReplicationHeaders(r *http.Request) *http.Request { + if r == nil { + return nil + } + + clone := new(http.Request) + *clone = *r + clone.Header = r.Header.Clone() + + for _, header := range []string{ + xhttp.MinIOSourceReplicationRequest, + xhttp.MinIOSourceETag, + xhttp.MinIOSourceMTime, + xhttp.MinIOSourceTaggingTimestamp, + xhttp.MinIOSourceObjectRetentionTimestamp, + xhttp.MinIOSourceObjectLegalHoldTimestamp, + xhttp.MinIOReplicationActualObjectSize, + ReplicationSsecChecksumHeader, + } { + clone.Header.Del(header) + } + + return clone +} + // getRemoteInstanceTransport contains a roundtripper for external (not peers) servers var remoteInstanceTransport atomic.Value @@ -1232,6 +1262,19 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrInvalidStorageClass), r.URL) return } + allowReplicationMetadata := false + if r.Header.Get(xhttp.AmzBucketReplicationStatus) == replication.Replica.String() { + if s3Error := checkRequestAuthType(ctx, r, policy.ReplicateObjectAction, dstBucket, dstObject); s3Error != ErrNone { + writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL) + return + } + allowReplicationMetadata = true + } + trustedReplicationRequest := allowReplicationMetadata && r.Header.Get(xhttp.MinIOSourceReplicationRequest) == "true" + optsReq := r + if !trustedReplicationRequest { + optsReq = cloneRequestWithoutCopyReplicationHeaders(r) + } // Check if bucket encryption is enabled sseConfig, _ := globalBucketSSEConfigSys.Get(dstBucket) @@ -1240,7 +1283,7 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re }) var srcOpts, dstOpts ObjectOptions - srcOpts, err = copySrcOpts(ctx, r, srcBucket, srcObject) + srcOpts, err = copySrcOpts(ctx, optsReq, srcBucket, srcObject) if err != nil { writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) return @@ -1252,14 +1295,14 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re VersionID: srcOpts.VersionID, Versioned: srcOpts.Versioned, VersionSuspended: srcOpts.VersionSuspended, - ReplicationRequest: r.Header.Get(xhttp.MinIOSourceReplicationRequest) == "true", + ReplicationRequest: trustedReplicationRequest, } getSSE := encrypt.SSE(srcOpts.ServerSideEncryption) if getSSE != srcOpts.ServerSideEncryption { getOpts.ServerSideEncryption = getSSE } - dstOpts, err = copyDstOpts(ctx, r, dstBucket, dstObject, nil) + dstOpts, err = copyDstOpts(ctx, optsReq, dstBucket, dstObject, nil) if err != nil { writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) return @@ -1269,7 +1312,7 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re getObjectNInfo := objectAPI.GetObjectNInfo checkCopyPrecondFn := func(o ObjectInfo) bool { - if _, err := DecryptObjectInfo(&o, r); err != nil { + if _, err := DecryptObjectInfo(&o, optsReq); err != nil { writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) return true } @@ -1380,7 +1423,7 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re return } // Encryption parameters not present for this object. - if crypto.SSEC.IsEncrypted(srcInfo.UserDefined) && !crypto.SSECopy.IsRequested(r.Header) && r.Header.Get(xhttp.MinIOSourceReplicationRequest) != "true" { + if crypto.SSEC.IsEncrypted(srcInfo.UserDefined) && !crypto.SSECopy.IsRequested(r.Header) && !trustedReplicationRequest { writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrInvalidSSECustomerAlgorithm), r.URL) return } @@ -1546,7 +1589,7 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re srcInfo.PutObjReader = pReader - srcInfo.UserDefined, err = getCpObjMetadataFromHeader(ctx, r, srcInfo.UserDefined) + srcInfo.UserDefined, err = getCpObjMetadataFromHeader(ctx, r, srcInfo.UserDefined, allowReplicationMetadata) if err != nil { writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) return @@ -1628,10 +1671,10 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Err), r.URL) return } - if rs := r.Header.Get(xhttp.AmzBucketReplicationStatus); rs != "" { + if allowReplicationMetadata { srcInfo.UserDefined[ReservedMetadataPrefixLower+ReplicaStatus] = replication.Replica.String() srcInfo.UserDefined[ReservedMetadataPrefixLower+ReplicaTimestamp] = UTCNow().Format(time.RFC3339Nano) - srcInfo.UserDefined[xhttp.AmzBucketReplicationStatus] = rs + srcInfo.UserDefined[xhttp.AmzBucketReplicationStatus] = replication.Replica.String() } op := replication.ObjectReplicationType @@ -1933,6 +1976,10 @@ func (api objectAPIHandlers) PutObjectHandler(w http.ResponseWriter, r *http.Req writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Err), r.URL) return } + if err = extractReplicationMetadataFromMime(ctx, textproto.MIMEHeader(r.Header), metadata); err != nil { + writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) + return + } metadata[ReservedMetadataPrefixLower+ReplicaStatus] = replication.Replica.String() metadata[ReservedMetadataPrefixLower+ReplicaTimestamp] = UTCNow().Format(time.RFC3339Nano) defer globalReplicationStats.Load().UpdateReplicaStat(bucket, size) @@ -2388,10 +2435,15 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h rawReader := hashReader pReader := NewPutObjReader(rawReader) + allowReplicationMetadata := false if r.Header.Get(xhttp.AmzBucketReplicationStatus) == replication.Replica.String() { if s3Err = isPutActionAllowed(ctx, getRequestAuthType(r), bucket, object, r, policy.ReplicateObjectAction); s3Err != ErrNone { return errors.New(errorCodes.ToAPIErr(s3Err).Code) } + allowReplicationMetadata = true + if err = extractReplicationMetadataFromMime(ctx, textproto.MIMEHeader(r.Header), metadata); err != nil { + return err + } metadata[ReservedMetadataPrefixLower+ReplicaStatus] = replication.Replica.String() metadata[ReservedMetadataPrefixLower+ReplicaTimestamp] = UTCNow().Format(time.RFC3339Nano) } @@ -2417,6 +2469,11 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h if err != nil { return err } + if allowReplicationMetadata { + if err = extractReplicationMetadataFromMime(ctx, textproto.MIMEHeader(hdrs), m); err != nil { + return err + } + } maps.Copy(metadata, m) } else { versionID = r.Form.Get(xhttp.VersionID) diff --git a/cmd/object-handlers_test.go b/cmd/object-handlers_test.go index ec71c6923..a182d9e6f 100644 --- a/cmd/object-handlers_test.go +++ b/cmd/object-handlers_test.go @@ -42,6 +42,7 @@ import ( "github.com/dustin/go-humanize" "github.com/minio/minio/internal/auth" + "github.com/minio/minio/internal/crypto" "github.com/minio/minio/internal/hash/sha256" xhttp "github.com/minio/minio/internal/http" ioutilx "github.com/minio/minio/internal/ioutil" @@ -60,6 +61,47 @@ const ( MissingUploadID ) +func replicationSSEPoisonHeaders() map[string]string { + return map[string]string{ + "X-Minio-Replication-Server-Side-Encryption-Sealed-Key": base64.StdEncoding.EncodeToString(make([]byte, 64)), + "X-Minio-Replication-Server-Side-Encryption-Seal-Algorithm": crypto.SealAlgorithm, + "X-Minio-Replication-Server-Side-Encryption-Iv": base64.StdEncoding.EncodeToString(make([]byte, 32)), + } +} + +func assertObjectMetadataKeysAbsent(t *testing.T, metadata map[string]string, keys ...string) { + t.Helper() + for _, key := range keys { + if got, ok := metadata[key]; ok { + t.Fatalf("expected metadata %q to be absent, got %q", key, got) + } + } +} + +func assertObjectMetadataValueNotEqual(t *testing.T, metadata map[string]string, key, unexpected string) { + t.Helper() + if got := metadata[key]; got == unexpected { + t.Fatalf("expected metadata %q to differ from %q", key, unexpected) + } +} + +func assertObjectContents(t *testing.T, obj ObjectLayer, bucketName, objectName string, expected []byte) { + t.Helper() + reader, err := obj.GetObjectNInfo(context.Background(), bucketName, objectName, nil, nil, ObjectOptions{}) + if err != nil { + t.Fatalf("failed to fetch object %s/%s: %v", bucketName, objectName, err) + } + defer reader.Close() + + got, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("failed to read object %s/%s: %v", bucketName, objectName, err) + } + if !bytes.Equal(got, expected) { + t.Fatalf("unexpected object contents: got %d bytes, expected %d bytes", len(got), len(expected)) + } +} + // Wrapper for calling HeadObject API handler tests for both Erasure multiple disks and FS single drive setup. func TestAPIHeadObjectHandler(t *testing.T) { ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: testAPIHeadObjectHandler, endpoints: []string{"HeadObject"}}) @@ -1819,6 +1861,53 @@ func testAPICopyObjectPartHandlerSanity(obj ObjectLayer, instanceType, bucketNam } } +func TestAPIPutObjectReplicationHeaderPoisoning(t *testing.T) { + defer DetectTestLeak(t)() + ExecExtendedObjectLayerAPITest(t, testAPIPutObjectReplicationHeaderPoisoning, []string{"PutObject"}) +} + +func testAPIPutObjectReplicationHeaderPoisoning(obj ObjectLayer, instanceType, bucketName string, apiRouter http.Handler, + credentials auth.Credentials, t *testing.T, +) { + objectName := "replication-header-poison-put" + payload := []byte("replication-header-poison-put-payload") + headers := replicationSSEPoisonHeaders() + + req, err := newTestSignedRequestV4( + http.MethodPut, + getPutObjectURL("", bucketName, objectName), + int64(len(payload)), + bytes.NewReader(payload), + credentials.AccessKey, + credentials.SecretKey, + headers, + ) + if err != nil { + t.Fatalf("%s: failed to create signed put request: %v", instanceType, err) + } + + rec := httptest.NewRecorder() + apiRouter.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("%s: expected put to succeed, got %d", instanceType, rec.Code) + } + + objInfo, err := obj.GetObjectInfo(context.Background(), bucketName, objectName, ObjectOptions{}) + if err != nil { + t.Fatalf("%s: failed to fetch object info: %v", instanceType, err) + } + + assertObjectMetadataValueNotEqual(t, objInfo.UserDefined, + crypto.MetaSealedKeySSEC, + headers["X-Minio-Replication-Server-Side-Encryption-Sealed-Key"], + ) + assertObjectMetadataValueNotEqual(t, objInfo.UserDefined, + crypto.MetaIV, + headers["X-Minio-Replication-Server-Side-Encryption-Iv"], + ) + assertObjectContents(t, obj, bucketName, objectName, payload) +} + // Wrapper for calling Copy Object Part API handler tests for both Erasure multiple disks and single node setup. func TestAPICopyObjectPartHandler(t *testing.T) { defer DetectTestLeak(t)() @@ -2860,6 +2949,74 @@ func testAPINewMultipartHandlerParallel(obj ObjectLayer, instanceType, bucketNam } } +func TestAPICopyObjectReplicationHeaderPoisoning(t *testing.T) { + defer DetectTestLeak(t)() + ExecExtendedObjectLayerAPITest(t, testAPICopyObjectReplicationHeaderPoisoning, []string{"CopyObject", "PutObject"}) +} + +func testAPICopyObjectReplicationHeaderPoisoning(obj ObjectLayer, instanceType, bucketName string, apiRouter http.Handler, + credentials auth.Credentials, t *testing.T, +) { + srcObject := "replication-header-poison-copy-src" + dstObject := "replication-header-poison-copy-dst" + payload := []byte("replication-header-poison-copy-payload") + + if _, err := obj.PutObject( + context.Background(), + bucketName, + srcObject, + mustGetPutObjReader(t, bytes.NewReader(payload), int64(len(payload)), "", ""), + ObjectOptions{}, + ); err != nil { + t.Fatalf("%s: failed to create source object: %v", instanceType, err) + } + + headers := replicationSSEPoisonHeaders() + headers[xhttp.AmzCopySource] = url.QueryEscape(SlashSeparator + bucketName + SlashSeparator + srcObject) + headers[xhttp.AmzMetadataDirective] = replaceDirective + headers[xhttp.AmzBucketReplicationStatus] = "PENDING" + headers["Content-Type"] = "application/octet-stream" + + req, err := newTestSignedRequestV4( + http.MethodPut, + getCopyObjectURL("", bucketName, dstObject), + 0, + nil, + credentials.AccessKey, + credentials.SecretKey, + headers, + ) + if err != nil { + t.Fatalf("%s: failed to create signed copy request: %v", instanceType, err) + } + + rec := httptest.NewRecorder() + apiRouter.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("%s: expected copy to succeed, got %d", instanceType, rec.Code) + } + + objInfo, err := obj.GetObjectInfo(context.Background(), bucketName, dstObject, ObjectOptions{}) + if err != nil { + t.Fatalf("%s: failed to fetch copied object info: %v", instanceType, err) + } + + assertObjectMetadataValueNotEqual(t, objInfo.UserDefined, + crypto.MetaSealedKeySSEC, + headers["X-Minio-Replication-Server-Side-Encryption-Sealed-Key"], + ) + assertObjectMetadataValueNotEqual(t, objInfo.UserDefined, + crypto.MetaIV, + headers["X-Minio-Replication-Server-Side-Encryption-Iv"], + ) + assertObjectMetadataKeysAbsent(t, objInfo.UserDefined, + xhttp.AmzBucketReplicationStatus, + ReservedMetadataPrefixLower+ReplicaStatus, + ReservedMetadataPrefixLower+ReplicaTimestamp, + ) + assertObjectContents(t, obj, bucketName, dstObject, payload) +} + // The UploadID from the response body is parsed and its existence is asserted with an attempt to ListParts using it. func TestAPICompleteMultipartHandler(t *testing.T) { defer DetectTestLeak(t)() diff --git a/cmd/object-multipart-handlers.go b/cmd/object-multipart-handlers.go index 12a99b312..fd40db42e 100644 --- a/cmd/object-multipart-handlers.go +++ b/cmd/object-multipart-handlers.go @@ -24,6 +24,7 @@ import ( "io" "maps" "net/http" + "net/textproto" "net/url" "sort" "strconv" @@ -157,6 +158,14 @@ func (api objectAPIHandlers) NewMultipartUploadHandler(w http.ResponseWriter, r metadata[xhttp.AmzObjectTagging] = objTags } if r.Header.Get(xhttp.AmzBucketReplicationStatus) == replication.Replica.String() { + if s3Err := isPutActionAllowed(ctx, getRequestAuthType(r), bucket, object, r, policy.ReplicateObjectAction); s3Err != ErrNone { + writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Err), r.URL) + return + } + if err = extractReplicationMetadataFromMime(ctx, textproto.MIMEHeader(r.Header), metadata); err != nil { + writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) + return + } metadata[ReservedMetadataPrefixLower+ReplicaStatus] = replication.Replica.String() metadata[ReservedMetadataPrefixLower+ReplicaTimestamp] = UTCNow().Format(time.RFC3339Nano) }