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) }