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 <codex@openai.com>
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Feng Ruohang
2026-04-15 18:36:49 +08:00
parent 3b950f8fa8
commit 56fa63bfd1
5 changed files with 361 additions and 9 deletions
+13
View File
@@ -192,6 +192,16 @@ func extractMetadata(ctx context.Context, mimesHeader ...textproto.MIMEHeader) (
// extractMetadata extracts metadata from map values. // extractMetadata extracts metadata from map values.
func extractMetadataFromMime(ctx context.Context, v textproto.MIMEHeader, m map[string]string) error { 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 { if v == nil {
bugLogIf(ctx, errInvalidArgument) bugLogIf(ctx, errInvalidArgument)
return errInvalidArgument return errInvalidArgument
@@ -208,6 +218,9 @@ func extractMetadataFromMime(ctx context.Context, v textproto.MIMEHeader, m map[
value, ok := nv[http.CanonicalHeaderKey(supportedHeader)] value, ok := nv[http.CanonicalHeaderKey(supportedHeader)]
if ok { if ok {
if v, ok := replicationToInternalHeaders[supportedHeader]; ok { if v, ok := replicationToInternalHeaders[supportedHeader]; ok {
if !allowReplication {
continue
}
m[v] = strings.Join(value, ",") m[v] = strings.Join(value, ",")
} else { } else {
m[supportedHeader] = strings.Join(value, ",") m[supportedHeader] = strings.Join(value, ",")
+116
View File
@@ -24,11 +24,13 @@ import (
"io" "io"
"net/http" "net/http"
"net/textproto" "net/textproto"
"net/url"
"os" "os"
"reflect" "reflect"
"testing" "testing"
"github.com/minio/minio/internal/config" "github.com/minio/minio/internal/config"
xhttp "github.com/minio/minio/internal/http"
) )
// Tests validate bucket LocationConstraint. // Tests validate bucket LocationConstraint.
@@ -152,6 +154,22 @@ func TestExtractMetadataHeaders(t *testing.T) {
}, },
shouldFail: false, 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. // Empty header input returns empty metadata.
{ {
header: nil, 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() // Test getResource()
func TestGetResource(t *testing.T) { func TestGetResource(t *testing.T) {
testCases := []struct { testCases := []struct {
+66 -9
View File
@@ -1036,7 +1036,7 @@ func (api objectAPIHandlers) HeadObjectHandler(w http.ResponseWriter, r *http.Re
// Extract metadata relevant for an CopyObject operation based on conditional // Extract metadata relevant for an CopyObject operation based on conditional
// header values specified in X-Amz-Metadata-Directive. // 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 // Make a copy of the supplied metadata to avoid
// to change the original one. // to change the original one.
defaultMeta := make(map[string]string, len(userMeta)) defaultMeta := make(map[string]string, len(userMeta))
@@ -1067,6 +1067,11 @@ func getCpObjMetadataFromHeader(ctx context.Context, r *http.Request, userMeta m
if err != nil { if err != nil {
return nil, err return nil, err
} }
if allowReplication {
if err = extractReplicationMetadataFromMime(ctx, textproto.MIMEHeader(r.Header), emetadata); err != nil {
return nil, err
}
}
if sc != "" { if sc != "" {
emetadata[xhttp.AmzStorageClass] = sc emetadata[xhttp.AmzStorageClass] = sc
} }
@@ -1087,6 +1092,31 @@ func getCpObjMetadataFromHeader(ctx context.Context, r *http.Request, userMeta m
return defaultMeta, nil 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 // getRemoteInstanceTransport contains a roundtripper for external (not peers) servers
var remoteInstanceTransport atomic.Value 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) writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrInvalidStorageClass), r.URL)
return 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 // Check if bucket encryption is enabled
sseConfig, _ := globalBucketSSEConfigSys.Get(dstBucket) sseConfig, _ := globalBucketSSEConfigSys.Get(dstBucket)
@@ -1240,7 +1283,7 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
}) })
var srcOpts, dstOpts ObjectOptions var srcOpts, dstOpts ObjectOptions
srcOpts, err = copySrcOpts(ctx, r, srcBucket, srcObject) srcOpts, err = copySrcOpts(ctx, optsReq, srcBucket, srcObject)
if err != nil { if err != nil {
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
return return
@@ -1252,14 +1295,14 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
VersionID: srcOpts.VersionID, VersionID: srcOpts.VersionID,
Versioned: srcOpts.Versioned, Versioned: srcOpts.Versioned,
VersionSuspended: srcOpts.VersionSuspended, VersionSuspended: srcOpts.VersionSuspended,
ReplicationRequest: r.Header.Get(xhttp.MinIOSourceReplicationRequest) == "true", ReplicationRequest: trustedReplicationRequest,
} }
getSSE := encrypt.SSE(srcOpts.ServerSideEncryption) getSSE := encrypt.SSE(srcOpts.ServerSideEncryption)
if getSSE != srcOpts.ServerSideEncryption { if getSSE != srcOpts.ServerSideEncryption {
getOpts.ServerSideEncryption = getSSE getOpts.ServerSideEncryption = getSSE
} }
dstOpts, err = copyDstOpts(ctx, r, dstBucket, dstObject, nil) dstOpts, err = copyDstOpts(ctx, optsReq, dstBucket, dstObject, nil)
if err != nil { if err != nil {
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
return return
@@ -1269,7 +1312,7 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
getObjectNInfo := objectAPI.GetObjectNInfo getObjectNInfo := objectAPI.GetObjectNInfo
checkCopyPrecondFn := func(o ObjectInfo) bool { 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) writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
return true return true
} }
@@ -1380,7 +1423,7 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
return return
} }
// Encryption parameters not present for this object. // 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) writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrInvalidSSECustomerAlgorithm), r.URL)
return return
} }
@@ -1546,7 +1589,7 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
srcInfo.PutObjReader = pReader srcInfo.PutObjReader = pReader
srcInfo.UserDefined, err = getCpObjMetadataFromHeader(ctx, r, srcInfo.UserDefined) srcInfo.UserDefined, err = getCpObjMetadataFromHeader(ctx, r, srcInfo.UserDefined, allowReplicationMetadata)
if err != nil { if err != nil {
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
return return
@@ -1628,10 +1671,10 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Err), r.URL) writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Err), r.URL)
return return
} }
if rs := r.Header.Get(xhttp.AmzBucketReplicationStatus); rs != "" { if allowReplicationMetadata {
srcInfo.UserDefined[ReservedMetadataPrefixLower+ReplicaStatus] = replication.Replica.String() srcInfo.UserDefined[ReservedMetadataPrefixLower+ReplicaStatus] = replication.Replica.String()
srcInfo.UserDefined[ReservedMetadataPrefixLower+ReplicaTimestamp] = UTCNow().Format(time.RFC3339Nano) srcInfo.UserDefined[ReservedMetadataPrefixLower+ReplicaTimestamp] = UTCNow().Format(time.RFC3339Nano)
srcInfo.UserDefined[xhttp.AmzBucketReplicationStatus] = rs srcInfo.UserDefined[xhttp.AmzBucketReplicationStatus] = replication.Replica.String()
} }
op := replication.ObjectReplicationType 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) writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Err), r.URL)
return 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+ReplicaStatus] = replication.Replica.String()
metadata[ReservedMetadataPrefixLower+ReplicaTimestamp] = UTCNow().Format(time.RFC3339Nano) metadata[ReservedMetadataPrefixLower+ReplicaTimestamp] = UTCNow().Format(time.RFC3339Nano)
defer globalReplicationStats.Load().UpdateReplicaStat(bucket, size) defer globalReplicationStats.Load().UpdateReplicaStat(bucket, size)
@@ -2388,10 +2435,15 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h
rawReader := hashReader rawReader := hashReader
pReader := NewPutObjReader(rawReader) pReader := NewPutObjReader(rawReader)
allowReplicationMetadata := false
if r.Header.Get(xhttp.AmzBucketReplicationStatus) == replication.Replica.String() { if r.Header.Get(xhttp.AmzBucketReplicationStatus) == replication.Replica.String() {
if s3Err = isPutActionAllowed(ctx, getRequestAuthType(r), bucket, object, r, policy.ReplicateObjectAction); s3Err != ErrNone { if s3Err = isPutActionAllowed(ctx, getRequestAuthType(r), bucket, object, r, policy.ReplicateObjectAction); s3Err != ErrNone {
return errors.New(errorCodes.ToAPIErr(s3Err).Code) 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+ReplicaStatus] = replication.Replica.String()
metadata[ReservedMetadataPrefixLower+ReplicaTimestamp] = UTCNow().Format(time.RFC3339Nano) metadata[ReservedMetadataPrefixLower+ReplicaTimestamp] = UTCNow().Format(time.RFC3339Nano)
} }
@@ -2417,6 +2469,11 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h
if err != nil { if err != nil {
return err return err
} }
if allowReplicationMetadata {
if err = extractReplicationMetadataFromMime(ctx, textproto.MIMEHeader(hdrs), m); err != nil {
return err
}
}
maps.Copy(metadata, m) maps.Copy(metadata, m)
} else { } else {
versionID = r.Form.Get(xhttp.VersionID) versionID = r.Form.Get(xhttp.VersionID)
+157
View File
@@ -42,6 +42,7 @@ import (
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
"github.com/minio/minio/internal/auth" "github.com/minio/minio/internal/auth"
"github.com/minio/minio/internal/crypto"
"github.com/minio/minio/internal/hash/sha256" "github.com/minio/minio/internal/hash/sha256"
xhttp "github.com/minio/minio/internal/http" xhttp "github.com/minio/minio/internal/http"
ioutilx "github.com/minio/minio/internal/ioutil" ioutilx "github.com/minio/minio/internal/ioutil"
@@ -60,6 +61,47 @@ const (
MissingUploadID 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. // Wrapper for calling HeadObject API handler tests for both Erasure multiple disks and FS single drive setup.
func TestAPIHeadObjectHandler(t *testing.T) { func TestAPIHeadObjectHandler(t *testing.T) {
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: testAPIHeadObjectHandler, endpoints: []string{"HeadObject"}}) 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. // Wrapper for calling Copy Object Part API handler tests for both Erasure multiple disks and single node setup.
func TestAPICopyObjectPartHandler(t *testing.T) { func TestAPICopyObjectPartHandler(t *testing.T) {
defer DetectTestLeak(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. // 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) { func TestAPICompleteMultipartHandler(t *testing.T) {
defer DetectTestLeak(t)() defer DetectTestLeak(t)()
+9
View File
@@ -24,6 +24,7 @@ import (
"io" "io"
"maps" "maps"
"net/http" "net/http"
"net/textproto"
"net/url" "net/url"
"sort" "sort"
"strconv" "strconv"
@@ -157,6 +158,14 @@ func (api objectAPIHandlers) NewMultipartUploadHandler(w http.ResponseWriter, r
metadata[xhttp.AmzObjectTagging] = objTags metadata[xhttp.AmzObjectTagging] = objTags
} }
if r.Header.Get(xhttp.AmzBucketReplicationStatus) == replication.Replica.String() { 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+ReplicaStatus] = replication.Replica.String()
metadata[ReservedMetadataPrefixLower+ReplicaTimestamp] = UTCNow().Format(time.RFC3339Nano) metadata[ReservedMetadataPrefixLower+ReplicaTimestamp] = UTCNow().Format(time.RFC3339Nano)
} }