Merge pull request #122 from pgsty/fix/issue-109-raw-ssec-replica

fix: store raw SSE-C replicas verbatim on the destination
This commit is contained in:
Feng Ruohang
2026-09-06 09:24:26 +08:00
committed by GitHub
6 changed files with 626 additions and 29 deletions
@@ -1061,9 +1061,6 @@
"cmd/object-handlers.go=\"minio-federated\"",
"cmd/object-handlers.go=\"minio.metadata.\"",
"cmd/object-handlers.go=\"minio.versionId\"",
"cmd/object-multipart-handlers.go=\"X-Minio-Replication-Server-Side-Encryption-Iv\"",
"cmd/object-multipart-handlers.go=\"X-Minio-Replication-Server-Side-Encryption-Seal-Algorithm\"",
"cmd/object-multipart-handlers.go=\"X-Minio-Replication-Server-Side-Encryption-Sealed-Key\"",
"cmd/replication-trust.go=\"X-Minio-Replication-Encrypted-Multipart\"",
"cmd/replication-trust.go=\"X-Minio-Replication-Server-Side-Encryption-Iv\"",
"cmd/replication-trust.go=\"X-Minio-Replication-Server-Side-Encryption-Seal-Algorithm\"",
+13
View File
@@ -779,6 +779,15 @@ func putReplicationOpts(ctx context.Context, sc string, objInfo ObjectInfo) (put
meta := make(map[string]string)
isSSEC := crypto.SSEC.IsEncrypted(objInfo.UserDefined)
// An SSE-C object is replicated as raw ciphertext, and the replication
// headers carry no compression state. Sending a compressed SSE-C object
// would land a replica that decrypts to an S2 stream instead of the
// object, so fail loudly instead of writing a wrong replica.
if isSSEC && objInfo.IsCompressed() {
return putOpts, false, fmt.Errorf("replication of a compressed SSE-C object is not supported: %s/%s(%s)",
objInfo.Bucket, objInfo.Name, objInfo.VersionID)
}
for k, v := range objInfo.UserDefined {
_, isValidSSEHeader := validSSEReplicationHeaders[k]
// In case of SSE-C objects copy the allowed internal headers as well
@@ -1299,6 +1308,8 @@ func (ri ReplicateObjectInfo) replicateObject(ctx context.Context, objectAPI Obj
putOpts, isMP, err := putReplicationOpts(ctx, tgt.StorageClass, objInfo)
if err != nil {
rinfo.Err = err
rinfo.ReplicationStatus = replication.Failed
replLogIf(ctx, fmt.Errorf("failure setting options for replication bucket:%s err:%w", bucket, err))
sendEvent(eventArgs{
EventName: event.ObjectReplicationNotTracked,
@@ -1586,6 +1597,8 @@ applyAction:
} else {
putOpts, isMP, err := putReplicationOpts(ctx, tgt.StorageClass, objInfo)
if err != nil {
rinfo.Err = err
rinfo.ReplicationStatus = replication.Failed
replLogIf(ctx, fmt.Errorf("failed to set replicate options for object %s/%s(%s) (target %s) err:%w", bucket, objInfo.Name, objInfo.VersionID, tgt.EndpointURL(), err))
sendEvent(eventArgs{
EventName: event.ObjectReplicationNotTracked,
+17 -7
View File
@@ -2120,11 +2120,18 @@ func (api objectAPIHandlers) PutObjectHandler(w http.ResponseWriter, r *http.Req
delete(metadata, xhttp.AmzBucketReplicationStatus)
}
// Check if bucket encryption is enabled
sseConfig, _ := globalBucketSSEConfigSys.Get(bucket)
sseConfig.Apply(r.Header, sse.ApplyOptions{
AutoEncrypt: globalAutoEncryption,
})
// A validated raw SSE-C replica carries the source ciphertext and the
// source seal. Its bytes must be stored verbatim: default encryption would
// overwrite the SSE-C IV and compression would invalidate the ciphertext.
rawSSECReplica := isRawSSECReplica(r.Header, replicaTrusted)
if !rawSSECReplica {
// Check if bucket encryption is enabled
sseConfig, _ := globalBucketSSEConfigSys.Get(bucket)
sseConfig.Apply(r.Header, sse.ApplyOptions{
AutoEncrypt: globalAutoEncryption,
})
}
var reader io.Reader
reader = rd
@@ -2138,7 +2145,7 @@ func (api objectAPIHandlers) PutObjectHandler(w http.ResponseWriter, r *http.Req
actualSize := size
var idxCb func() []byte
if isCompressible(r.Header, object) && size > minCompressibleSize {
if !rawSSECReplica && isCompressible(r.Header, object) && size > minCompressibleSize {
// Storing the compression metadata.
metadata[ReservedMetadataPrefix+"compression"] = compressionAlgorithmV2
metadata[ReservedMetadataPrefix+"actual-size"] = strconv.FormatInt(size, 10)
@@ -2233,7 +2240,10 @@ func (api objectAPIHandlers) PutObjectHandler(w http.ResponseWriter, r *http.Req
metadata[ReservedMetadataPrefixLower+ReplicationStatus] = dsc.PendingStatus()
}
var objectEncryptionKey crypto.ObjectKey
if crypto.Requested(r.Header) {
// rawSSECReplica also gates the branch itself, not just the default-SSE
// header that would normally reach it: a trusted peer that sends an
// explicit public SSE header alongside a source seal must not re-encrypt.
if !rawSSECReplica && crypto.Requested(r.Header) {
if crypto.SSECopy.IsRequested(r.Header) {
writeErrorResponse(ctx, w, toAPIError(ctx, errInvalidEncryptionParameters), r.URL)
return
+13 -19
View File
@@ -180,11 +180,17 @@ func (api objectAPIHandlers) NewMultipartUploadHandler(w http.ResponseWriter, r
ctx, r = applyReplicationTrust(ctx, r, trustedReplication, replicaTrusted)
}
// Check if bucket encryption is enabled
sseConfig, _ := globalBucketSSEConfigSys.Get(bucket)
sseConfig.Apply(r.Header, sse.ApplyOptions{
AutoEncrypt: globalAutoEncryption,
})
// A validated raw SSE-C replica upload carries source ciphertext in every
// part; the destination must not add its own encryption or compression.
rawSSECReplica := isRawSSECReplica(r.Header, replicaTrusted)
if !rawSSECReplica {
// Check if bucket encryption is enabled
sseConfig, _ := globalBucketSSEConfigSys.Get(bucket)
sseConfig.Apply(r.Header, sse.ApplyOptions{
AutoEncrypt: globalAutoEncryption,
})
}
// Validate the storage class header if present. Query values retain the
// existing compatibility path, including its historical validation behavior.
@@ -213,19 +219,7 @@ func (api objectAPIHandlers) NewMultipartUploadHandler(w http.ResponseWriter, r
return
}
ssecRepHeaders := []string{
"X-Minio-Replication-Server-Side-Encryption-Seal-Algorithm",
"X-Minio-Replication-Server-Side-Encryption-Sealed-Key",
"X-Minio-Replication-Server-Side-Encryption-Iv",
}
ssecRep := false
for _, header := range ssecRepHeaders {
if val := r.Header.Get(header); val != "" {
ssecRep = true
break
}
}
if !ssecRep || !replicaTrusted {
if !rawSSECReplica {
if err = setEncryptionMetadata(r, bucket, object, encMetadata); err != nil {
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
return
@@ -289,7 +283,7 @@ func (api objectAPIHandlers) NewMultipartUploadHandler(w http.ResponseWriter, r
// Ensure that metadata does not contain sensitive information
crypto.RemoveSensitiveEntries(metadata)
if isCompressible(r.Header, object) {
if !rawSSECReplica && isCompressible(r.Header, object) {
// Storing the compression metadata.
metadata[ReservedMetadataPrefix+"compression"] = compressionAlgorithmV2
}
+558
View File
@@ -0,0 +1,558 @@
// Copyright (c) 2015-2026 MinIO, Inc.
// Copyright (c) 2026 PGSTY
//
// This file is part of MinIO Object Storage stack
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
package cmd
import (
"bytes"
"crypto/md5"
"encoding/base64"
"encoding/xml"
"io"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/minio/minio/internal/auth"
"github.com/minio/minio/internal/crypto"
xhttp "github.com/minio/minio/internal/http"
"github.com/minio/minio/internal/kms"
)
// TestAPISSECReplicaSkipsDestinationTransforms asserts that a validated raw
// SSE-C replica write is stored byte for byte, whatever default encryption or
// compression the destination bucket has configured, and that the exemption is
// gated on replication trust rather than on the headers alone.
func TestAPISSECReplicaSkipsDestinationTransforms(t *testing.T) {
defer DetectTestLeak(t)()
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
t: t,
objAPITest: testAPISSECReplicaSkipsDestinationTransforms,
})
}
func testAPISSECReplicaSkipsDestinationTransforms(obj ObjectLayer, instanceType, bucketName string,
apiRouter http.Handler, credentials auth.Credentials, t *testing.T,
) {
previousTLS := globalIsTLS
globalIsTLS = true
defer func() { globalIsTLS = previousTLS }()
replicator := newObjectAttributesAuthzUser(t, instanceType, bucketName, `"s3:PutObject","s3:GetObject","s3:ReplicateObject"`)
putOnly := newObjectAttributesAuthzUser(t, instanceType, bucketName, `"s3:PutObject","s3:GetObject"`)
key := bytes.Repeat([]byte{0x42}, 32)
keyMD5 := md5.Sum(key)
sseHeaders := map[string]string{
xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES,
xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(key),
xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(keyMD5[:]),
}
data := bytes.Repeat([]byte("silo ssec replica payload "), 8192) // > minCompressibleSize
// makeSource writes a real client SSE-C object, then reads it back the way
// the replication worker does and builds the replica wire headers from the
// production option builder. The SSE-C sealed key is bound to bucket and
// object path, so a replica is only unsealable at the same key: the caller
// writes the raw bytes back over the same name, as a real destination does.
makeSource := func(t *testing.T, object string) ([]byte, map[string]string, ObjectInfo) {
t.Helper()
srcReq, err := newTestSignedRequestV4(http.MethodPut, getPutObjectURL("", bucketName, object),
int64(len(data)), bytes.NewReader(data), credentials.AccessKey, credentials.SecretKey, sseHeaders)
if err != nil {
t.Fatal(err)
}
srcRec := httptest.NewRecorder()
apiRouter.ServeHTTP(srcRec, srcReq)
if srcRec.Code != http.StatusOK {
t.Fatalf("%s: source PUT status %d: %s", instanceType, srcRec.Code, srcRec.Body.String())
}
gr, err := obj.GetObjectNInfo(t.Context(), bucketName, object, nil, http.Header{}, ObjectOptions{ReplicationRequest: true})
if err != nil {
t.Fatal(err)
}
sourceInfo := gr.ObjInfo
raw, err := io.ReadAll(gr)
gr.Close()
if err != nil {
t.Fatal(err)
}
if len(raw) == 0 || bytes.Equal(raw, data) {
t.Fatal("source replication read did not return encrypted bytes")
}
if sourceInfo.UserDefined[ReservedMetadataPrefix+"compression"] != "" {
t.Fatal("source SSE-C object was compressed; the fixture is not a raw SSE-C source")
}
replicationOpts, isMP, err := putReplicationOpts(t.Context(), "", sourceInfo)
if err != nil {
t.Fatal(err)
}
if isMP {
t.Fatal("single PUT SSE-C source was classified as multipart")
}
headers := make(map[string]string)
for name, values := range replicationOpts.Header() {
if len(values) > 0 {
headers[name] = values[0]
}
}
if headers["X-Minio-Replication-Server-Side-Encryption-Sealed-Key"] == "" {
t.Fatal("replication options did not carry the source SSE-C seal")
}
return raw, headers, sourceInfo
}
putReplica := func(t *testing.T, object string, raw []byte, creds auth.Credentials, headers map[string]string) *httptest.ResponseRecorder {
t.Helper()
req, err := newTestSignedRequestV4(http.MethodPut, getPutObjectURL("", bucketName, object),
int64(len(raw)), bytes.NewReader(raw), creds.AccessKey, creds.SecretKey, headers)
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
apiRouter.ServeHTTP(rec, req)
return rec
}
withDefaultSSE := func(t *testing.T) func() {
t.Helper()
previousKMS := GlobalKMS
GlobalKMS = kms.NewStub("ssec-replica-default-encryption")
sseXML := []byte(`<ServerSideEncryptionConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Rule><ApplyServerSideEncryptionByDefault><SSEAlgorithm>AES256</SSEAlgorithm></ApplyServerSideEncryptionByDefault></Rule></ServerSideEncryptionConfiguration>`)
if _, err := globalBucketMetadataSys.Update(t.Context(), bucketName, bucketSSEConfig, sseXML); err != nil {
t.Fatalf("configure bucket encryption: %v", err)
}
return func() {
if _, err := globalBucketMetadataSys.Delete(t.Context(), bucketName, bucketSSEConfig); err != nil {
t.Fatalf("remove bucket encryption: %v", err)
}
GlobalKMS = previousKMS
}
}
withCompression := func(t *testing.T) func() {
t.Helper()
globalCompressConfigMu.Lock()
previous := globalCompressConfig
globalCompressConfig.Enabled = true
globalCompressConfig.Extensions = []string{".txt"}
globalCompressConfig.MimeTypes = nil
globalCompressConfig.AllowEncrypted = false
globalCompressConfigMu.Unlock()
return func() {
globalCompressConfigMu.Lock()
globalCompressConfig = previous
globalCompressConfigMu.Unlock()
}
}
for _, tc := range []struct {
name string
setup func(*testing.T) func()
extraHeaders map[string]string
}{
{name: "default-sse-s3", setup: withDefaultSSE},
{name: "compression", setup: withCompression},
// A trusted peer that also sends an explicit public SSE header must
// not re-encrypt: skipping the bucket default alone would not stop it.
{name: "explicit-sse-header", extraHeaders: map[string]string{xhttp.AmzServerSideEncryption: xhttp.AmzEncryptionAES}},
} {
t.Run(instanceType+"/"+tc.name, func(t *testing.T) {
object := "replication/ssec-" + tc.name + ".txt"
raw, replicationHeaders, sourceInfo := makeSource(t, object)
if tc.setup != nil {
cleanup := tc.setup(t)
defer cleanup()
}
for k, v := range tc.extraHeaders {
replicationHeaders[k] = v
}
rec := putReplica(t, object, raw, replicator, replicationHeaders)
if rec.Code != http.StatusOK {
t.Fatalf("replica PUT status %d: %s", rec.Code, rec.Body.String())
}
info, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{})
if err != nil {
t.Fatal(err)
}
for _, name := range []string{crypto.MetaSealedKeySSEC, crypto.MetaIV, crypto.MetaAlgorithm} {
if got, want := info.UserDefined[name], sourceInfo.UserDefined[name]; got != want {
t.Errorf("%s: replica has %q, source has %q", name, got, want)
}
}
for _, name := range []string{crypto.MetaSealedKeyS3, crypto.MetaKeyID, ReservedMetadataPrefix + "compression"} {
if v, ok := info.UserDefined[name]; ok {
t.Errorf("replica gained destination metadata %s=%q", name, v)
}
}
if info.Size != int64(len(raw)) {
t.Errorf("replica size %d, want the source ciphertext size %d", info.Size, len(raw))
}
getReq, err := newTestSignedRequestV4(http.MethodGet, getGetObjectURL("", bucketName, object),
0, nil, credentials.AccessKey, credentials.SecretKey, sseHeaders)
if err != nil {
t.Fatal(err)
}
getRec := httptest.NewRecorder()
apiRouter.ServeHTTP(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("GET replica status %d: %s", getRec.Code, getRec.Body.String())
}
if !bytes.Equal(getRec.Body.Bytes(), data) {
t.Fatalf("replica did not decrypt to the source plaintext (got %d bytes, want %d)",
getRec.Body.Len(), len(data))
}
})
}
// The multipart replica path shares the same two decisions at
// NewMultipartUpload. Every later part follows the upload's stored
// metadata, so asserting on the init is enough.
for _, tc := range []struct {
name string
setup func(*testing.T) func()
}{
{name: "mpu-default-sse-s3", setup: withDefaultSSE},
{name: "mpu-compression", setup: withCompression},
} {
t.Run(instanceType+"/"+tc.name, func(t *testing.T) {
object := "replication/ssec-" + tc.name + ".txt"
_, replicationHeaders, sourceInfo := makeSource(t, object)
cleanup := tc.setup(t)
defer cleanup()
req, err := newTestSignedRequestV4(http.MethodPost, getNewMultipartURL("", bucketName, object),
0, nil, replicator.AccessKey, replicator.SecretKey, replicationHeaders)
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
apiRouter.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("replica NewMultipart status %d: %s", rec.Code, rec.Body.String())
}
var init InitiateMultipartUploadResponse
if err = xmlDecoder(rec.Body, &init, int64(rec.Body.Len())); err != nil {
t.Fatal(err)
}
mi, err := obj.GetMultipartInfo(t.Context(), bucketName, object, init.UploadID, ObjectOptions{})
if err != nil {
t.Fatal(err)
}
for _, name := range []string{crypto.MetaSealedKeySSEC, crypto.MetaIV, crypto.MetaAlgorithm} {
if got, want := mi.UserDefined[name], sourceInfo.UserDefined[name]; got != want {
t.Errorf("%s: replica upload has %q, source has %q", name, got, want)
}
}
for _, name := range []string{crypto.MetaSealedKeyS3, crypto.MetaKeyID, ReservedMetadataPrefix + "compression"} {
if v, ok := mi.UserDefined[name]; ok {
t.Errorf("replica upload gained destination metadata %s=%q", name, v)
}
}
})
}
// Control: the same seal headers from a principal without
// s3:ReplicateObject must not buy the exemption. The headers are stripped
// and the object is transformed exactly as an ordinary upload would be.
t.Run(instanceType+"/untrusted-is-still-transformed", func(t *testing.T) {
object := "replication/ssec-untrusted.txt"
raw, replicationHeaders, _ := makeSource(t, object)
cleanup := withCompression(t)
defer cleanup()
untrusted := make(map[string]string, len(replicationHeaders))
for k, v := range replicationHeaders {
untrusted[k] = v
}
delete(untrusted, xhttp.AmzBucketReplicationStatus)
rec := putReplica(t, object, raw, putOnly, untrusted)
if rec.Code != http.StatusOK {
t.Fatalf("untrusted PUT status %d: %s", rec.Code, rec.Body.String())
}
info, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{})
if err != nil {
t.Fatal(err)
}
if _, ok := info.UserDefined[crypto.MetaSealedKeySSEC]; ok {
t.Error("untrusted writer smuggled an SSE-C seal into stored metadata")
}
if _, ok := info.UserDefined[ReservedMetadataPrefix+"compression"]; !ok {
t.Error("untrusted upload skipped destination compression")
}
})
}
// TestAPISSECMultipartReplicaRoundTripWithCompression drives a full multipart
// replica: a real SSE-C multipart source, the replica upload, its parts and
// Complete, then a GET with the customer key, all with destination compression
// enabled. Asserting on the init metadata alone would not prove that the parts
// were stored unmodified.
func TestAPISSECMultipartReplicaRoundTripWithCompression(t *testing.T) {
defer DetectTestLeak(t)()
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
t: t,
objAPITest: testAPISSECMultipartReplicaRoundTripWithCompression,
})
}
func testAPISSECMultipartReplicaRoundTripWithCompression(obj ObjectLayer, instanceType, bucketName string,
apiRouter http.Handler, credentials auth.Credentials, t *testing.T,
) {
previousTLS := globalIsTLS
globalIsTLS = true
defer func() { globalIsTLS = previousTLS }()
replicator := newObjectAttributesAuthzUser(t, instanceType, bucketName, `"s3:PutObject","s3:GetObject","s3:ReplicateObject"`)
key := bytes.Repeat([]byte{0x37}, 32)
keyMD5 := md5.Sum(key)
sseHeaders := map[string]string{
xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES,
xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(key),
xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(keyMD5[:]),
}
data := bytes.Repeat([]byte("silo ssec multipart replica payload "), 4096)
object := "replication/ssec-multipart-roundtrip.txt"
// Source: a real SSE-C multipart object, so the part layout and metadata
// are what the replication worker actually reads.
newRec := httptest.NewRecorder()
newReq, err := newTestSignedRequestV4(http.MethodPost, getNewMultipartURL("", bucketName, object),
0, nil, credentials.AccessKey, credentials.SecretKey, sseHeaders)
if err != nil {
t.Fatal(err)
}
apiRouter.ServeHTTP(newRec, newReq)
if newRec.Code != http.StatusOK {
t.Fatalf("source NewMultipart status %d: %s", newRec.Code, newRec.Body.String())
}
var sourceInit InitiateMultipartUploadResponse
if err = xmlDecoder(newRec.Body, &sourceInit, int64(newRec.Body.Len())); err != nil {
t.Fatal(err)
}
partReq, err := newTestSignedRequestV4(http.MethodPut,
getPutObjectPartURL("", bucketName, object, sourceInit.UploadID, "1"),
int64(len(data)), bytes.NewReader(data), credentials.AccessKey, credentials.SecretKey, sseHeaders)
if err != nil {
t.Fatal(err)
}
partRec := httptest.NewRecorder()
apiRouter.ServeHTTP(partRec, partReq)
if partRec.Code != http.StatusOK {
t.Fatalf("source PutPart status %d: %s", partRec.Code, partRec.Body.String())
}
completeBody, err := xml.Marshal(CompleteMultipartUpload{Parts: []CompletePart{
{PartNumber: 1, ETag: canonicalizeETag(partRec.Header()[xhttp.ETag][0])},
}})
if err != nil {
t.Fatal(err)
}
completeReq, err := newTestSignedRequestV4(http.MethodPost,
getCompleteMultipartUploadURL("", bucketName, object, sourceInit.UploadID),
int64(len(completeBody)), bytes.NewReader(completeBody), credentials.AccessKey, credentials.SecretKey, sseHeaders)
if err != nil {
t.Fatal(err)
}
completeRec := httptest.NewRecorder()
apiRouter.ServeHTTP(completeRec, completeReq)
if completeRec.Code != http.StatusOK {
t.Fatalf("source Complete status %d: %s", completeRec.Code, completeRec.Body.String())
}
gr, err := obj.GetObjectNInfo(t.Context(), bucketName, object, nil, http.Header{}, ObjectOptions{ReplicationRequest: true})
if err != nil {
t.Fatal(err)
}
sourceInfo := gr.ObjInfo
rawPart, err := io.ReadAll(gr)
gr.Close()
if err != nil {
t.Fatal(err)
}
replicationOpts, isMP, err := putReplicationOpts(t.Context(), "", sourceInfo)
if err != nil {
t.Fatal(err)
}
if !isMP {
t.Fatal("SSE-C multipart source was not recognized as multipart")
}
replicationOpts.Internal.SourceMTime = time.Time{}
replicationHeaders := make(map[string]string)
for name, values := range replicationOpts.Header() {
if len(values) > 0 {
replicationHeaders[name] = values[0]
}
}
// Destination compression on for the whole replica write.
globalCompressConfigMu.Lock()
previousCompress := globalCompressConfig
globalCompressConfig.Enabled = true
globalCompressConfig.Extensions = []string{".txt"}
globalCompressConfig.MimeTypes = nil
globalCompressConfig.AllowEncrypted = false
globalCompressConfigMu.Unlock()
defer func() {
globalCompressConfigMu.Lock()
globalCompressConfig = previousCompress
globalCompressConfigMu.Unlock()
}()
replNewReq, err := newTestSignedRequestV4(http.MethodPost, getNewMultipartURL("", bucketName, object),
0, nil, replicator.AccessKey, replicator.SecretKey, replicationHeaders)
if err != nil {
t.Fatal(err)
}
replNewRec := httptest.NewRecorder()
apiRouter.ServeHTTP(replNewRec, replNewReq)
if replNewRec.Code != http.StatusOK {
t.Fatalf("replica NewMultipart status %d: %s", replNewRec.Code, replNewRec.Body.String())
}
var replicaInit InitiateMultipartUploadResponse
if err = xmlDecoder(replNewRec.Body, &replicaInit, int64(replNewRec.Body.Len())); err != nil {
t.Fatal(err)
}
mi, err := obj.GetMultipartInfo(t.Context(), bucketName, object, replicaInit.UploadID, ObjectOptions{})
if err != nil {
t.Fatal(err)
}
if _, ok := mi.UserDefined[ReservedMetadataPrefix+"compression"]; ok {
t.Fatal("replica upload was marked compressed")
}
if _, ok := mi.UserDefined[ReservedMetadataPrefix+"Encrypted-Multipart"]; !ok {
t.Error("replica upload lost the encrypted-multipart marker")
}
replPartReq, err := newTestSignedRequestV4(http.MethodPut,
getPutObjectPartURL("", bucketName, object, replicaInit.UploadID, "1"),
int64(len(rawPart)), bytes.NewReader(rawPart), replicator.AccessKey, replicator.SecretKey,
map[string]string{xhttp.MinIOSourceReplicationRequest: "true"})
if err != nil {
t.Fatal(err)
}
replPartRec := httptest.NewRecorder()
apiRouter.ServeHTTP(replPartRec, replPartReq)
if replPartRec.Code != http.StatusOK {
t.Fatalf("replica PutPart status %d: %s", replPartRec.Code, replPartRec.Body.String())
}
replCompleteBody, err := xml.Marshal(CompleteMultipartUpload{Parts: []CompletePart{
{PartNumber: 1, ETag: canonicalizeETag(replPartRec.Header()[xhttp.ETag][0])},
}})
if err != nil {
t.Fatal(err)
}
actualSize, err := sourceInfo.GetActualSize()
if err != nil {
t.Fatal(err)
}
replCompleteReq, err := newTestSignedRequestV4(http.MethodPost,
getCompleteMultipartUploadURL("", bucketName, object, replicaInit.UploadID),
int64(len(replCompleteBody)), bytes.NewReader(replCompleteBody), replicator.AccessKey, replicator.SecretKey,
map[string]string{
xhttp.MinIOSourceReplicationRequest: "true",
xhttp.MinIOSourceMTime: sourceInfo.ModTime.Format(time.RFC3339Nano),
xhttp.MinIOSourceETag: sourceInfo.ETag,
xhttp.MinIOReplicationActualObjectSize: strconv.FormatInt(actualSize, 10),
})
if err != nil {
t.Fatal(err)
}
replCompleteRec := httptest.NewRecorder()
apiRouter.ServeHTTP(replCompleteRec, replCompleteReq)
if replCompleteRec.Code != http.StatusOK {
t.Fatalf("replica Complete status %d: %s", replCompleteRec.Code, replCompleteRec.Body.String())
}
info, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{})
if err != nil {
t.Fatal(err)
}
if v, ok := info.UserDefined[ReservedMetadataPrefix+"compression"]; ok {
t.Errorf("replica gained destination compression %q", v)
}
getReq, err := newTestSignedRequestV4(http.MethodGet, getGetObjectURL("", bucketName, object),
0, nil, credentials.AccessKey, credentials.SecretKey, sseHeaders)
if err != nil {
t.Fatal(err)
}
getRec := httptest.NewRecorder()
apiRouter.ServeHTTP(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("GET replicated multipart object status %d: %s", getRec.Code, getRec.Body.String())
}
if !bytes.Equal(getRec.Body.Bytes(), data) {
t.Fatalf("replicated SSE-C multipart object did not decrypt to source plaintext (got %d bytes, want %d)",
getRec.Body.Len(), len(data))
}
}
// TestPutReplicationOptsRejectsCompressedSSEC covers the source-side guard: a
// compressed SSE-C object cannot be represented on the replication wire, so the
// shared option builder must refuse it rather than emit a replica that decrypts
// to an S2 stream. Uncompressed SSE-C and compressed plaintext stay accepted.
func TestPutReplicationOptsRejectsCompressedSSEC(t *testing.T) {
sealed := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x11}, 64))
iv := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x22}, 32))
newInfo := func(ssec, compressed bool) ObjectInfo {
oi := ObjectInfo{
Bucket: "src",
Name: "obj.txt",
VersionID: "v1",
Size: 1024,
ContentType: "text/plain",
UserDefined: map[string]string{},
}
if ssec {
oi.UserDefined[crypto.MetaSealedKeySSEC] = sealed
oi.UserDefined[crypto.MetaIV] = iv
oi.UserDefined[crypto.MetaAlgorithm] = "DAREv2-HMAC-SHA256"
}
if compressed {
oi.UserDefined[ReservedMetadataPrefix+"compression"] = compressionAlgorithmV2
oi.UserDefined[ReservedMetadataPrefix+"actual-size"] = "2048"
}
return oi
}
for _, tc := range []struct {
name string
ssec bool
compressed bool
wantErr bool
}{
{name: "compressed-ssec-rejected", ssec: true, compressed: true, wantErr: true},
{name: "plain-ssec-accepted", ssec: true},
{name: "compressed-plaintext-accepted", compressed: true},
{name: "plain-accepted"},
} {
t.Run(tc.name, func(t *testing.T) {
oi := newInfo(tc.ssec, tc.compressed)
if _, _, err := putReplicationOpts(t.Context(), "", oi); (err != nil) != tc.wantErr {
t.Fatalf("putReplicationOpts err=%v, wantErr=%v", err, tc.wantErr)
}
// The batch path shares the same builder and propagates its error.
if _, _, err := batchReplicationOpts(t.Context(), "", oi); (err != nil) != tc.wantErr {
t.Fatalf("batchReplicationOpts err=%v, wantErr=%v", err, tc.wantErr)
}
})
}
}
+25
View File
@@ -111,6 +111,31 @@ var replicationRequestHeaders = []string{
xhttp.AmzBucketReplicationStatus,
}
// ssecReplicaSealHeaders are the internal headers a source site attaches to a
// raw SSE-C replica write. Their presence means the body is source ciphertext
// that the destination can neither decrypt nor re-frame.
var ssecReplicaSealHeaders = []string{
"X-Minio-Replication-Server-Side-Encryption-Seal-Algorithm",
"X-Minio-Replication-Server-Side-Encryption-Sealed-Key",
"X-Minio-Replication-Server-Side-Encryption-Iv",
}
// isRawSSECReplica reports whether a request is a replica write carrying a
// source SSE-C seal. replicaTrusted must be the value evaluateReplicationTrust
// returned for this request: the headers alone are client controlled and are
// never a trust signal on their own.
func isRawSSECReplica(h http.Header, replicaTrusted bool) bool {
if !replicaTrusted {
return false
}
for _, name := range ssecReplicaSealHeaders {
if h.Get(name) != "" {
return true
}
}
return false
}
func stripReplicationRequestHeaders(h http.Header) {
for _, name := range replicationRequestHeaders {
h.Del(name)