mirror of
https://github.com/pgsty/minio.git
synced 2026-11-11 18:15:29 +03:00
fix(security): close release blockers
Signed-off-by: Feng Ruohang <rh@vonng.com>
This commit is contained in:
+6
-10
@@ -478,15 +478,11 @@ func (api objectAPIHandlers) DeleteMultipleObjectsHandler(w http.ResponseWriter,
|
|||||||
getObjectInfoFn := objectAPI.GetObjectInfo
|
getObjectInfoFn := objectAPI.GetObjectInfo
|
||||||
|
|
||||||
var (
|
var (
|
||||||
hasLockEnabled bool
|
|
||||||
dsc ReplicateDecision
|
dsc ReplicateDecision
|
||||||
goi ObjectInfo
|
goi ObjectInfo
|
||||||
gerr error
|
gerr error
|
||||||
)
|
)
|
||||||
replicateDeletes := hasReplicationRules(ctx, bucket, deleteObjectsReq.Objects)
|
replicateDeletes := hasReplicationRules(ctx, bucket, deleteObjectsReq.Objects)
|
||||||
if rcfg, _ := globalBucketObjectLockSys.Get(bucket); rcfg.LockEnabled {
|
|
||||||
hasLockEnabled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
type deleteResult struct {
|
type deleteResult struct {
|
||||||
delInfo DeletedObject
|
delInfo DeletedObject
|
||||||
@@ -537,10 +533,10 @@ func (api objectAPIHandlers) DeleteMultipleObjectsHandler(w http.ResponseWriter,
|
|||||||
VersionSuspended: vc.Suspended(),
|
VersionSuspended: vc.Suspended(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Physical version deletes in a locked bucket are checked by the
|
// Every physical version delete checks the version's own protection
|
||||||
// single-object path below, while its mutation lock is held. A lookup
|
// under its mutation lock. Bucket metadata may be stale or unavailable
|
||||||
// here could miss a later protection update or a protected pool copy.
|
// and cannot establish that a stored version is unprotected.
|
||||||
if hasLockEnabled && object.VersionID != "" {
|
if object.VersionID != "" {
|
||||||
if first, ok := objectsToDelete[object]; ok {
|
if first, ok := objectsToDelete[object]; ok {
|
||||||
duplicateOf[index] = first
|
duplicateOf[index] = first
|
||||||
} else {
|
} else {
|
||||||
@@ -605,7 +601,7 @@ func (api objectAPIHandlers) DeleteMultipleObjectsHandler(w http.ResponseWriter,
|
|||||||
bulk := make([]ObjectToDelete, 0, len(deleteList))
|
bulk := make([]ObjectToDelete, 0, len(deleteList))
|
||||||
bulkIndex := make([]int, 0, len(deleteList))
|
bulkIndex := make([]int, 0, len(deleteList))
|
||||||
for i, object := range deleteList {
|
for i, object := range deleteList {
|
||||||
if !hasLockEnabled || object.VersionID == "" {
|
if object.VersionID == "" {
|
||||||
bulk = append(bulk, object)
|
bulk = append(bulk, object)
|
||||||
bulkIndex = append(bulkIndex, i)
|
bulkIndex = append(bulkIndex, i)
|
||||||
continue
|
continue
|
||||||
@@ -663,7 +659,7 @@ func (api objectAPIHandlers) DeleteMultipleObjectsHandler(w http.ResponseWriter,
|
|||||||
// under the mutation lock, and a missing version has no stored identity.
|
// under the mutation lock, and a missing version has no stored identity.
|
||||||
dindex := objectsToDelete[deleteList[i]]
|
dindex := objectsToDelete[deleteList[i]]
|
||||||
if errs[i] == nil || isErrObjectNotFound(errs[i]) || isErrVersionNotFound(errs[i]) {
|
if errs[i] == nil || isErrObjectNotFound(errs[i]) || isErrVersionNotFound(errs[i]) {
|
||||||
if replicateDeletes && (!hasLockEnabled || deleteList[i].VersionID == "") {
|
if replicateDeletes && deleteList[i].VersionID == "" {
|
||||||
dObjects[i].ReplicationState = deleteList[i].ReplicationState()
|
dObjects[i].ReplicationState = deleteList[i].ReplicationState()
|
||||||
}
|
}
|
||||||
deleteResults[dindex].delInfo = dObjects[i]
|
deleteResults[dindex].delInfo = dObjects[i]
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// Copyright (c) 2026 Feng Ruohang
|
||||||
|
//
|
||||||
|
// This file is part of Silo 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.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/xml"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/minio/minio/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type metadataCacheLoadBarrier struct {
|
||||||
|
ObjectLayer
|
||||||
|
key string
|
||||||
|
read, resume chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *metadataCacheLoadBarrier) GetObjectNInfo(ctx context.Context, bucket, key string, rs *HTTPRangeSpec, h http.Header, opts ObjectOptions) (*GetObjectReader, error) {
|
||||||
|
r, err := b.ObjectLayer.GetObjectNInfo(ctx, bucket, key, rs, h, opts)
|
||||||
|
if bucket == minioMetaBucket && key == b.key {
|
||||||
|
b.once.Do(func() { close(b.read); <-b.resume })
|
||||||
|
}
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBucketMetadataLoadPreservesConcurrentPublication(t *testing.T) {
|
||||||
|
defer DetectTestLeak(t)()
|
||||||
|
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||||
|
t: t,
|
||||||
|
objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, _ auth.Credentials, t *testing.T) {
|
||||||
|
// Disk has the old unlocked config; a concurrent publication enables
|
||||||
|
// lock and versioning after the cache miss has started loading it.
|
||||||
|
enableBucketObjectLock(t, bucket)
|
||||||
|
newer, err := globalBucketMetadataSys.Get(bucket)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
globalBucketMetadataSys.Remove(bucket)
|
||||||
|
barrier := &metadataCacheLoadBarrier{ObjectLayer: obj, key: pathJoin(bucketMetaPrefix, bucket, bucketMetadataFile), read: make(chan struct{}), resume: make(chan struct{})}
|
||||||
|
previous := newObjectLayerFn()
|
||||||
|
setObjectLayer(barrier)
|
||||||
|
defer setObjectLayer(previous)
|
||||||
|
release := sync.OnceFunc(func() { close(barrier.resume) })
|
||||||
|
defer release()
|
||||||
|
type result struct {
|
||||||
|
meta BucketMetadata
|
||||||
|
reloaded bool
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
done := make(chan result, 1)
|
||||||
|
go func() {
|
||||||
|
meta, reloaded, err := globalBucketMetadataSys.GetConfig(t.Context(), bucket)
|
||||||
|
done <- result{meta, reloaded, err}
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-barrier.read:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("metadata load did not reach disk")
|
||||||
|
}
|
||||||
|
globalBucketMetadataSys.Set(bucket, newer)
|
||||||
|
release()
|
||||||
|
select {
|
||||||
|
case got := <-done:
|
||||||
|
if got.err != nil || got.reloaded || got.meta.objectLockConfig == nil || got.meta.objectLockConfig.ObjectLockEnabled != "Enabled" {
|
||||||
|
t.Fatalf("%s: stale load replaced the publication: %+v", backend, got)
|
||||||
|
}
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("metadata load did not finish")
|
||||||
|
}
|
||||||
|
cached, err := globalBucketMetadataSys.Get(bucket)
|
||||||
|
if err != nil || cached.objectLockConfig == nil || cached.objectLockConfig.ObjectLockEnabled != "Enabled" {
|
||||||
|
t.Fatalf("cached lock lost: %+v %v", cached, err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkDeleteVersionLockWithStaleBucketCache(t *testing.T) {
|
||||||
|
for _, pools := range []int{1, 2} {
|
||||||
|
z, _ := bulkDeletePools(t, pools)
|
||||||
|
bucket, router, err := initAPIHandlerTest(t.Context(), z, nil, MakeBucketOptions{LockEnabled: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, protection := range batchLockKinds {
|
||||||
|
oi := batchPutVersion(t, z, bucket, protection, batchLockMeta(protection))
|
||||||
|
saved, err := globalBucketMetadataSys.Get(bucket)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
globalBucketMetadataSys.Set(bucket, newBucketMetadata(bucket))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, bulkDeleteLockRequest(t, bucket, protection, oi.VersionID))
|
||||||
|
globalBucketMetadataSys.Set(bucket, saved)
|
||||||
|
var response DeleteObjectsResponse
|
||||||
|
if err := xml.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusOK || len(response.Errors) != 1 || len(response.DeletedObjects) != 0 || !batchVersionExists(t, z, bucket, protection, oi.VersionID) {
|
||||||
|
t.Fatalf("pools=%d %s: stale bucket cache bypassed version lock: %s", pools, protection, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -697,7 +697,7 @@ func (sys *BucketMetadataSys) GetConfig(ctx context.Context, bucket string) (met
|
|||||||
}
|
}
|
||||||
|
|
||||||
val, err, _ := sys.group.Do(bucket, func() (val any, err error) {
|
val, err, _ := sys.group.Do(bucket, func() (val any, err error) {
|
||||||
meta, err = loadBucketMetadata(ctx, objAPI, bucket)
|
meta, err := loadBucketMetadata(ctx, objAPI, bucket)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !sys.Initialized() {
|
if !sys.Initialized() {
|
||||||
// bucket metadata not yet initialized
|
// bucket metadata not yet initialized
|
||||||
@@ -707,13 +707,18 @@ func (sys *BucketMetadataSys) GetConfig(ctx context.Context, bucket string) (met
|
|||||||
return meta, err
|
return meta, err
|
||||||
})
|
})
|
||||||
meta, _ = val.(BucketMetadata)
|
meta, _ = val.(BucketMetadata)
|
||||||
|
sys.Lock()
|
||||||
|
defer sys.Unlock()
|
||||||
|
// A writer may have published a newer configuration while the disk read
|
||||||
|
// was in flight. Never replace that publication with the older load.
|
||||||
|
if current, ok := sys.metadataMap[bucket]; ok {
|
||||||
|
return current, false, nil
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return meta, false, err
|
return meta, false, err
|
||||||
}
|
}
|
||||||
sys.Lock()
|
|
||||||
sys.metadataMap[bucket] = meta
|
sys.metadataMap[bucket] = meta
|
||||||
sys.clearLoadFailure(bucket)
|
sys.clearLoadFailure(bucket)
|
||||||
sys.Unlock()
|
|
||||||
|
|
||||||
return meta, true, nil
|
return meta, true, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -491,6 +491,25 @@ func (s objectLockState) legalHoldIsOlderThan(src time.Time) bool {
|
|||||||
return olderThan(s.legalHoldTimestamp, src)
|
return olderThan(s.legalHoldTimestamp, src)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A peer's ordering timestamp cannot override an unexpired COMPLIANCE lock.
|
||||||
|
// Otherwise it could first remove retention with a same-content retransmit,
|
||||||
|
// then replace the now-unprotected version in a second request.
|
||||||
|
func (s objectLockState) complianceWouldWeaken(incoming objectLockState) bool {
|
||||||
|
if s.mode != string(objectlock.RetCompliance) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
until, err := time.Parse(time.RFC3339Nano, s.retainUntil)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
now, err := objectlock.UTCNowNTP()
|
||||||
|
if err == nil && !until.After(now) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
nextUntil, nextErr := time.Parse(time.RFC3339Nano, incoming.retainUntil)
|
||||||
|
return incoming.mode != string(objectlock.RetCompliance) || nextErr != nil || nextUntil.Before(until)
|
||||||
|
}
|
||||||
|
|
||||||
// restoreRetention and restoreLegalHold put the stored state back into
|
// restoreRetention and restoreLegalHold put the stored state back into
|
||||||
// metadata that was rebuilt from a request whose update was not applied.
|
// metadata that was rebuilt from a request whose update was not applied.
|
||||||
func (s objectLockState) restoreRetention(metadata map[string]string) {
|
func (s objectLockState) restoreRetention(metadata map[string]string) {
|
||||||
@@ -610,7 +629,7 @@ func reconcileStoredObjectLock(metadata map[string]string, stored objectLockStat
|
|||||||
incoming := storedObjectLockState(metadata)
|
incoming := storedObjectLockState(metadata)
|
||||||
|
|
||||||
incomingRetentionTS, _ := time.Parse(time.RFC3339Nano, incoming.retentionTimestamp)
|
incomingRetentionTS, _ := time.Parse(time.RFC3339Nano, incoming.retentionTimestamp)
|
||||||
if !stored.retentionIsOlderThan(incomingRetentionTS) {
|
if !stored.retentionIsOlderThan(incomingRetentionTS) || stored.complianceWouldWeaken(incoming) {
|
||||||
// The stored retention is at least as new as the incoming one (or the
|
// The stored retention is at least as new as the incoming one (or the
|
||||||
// incoming update is unordered): drop the incoming value and put the stored
|
// incoming update is unordered): drop the incoming value and put the stored
|
||||||
// state back, which may itself be a removal (value keys absent, timestamp
|
// state back, which may itself be a removal (value keys absent, timestamp
|
||||||
|
|||||||
@@ -1722,6 +1722,9 @@ func (er erasureObjects) CompleteMultipartUpload(ctx context.Context, bucket str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := er.checkReplicaContent(ctx, bucket, object, minioMetaMultipartBucket, uploadIDPath, partsMetadata, onlineDisks, opts); err != nil {
|
||||||
|
return oi, toObjectErr(err, bucket, object)
|
||||||
|
}
|
||||||
er.cleanupMultipartPath(ctx, paths...) // cleanup all part.N.meta, and skipped part.N's before final rename().
|
er.cleanupMultipartPath(ctx, paths...) // cleanup all part.N.meta, and skipped part.N's before final rename().
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|||||||
@@ -148,6 +148,17 @@ func (er erasureObjects) CopyObject(ctx context.Context, srcBucket, srcObject, d
|
|||||||
fi.IsLatest = true // we are creating a new version so this is latest.
|
fi.IsLatest = true // we are creating a new version so this is latest.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if srcInfo.versionOnly && (dstOpts.ReplicaLockReconcile || dstOpts.ReplicationRequest) && !dstOpts.DataMovement {
|
||||||
|
candidate := slices.Clone(metaArr)
|
||||||
|
for i := range candidate {
|
||||||
|
candidate[i].VersionID = versionID
|
||||||
|
candidate[i].Metadata = srcInfo.UserDefined
|
||||||
|
}
|
||||||
|
readDisks, readMeta := shuffleDisksAndPartsMetadataByIndex(onlineDisks, candidate, fi)
|
||||||
|
if err := er.checkReplicaContent(ctx, dstBucket, dstObject, srcBucket, srcObject, readMeta, readDisks, dstOpts); err != nil {
|
||||||
|
return oi, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !dstOpts.ReplicationRequest && !dstOpts.ReplicaLockReconcile && !dstOpts.DataMovement &&
|
if !dstOpts.ReplicationRequest && !dstOpts.ReplicaLockReconcile && !dstOpts.DataMovement &&
|
||||||
(versionID == "" || versionID == nullVersionID) {
|
(versionID == "" || versionID == nullVersionID) {
|
||||||
@@ -1722,6 +1733,9 @@ func (er erasureObjects) putObject(ctx context.Context, bucket string, object st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Rename the successfully written temporary object to final location.
|
// Rename the successfully written temporary object to final location.
|
||||||
|
if err := er.checkReplicaContent(ctx, bucket, object, minioMetaTmpBucket, tempObj, partsMetadata, onlineDisks, opts); err != nil {
|
||||||
|
return ObjectInfo{}, toObjectErr(err, bucket, object)
|
||||||
|
}
|
||||||
onlineDisks, versions, oldDataDir, err := renameData(ctx, onlineDisks, minioMetaTmpBucket, tempObj, partsMetadata, bucket, object, writeQuorum)
|
onlineDisks, versions, oldDataDir, err := renameData(ctx, onlineDisks, minioMetaTmpBucket, tempObj, partsMetadata, bucket, object, writeQuorum)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errFileNotFound) {
|
if errors.Is(err, errFileNotFound) {
|
||||||
|
|||||||
@@ -590,9 +590,9 @@ func TestSinglePoolConditionalPutHTTP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// An internal replica callback without an addressed version is not a public
|
// Replica overwrites must establish protection across every pool, including
|
||||||
// condition. Preserve its availability when another pool is unreadable. An
|
// the null version of an unversioned request. An unreadable pool may hold a
|
||||||
// addressed replica already requires all pools for lock/tag reconciliation.
|
// protected copy, so neither addressed nor unaddressed writes may replace it.
|
||||||
func TestPoolsConditionalPutReplicaAvailability(t *testing.T) {
|
func TestPoolsConditionalPutReplicaAvailability(t *testing.T) {
|
||||||
for _, addressed := range []bool{false, true} {
|
for _, addressed := range []bool{false, true} {
|
||||||
t.Run(fmt.Sprintf("addressed=%t", addressed), func(t *testing.T) {
|
t.Run(fmt.Sprintf("addressed=%t", addressed), func(t *testing.T) {
|
||||||
@@ -615,10 +615,9 @@ func TestPoolsConditionalPutReplicaAvailability(t *testing.T) {
|
|||||||
xhttp.MinIOSourceETag: fmt.Sprintf("%x", md5.Sum([]byte("replacement"))),
|
xhttp.MinIOSourceETag: fmt.Sprintf("%x", md5.Sum([]byte("replacement"))),
|
||||||
}
|
}
|
||||||
url := getPutObjectURL("", bucket, object)
|
url := getPutObjectURL("", bucket, object)
|
||||||
wantStatus, wantBody := http.StatusOK, "replacement"
|
wantStatus, wantBody := http.StatusServiceUnavailable, "old"
|
||||||
if addressed {
|
if addressed {
|
||||||
url += "?versionId=" + oi.VersionID
|
url += "?versionId=" + oi.VersionID
|
||||||
wantStatus, wantBody = http.StatusServiceUnavailable, "old"
|
|
||||||
}
|
}
|
||||||
put := multipartConditionRequest(t, router, http.MethodPut, url, "replacement", headers)
|
put := multipartConditionRequest(t, router, http.MethodPut, url, "replacement", headers)
|
||||||
if put.Code != wantStatus {
|
if put.Code != wantStatus {
|
||||||
|
|||||||
@@ -1195,6 +1195,7 @@ func (z *erasureServerPools) PutObject(ctx context.Context, bucket string, objec
|
|||||||
if opts.replicaObjectInfo == nil && (opts.ReplicaLockReconcile || !opts.ReplicationRequest) {
|
if opts.replicaObjectInfo == nil && (opts.ReplicaLockReconcile || !opts.ReplicationRequest) {
|
||||||
opts.replicaObjectInfo = z.replicaObjectInfo
|
opts.replicaObjectInfo = z.replicaObjectInfo
|
||||||
}
|
}
|
||||||
|
opts.replicaContentGuard = z.guardReplicaContent
|
||||||
oi, err := z.serverPools[idx].PutObject(ctx, bucket, object, data, opts)
|
oi, err := z.serverPools[idx].PutObject(ctx, bucket, object, data, opts)
|
||||||
if err == nil && opts.ReplicaLockReconcile && !opts.DataMovement {
|
if err == nil && opts.ReplicaLockReconcile && !opts.DataMovement {
|
||||||
err = z.retireReplicaCopies(ctx, bucket, object, idx, oi)
|
err = z.retireReplicaCopies(ctx, bucket, object, idx, oi)
|
||||||
@@ -1749,6 +1750,9 @@ func (z *erasureServerPools) CopyObject(ctx context.Context, srcBucket, srcObjec
|
|||||||
return objInfo, err
|
return objInfo, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !z.SinglePool() {
|
||||||
|
dstOpts.replicaContentGuard = z.guardReplicaContent
|
||||||
|
}
|
||||||
if !z.SinglePool() && (dstOpts.ReplicaLockReconcile || !dstOpts.ReplicationRequest) {
|
if !z.SinglePool() && (dstOpts.ReplicaLockReconcile || !dstOpts.ReplicationRequest) {
|
||||||
dstOpts.replicaObjectInfo = z.replicaObjectInfo
|
dstOpts.replicaObjectInfo = z.replicaObjectInfo
|
||||||
if dstOpts.ReplicaLockReconcile && !dstOpts.DataMovement {
|
if dstOpts.ReplicaLockReconcile && !dstOpts.DataMovement {
|
||||||
@@ -1799,6 +1803,7 @@ func (z *erasureServerPools) CopyObject(ctx context.Context, srcBucket, srcObjec
|
|||||||
ReplicaLockReconcile: dstOpts.ReplicaLockReconcile,
|
ReplicaLockReconcile: dstOpts.ReplicaLockReconcile,
|
||||||
DataMovement: dstOpts.DataMovement,
|
DataMovement: dstOpts.DataMovement,
|
||||||
replicaObjectInfo: dstOpts.replicaObjectInfo,
|
replicaObjectInfo: dstOpts.replicaObjectInfo,
|
||||||
|
replicaContentGuard: dstOpts.replicaContentGuard,
|
||||||
}
|
}
|
||||||
|
|
||||||
return z.serverPools[poolIdx].PutObject(ctx, dstBucket, dstObject, srcInfo.PutObjReader, putOpts)
|
return z.serverPools[poolIdx].PutObject(ctx, dstBucket, dstObject, srcInfo.PutObjReader, putOpts)
|
||||||
@@ -2524,6 +2529,7 @@ func (z *erasureServerPools) CompleteMultipartUpload(ctx context.Context, bucket
|
|||||||
opts.replicaObjectInfo = z.replicaObjectInfo
|
opts.replicaObjectInfo = z.replicaObjectInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
opts.replicaContentGuard = z.guardReplicaContent
|
||||||
for idx, pool := range z.serverPools {
|
for idx, pool := range z.serverPools {
|
||||||
if z.IsSuspended(idx) {
|
if z.IsSuspended(idx) {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ type ObjectOptions struct {
|
|||||||
|
|
||||||
// Pools-layer version resolver; the caller holds the shared object lock.
|
// Pools-layer version resolver; the caller holds the shared object lock.
|
||||||
replicaObjectInfo GetObjectInfoFn
|
replicaObjectInfo GetObjectInfoFn
|
||||||
|
// Verifies protected content in every pool before a replica commits.
|
||||||
|
replicaContentGuard replicaContentGuardFn
|
||||||
|
|
||||||
// RefuseLockedOverwrite refuses, under the destination's write lock, a
|
// RefuseLockedOverwrite refuses, under the destination's write lock, a
|
||||||
// write that would replace a stored version Object Lock protects: the
|
// write that would replace a stored version Object Lock protects: the
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
// Copyright (c) 2026 Feng Ruohang
|
||||||
|
//
|
||||||
|
// This file is part of Silo 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.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/minio/minio/internal/crypto"
|
||||||
|
"github.com/minio/minio/internal/hash/sha256"
|
||||||
|
xioutil "github.com/minio/minio/internal/ioutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
type replicaContentGuardFn func(context.Context, string, string, ObjectInfo, func() (*GetObjectReader, error)) error
|
||||||
|
|
||||||
|
// verifyLockedReplica compares the actual data, never a sender's ETag or
|
||||||
|
// checksum assertion. A peer may retransmit a protected version to update its
|
||||||
|
// metadata, but may not replace its content. Call under the object write lock.
|
||||||
|
func verifyLockedReplica(ctx context.Context, incoming, stored ObjectInfo, openIncoming, openStored func() (*GetObjectReader, error)) error {
|
||||||
|
if !enforceRetentionForDeletion(ctx, stored) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
locked := ObjectLocked{Bucket: stored.Bucket, Object: stored.Name, VersionID: stored.VersionID}
|
||||||
|
if storedObjectLockState(stored.UserDefined).complianceWouldWeaken(storedObjectLockState(incoming.UserDefined)) {
|
||||||
|
return locked
|
||||||
|
}
|
||||||
|
inSSEC, oldSSEC := crypto.SSEC.IsEncrypted(incoming.UserDefined), crypto.SSEC.IsEncrypted(stored.UserDefined)
|
||||||
|
if inSSEC != oldSSEC {
|
||||||
|
return locked
|
||||||
|
}
|
||||||
|
if inSSEC {
|
||||||
|
inSize, inErr := incoming.GetActualSize()
|
||||||
|
oldSize, oldErr := stored.GetActualSize()
|
||||||
|
if inErr != nil || oldErr != nil || inSize != oldSize {
|
||||||
|
return locked
|
||||||
|
}
|
||||||
|
// Without the customer key, compare ciphertext and its interpretation.
|
||||||
|
// Equal ciphertext with a changed seal or multipart framing is not the
|
||||||
|
// same object, even if the supplied ETag and size are unchanged.
|
||||||
|
for _, key := range []string{
|
||||||
|
crypto.MetaIV, crypto.MetaAlgorithm, crypto.MetaSealedKeySSEC,
|
||||||
|
ReservedMetadataPrefix + "compression",
|
||||||
|
} {
|
||||||
|
if incoming.UserDefined[key] != stored.UserDefined[key] {
|
||||||
|
return locked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if crypto.IsMultiPart(incoming.UserDefined) != crypto.IsMultiPart(stored.UserDefined) || incoming.isMultipart() != stored.isMultipart() {
|
||||||
|
return locked
|
||||||
|
}
|
||||||
|
if crypto.IsMultiPart(incoming.UserDefined) {
|
||||||
|
if len(incoming.Parts) != len(stored.Parts) {
|
||||||
|
return locked
|
||||||
|
}
|
||||||
|
for i, p := range incoming.Parts {
|
||||||
|
old := stored.Parts[i]
|
||||||
|
if p.Number != old.Number || p.Size != old.Size {
|
||||||
|
return locked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
in, err := openIncoming()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
old, err := openStored()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer old.Close()
|
||||||
|
ih, oh := sha256.New(), sha256.New()
|
||||||
|
inSize, err := io.Copy(ih, in)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
oldSize, err := io.Copy(oh, old)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if inSize != oldSize || !bytes.Equal(ih.Sum(nil), oh.Sum(nil)) {
|
||||||
|
return locked
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (er erasureObjects) guardReplicaContent(ctx context.Context, bucket, object string, incoming ObjectInfo, openIncoming func() (*GetObjectReader, error)) error {
|
||||||
|
opts := ObjectOptions{VersionID: incoming.VersionID, NoLock: true, NoAuditLog: true, ReplicationRequest: true}
|
||||||
|
if opts.VersionID == "" {
|
||||||
|
opts.VersionID = nullVersionID
|
||||||
|
}
|
||||||
|
stored, err := er.getObjectInfo(ctx, bucket, object, opts)
|
||||||
|
if isErrObjectNotFound(err) || isErrVersionNotFound(err) || isErrMethodNotAllowed(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return verifyLockedReplica(ctx, incoming, stored, openIncoming, func() (*GetObjectReader, error) {
|
||||||
|
return er.GetObjectNInfo(ctx, bucket, object, nil, http.Header{}, opts)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (z *erasureServerPools) guardReplicaContent(ctx context.Context, bucket, object string, incoming ObjectInfo, openIncoming func() (*GetObjectReader, error)) error {
|
||||||
|
opts := ObjectOptions{VersionID: incoming.VersionID, NoLock: true, NoAuditLog: true, ReplicationRequest: true}
|
||||||
|
if opts.VersionID == "" {
|
||||||
|
opts.VersionID = nullVersionID
|
||||||
|
}
|
||||||
|
copies, err := z.objectPoolInfos(ctx, bucket, object, opts)
|
||||||
|
if isErrObjectNotFound(err) || isErrVersionNotFound(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, copy := range copies {
|
||||||
|
if err := verifyLockedReplica(ctx, incoming, copy.ObjInfo, openIncoming, func() (*GetObjectReader, error) {
|
||||||
|
return z.serverPools[copy.Index].GetObjectNInfo(ctx, bucket, object, nil, http.Header{}, opts)
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkReplicaContent reads the staged version before rename commits it. Its
|
||||||
|
// data is decoded/decrypted just like a GET (raw ciphertext for SSE-C), so this
|
||||||
|
// covers multipart, compression and destination SSE-S3/KMS re-encryption.
|
||||||
|
// disks and metadata are in erasure shard order, as passed to renameData.
|
||||||
|
func (er erasureObjects) checkReplicaContent(ctx context.Context, bucket, object, stageBucket, stageObject string, metadata []FileInfo, disks []StorageAPI, opts ObjectOptions) error {
|
||||||
|
if (!opts.ReplicaLockReconcile && !opts.ReplicationRequest) || opts.DataMovement {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var fi FileInfo
|
||||||
|
for i, disk := range disks {
|
||||||
|
if disk != nil {
|
||||||
|
fi = metadata[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
incoming := fi.ToObjectInfo(bucket, object, opts.Versioned || opts.VersionSuspended)
|
||||||
|
guard := opts.replicaContentGuard
|
||||||
|
if guard == nil {
|
||||||
|
guard = er.guardReplicaContent
|
||||||
|
}
|
||||||
|
return guard(ctx, bucket, object, incoming, func() (*GetObjectReader, error) {
|
||||||
|
// A marker-only peer write does not reconcile the stored protection.
|
||||||
|
// It therefore cannot replace a protected version, even with equal data.
|
||||||
|
if !opts.ReplicaLockReconcile {
|
||||||
|
return nil, ObjectLocked{Bucket: bucket, Object: decodeDirObject(object), VersionID: incoming.VersionID}
|
||||||
|
}
|
||||||
|
if incoming.IsCompressed() {
|
||||||
|
// A replica may assert the source's logical size. A compressed GET
|
||||||
|
// limits its output to that size, so verify it against the lengths
|
||||||
|
// recorded while consuming the uploaded parts before comparing GETs.
|
||||||
|
// Otherwise a forged zero size could hide a non-empty replacement.
|
||||||
|
var actual int64
|
||||||
|
for _, part := range incoming.Parts {
|
||||||
|
actual += part.ActualSize
|
||||||
|
}
|
||||||
|
declared, err := incoming.GetActualSize()
|
||||||
|
if err != nil || declared != actual {
|
||||||
|
return nil, ObjectLocked{Bucket: bucket, Object: decodeDirObject(object), VersionID: incoming.VersionID}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
readOpts := ObjectOptions{NoDecryption: crypto.SSEC.IsEncrypted(incoming.UserDefined)}
|
||||||
|
fn, off, length, err := NewGetObjectReader(nil, incoming, readOpts, http.Header{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// The ordinary reader expects original drive order, not shard order.
|
||||||
|
readDisks, readMeta := make([]StorageAPI, len(disks)), make([]FileInfo, len(metadata))
|
||||||
|
for i, shard := range fi.Erasure.Distribution {
|
||||||
|
readDisks[i], readMeta[i] = disks[shard-1], metadata[shard-1]
|
||||||
|
readMeta[i].Erasure.Index = shard
|
||||||
|
}
|
||||||
|
pr, pw := xioutil.WaitPipe()
|
||||||
|
go func() {
|
||||||
|
pw.CloseWithError(er.getObjectWithFileInfo(ctx, stageBucket, stageObject, off, length, pw, fi, readMeta, readDisks))
|
||||||
|
}()
|
||||||
|
reader, err := fn(pr, http.Header{}, func() { pr.CloseWithError(nil) })
|
||||||
|
if err != nil {
|
||||||
|
pr.CloseWithError(err)
|
||||||
|
}
|
||||||
|
return reader, err
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
// Copyright (c) 2026 Feng Ruohang
|
||||||
|
//
|
||||||
|
// This file is part of Silo 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.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAPIReplicaPreservesProtectedContent(t *testing.T) {
|
||||||
|
defer DetectTestLeak(t)()
|
||||||
|
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||||
|
t: t, makeBucketOptions: MakeBucketOptions{LockEnabled: true},
|
||||||
|
objAPITest: func(obj ObjectLayer, backend, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||||
|
oldTLS, oldKMS := globalIsTLS, GlobalKMS
|
||||||
|
globalIsTLS, GlobalKMS = true, kms.NewStub("replica-content")
|
||||||
|
defer func() { globalIsTLS, GlobalKMS = oldTLS, oldKMS }()
|
||||||
|
restoreCompression := disableCompression()
|
||||||
|
defer restoreCompression()
|
||||||
|
request := func(method, url string, data []byte, headers map[string]string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req, err := newTestSignedRequestV4(method, url, int64(len(data)), bytes.NewReader(data), cred.AccessKey, cred.SecretKey, headers)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
for _, encryption := range []string{"plain", "sse-s3", "sse-kms", "sse-c", "plain-compressed", "sse-s3-compressed"} {
|
||||||
|
restore := func() {}
|
||||||
|
if strings.HasSuffix(encryption, "-compressed") {
|
||||||
|
restore = setCopyChecksumCompression(true)
|
||||||
|
}
|
||||||
|
for _, multipart := range []bool{false, true} {
|
||||||
|
for _, protection := range []string{"COMPLIANCE", "LEGALHOLD", "GOVERNANCE"} {
|
||||||
|
key := fmt.Sprintf("protected/%s/%v/%s.txt", encryption, multipart, protection)
|
||||||
|
enc := map[string]string{}
|
||||||
|
switch encryption {
|
||||||
|
case "sse-s3", "sse-s3-compressed":
|
||||||
|
enc[xhttp.AmzServerSideEncryption] = xhttp.AmzEncryptionAES
|
||||||
|
case "sse-kms":
|
||||||
|
enc[xhttp.AmzServerSideEncryption] = xhttp.AmzEncryptionKMS
|
||||||
|
enc[xhttp.AmzServerSideEncryptionKmsID] = "replica-content"
|
||||||
|
case "sse-c":
|
||||||
|
enc = ssecKeyHeaders(bytes.Repeat([]byte{0x37}, 32), false)
|
||||||
|
}
|
||||||
|
headers := maps.Clone(enc)
|
||||||
|
maps.Copy(headers, batchLockMeta(protection))
|
||||||
|
// Both inline and disk-backed writes are exercised.
|
||||||
|
body := bytes.Repeat([]byte("immutable content "), 20000)
|
||||||
|
switch protection {
|
||||||
|
case "LEGALHOLD":
|
||||||
|
body = body[:64]
|
||||||
|
case "GOVERNANCE":
|
||||||
|
body = nil
|
||||||
|
}
|
||||||
|
if !multipart {
|
||||||
|
putCopyChecksumSource(t, router, cred, bucket, key, body, headers)
|
||||||
|
} else {
|
||||||
|
initRec := request(http.MethodPost, getNewMultipartURL("", bucket, key), nil, headers)
|
||||||
|
if initRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("source initiate: %d %s", initRec.Code, initRec.Body.String())
|
||||||
|
}
|
||||||
|
var init InitiateMultipartUploadResponse
|
||||||
|
if err := xml.Unmarshal(initRec.Body.Bytes(), &init); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
part := request(http.MethodPut, getPutObjectPartURL("", bucket, key, init.UploadID, "1"), body, enc)
|
||||||
|
if part.Code != http.StatusOK {
|
||||||
|
t.Fatalf("source part: %d %s", part.Code, part.Body.String())
|
||||||
|
}
|
||||||
|
complete, err := xml.Marshal(CompleteMultipartUpload{Parts: []CompletePart{{PartNumber: 1, ETag: canonicalizeETag(part.Header()[xhttp.ETag][0])}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := request(http.MethodPost, getCompleteMultipartUploadURL("", bucket, key, init.UploadID), complete, enc)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("source complete: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gr, err := obj.GetObjectNInfo(t.Context(), bucket, key, nil, http.Header{}, ObjectOptions{ReplicationRequest: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
src := gr.ObjInfo
|
||||||
|
wire, err := io.ReadAll(gr)
|
||||||
|
gr.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, altered := range []bool{true, false} {
|
||||||
|
data := bytes.Clone(wire)
|
||||||
|
if altered {
|
||||||
|
if len(data) == 0 {
|
||||||
|
data = []byte("x")
|
||||||
|
} else {
|
||||||
|
data[len(data)-1] ^= 0x20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
opts, _, err := putReplicationOpts(t.Context(), "", src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hdrs := map[string]string{}
|
||||||
|
for k, vs := range opts.Header() {
|
||||||
|
if len(vs) > 0 {
|
||||||
|
hdrs[k] = vs[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hdrs[xhttp.MinIOSourceReplicationRequest] = "true"
|
||||||
|
hdrs[xhttp.AmzBucketReplicationStatus] = "REPLICA"
|
||||||
|
// Forge the old ETag and size along with a newer tag revision:
|
||||||
|
// the body comparison must still reject different bytes.
|
||||||
|
hdrs[xhttp.MinIOSourceETag] = src.ETag
|
||||||
|
hdrs[xhttp.MinIOSourceTaggingTimestamp] = UTCNow().Add(time.Minute).Format(time.RFC3339Nano)
|
||||||
|
hdrs[xhttp.AmzObjectTagging] = "replica=updated"
|
||||||
|
var rec *httptest.ResponseRecorder
|
||||||
|
if !multipart {
|
||||||
|
rec = request(http.MethodPut, getPutObjectURL("", bucket, key)+"?versionId="+src.VersionID, data, hdrs)
|
||||||
|
} else {
|
||||||
|
initRec := request(http.MethodPost, getNewMultipartURL("", bucket, key)+"&versionId="+src.VersionID, nil, hdrs)
|
||||||
|
if initRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("%s %s initiate: %d %s", backend, key, initRec.Code, initRec.Body.String())
|
||||||
|
}
|
||||||
|
var init InitiateMultipartUploadResponse
|
||||||
|
if err := xml.Unmarshal(initRec.Body.Bytes(), &init); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
partHdrs := map[string]string{xhttp.MinIOSourceReplicationRequest: "true"}
|
||||||
|
partRec := request(http.MethodPut, getPutObjectPartURL("", bucket, key, init.UploadID, "1"), data, partHdrs)
|
||||||
|
if partRec.Code != http.StatusOK && (!altered || !strings.Contains(partRec.Body.String(), "XMinioObjectTampered")) {
|
||||||
|
t.Fatalf("%s %s part: %d %s", backend, key, partRec.Code, partRec.Body.String())
|
||||||
|
}
|
||||||
|
if partRec.Code != http.StatusOK {
|
||||||
|
rec = partRec
|
||||||
|
} else {
|
||||||
|
complete, err := xml.Marshal(CompleteMultipartUpload{Parts: []CompletePart{{PartNumber: 1, ETag: canonicalizeETag(partRec.Header()[xhttp.ETag][0])}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
actual, err := src.GetActualSize()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
partHdrs[xhttp.MinIOReplicationActualObjectSize] = strconv.FormatInt(actual, 10)
|
||||||
|
rec = request(http.MethodPost, getCompleteMultipartUploadURL("", bucket, key, init.UploadID), complete, partHdrs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if altered {
|
||||||
|
if rec.Code < 400 || !strings.Contains(rec.Body.String(), "WORM") && !strings.Contains(rec.Body.String(), "XMinioObjectTampered") {
|
||||||
|
t.Fatalf("%s %s: different content not refused: %d %s", backend, key, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
} else if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("%s %s: same content refused: %d %s", backend, key, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
getHdrs := map[string]string{}
|
||||||
|
if encryption == "sse-c" {
|
||||||
|
getHdrs = enc
|
||||||
|
}
|
||||||
|
got := request(http.MethodGet, getGetObjectURL("", bucket, key)+"?versionId="+src.VersionID, nil, getHdrs)
|
||||||
|
if got.Code != http.StatusOK || !bytes.Equal(got.Body.Bytes(), body) {
|
||||||
|
t.Fatalf("%s %s: protected content changed or became unreadable: %d", backend, key, got.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
restore()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPoolsReplicaProtectedContentAcrossPools(t *testing.T) {
|
||||||
|
z, bucket := consistencyPools(t)
|
||||||
|
const key = "protected-across-pools"
|
||||||
|
id := mustGetUUID()
|
||||||
|
stamp := UTCNow().Add(-time.Hour)
|
||||||
|
putConsistencyObject(t, z, bucket, key, 0, "original", ObjectOptions{Versioned: true, VersionID: id, MTime: stamp})
|
||||||
|
putConsistencyObject(t, z, bucket, key, 1, "original", ObjectOptions{Versioned: true, VersionID: id, MTime: stamp, UserDefined: batchLockMeta("COMPLIANCE")})
|
||||||
|
_, err := z.PutObject(t.Context(), bucket, key, mustGetPutObjReader(t, strings.NewReader("tampered"), 8, "", ""), ObjectOptions{Versioned: true, VersionID: id, ReplicationRequest: true, ReplicaLockReconcile: true, UserDefined: map[string]string{}})
|
||||||
|
if _, ok := err.(ObjectLocked); !ok {
|
||||||
|
t.Fatalf("protected pool copy allowed overwrite: %v", err)
|
||||||
|
}
|
||||||
|
for i, pool := range z.serverPools {
|
||||||
|
gr, err := pool.GetObjectNInfo(t.Context(), bucket, key, nil, http.Header{}, ObjectOptions{VersionID: id})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(gr)
|
||||||
|
gr.Close()
|
||||||
|
if err != nil || string(data) != "original" {
|
||||||
|
t.Fatalf("pool %d: changed protected data: %q %v", i, data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLockedSSECReplicaRejectsChangedSeal(t *testing.T) {
|
||||||
|
stored := ObjectInfo{Bucket: "bucket", Name: "key", Size: 0, UserDefined: batchLockMeta("LEGALHOLD")}
|
||||||
|
stored.UserDefined[crypto.MetaSealedKeySSEC] = "stored-seal"
|
||||||
|
incoming := stored
|
||||||
|
incoming.UserDefined = maps.Clone(stored.UserDefined)
|
||||||
|
incoming.UserDefined[crypto.MetaSealedKeySSEC] = "changed-seal"
|
||||||
|
open := func() (*GetObjectReader, error) {
|
||||||
|
t.Fatal("changed seal must be rejected before reading")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err := verifyLockedReplica(t.Context(), incoming, stored, open, open); err == nil {
|
||||||
|
t.Fatal("changed SSE-C seal accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIReplicaCopyPreservesProtectedVersion(t *testing.T) {
|
||||||
|
defer DetectTestLeak(t)()
|
||||||
|
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||||
|
t: t, makeBucketOptions: MakeBucketOptions{LockEnabled: true},
|
||||||
|
objAPITest: func(obj ObjectLayer, backend, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||||
|
const key = "copy-protected-version"
|
||||||
|
original := batchPutVersion(t, obj, bucket, key, batchLockMeta("COMPLIANCE"))
|
||||||
|
data := []byte("different source version")
|
||||||
|
source, err := obj.PutObject(t.Context(), bucket, key, mustGetPutObjReader(t, bytes.NewReader(data), int64(len(data)), "", ""), ObjectOptions{Versioned: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
headers := map[string]string{
|
||||||
|
xhttp.AmzCopySource: url.QueryEscape("/"+bucket+"/"+key) + "?versionId=" + source.VersionID,
|
||||||
|
xhttp.MinIOSourceReplicationRequest: "true", xhttp.AmzBucketReplicationStatus: "REPLICA",
|
||||||
|
xhttp.AmzMetadataDirective: replaceDirective,
|
||||||
|
}
|
||||||
|
req, err := newTestSignedRequestV4(http.MethodPut, getCopyObjectURL("", bucket, key)+"?versionId="+original.VersionID, 0, nil, cred.AccessKey, cred.SecretKey, headers)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code < 400 || !strings.Contains(rec.Body.String(), "WORM") {
|
||||||
|
t.Fatalf("%s: copy replaced protected version: %d %s", backend, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
gr, err := obj.GetObjectNInfo(t.Context(), bucket, key, nil, http.Header{}, ObjectOptions{VersionID: original.VersionID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := io.ReadAll(gr)
|
||||||
|
gr.Close()
|
||||||
|
if err != nil || string(got) != "batch lock "+key {
|
||||||
|
t.Fatalf("%s: protected body changed: %q %v", backend, got, err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIReplicaCannotWeakenCompliance(t *testing.T) {
|
||||||
|
defer DetectTestLeak(t)()
|
||||||
|
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||||
|
t: t, makeBucketOptions: MakeBucketOptions{LockEnabled: true},
|
||||||
|
objAPITest: func(obj ObjectLayer, backend, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||||
|
const key = "compliance-removal"
|
||||||
|
oi := batchPutVersion(t, obj, bucket, key, batchLockMeta("COMPLIANCE"))
|
||||||
|
until := oi.UserDefined[strings.ToLower(xhttp.AmzObjectLockRetainUntilDate)]
|
||||||
|
for _, copyRequest := range []bool{false, true} {
|
||||||
|
headers := map[string]string{xhttp.MinIOSourceReplicationRequest: "true", xhttp.AmzBucketReplicationStatus: "REPLICA", xhttp.MinIOSourceObjectRetentionTimestamp: UTCNow().Add(time.Hour).Format(time.RFC3339Nano)}
|
||||||
|
body := []byte("batch lock " + key)
|
||||||
|
if copyRequest {
|
||||||
|
headers[xhttp.AmzCopySource] = url.QueryEscape("/"+bucket+"/"+key) + "?versionId=" + oi.VersionID
|
||||||
|
headers[xhttp.AmzMetadataDirective] = replaceDirective
|
||||||
|
body = nil
|
||||||
|
}
|
||||||
|
req, err := newTestSignedRequestV4(http.MethodPut, getPutObjectURL("", bucket, key)+"?versionId="+oi.VersionID, int64(len(body)), bytes.NewReader(body), cred.AccessKey, cred.SecretKey, headers)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("%s copy=%v: %d %s", backend, copyRequest, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
stored, err := obj.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: oi.VersionID})
|
||||||
|
if err != nil || stored.UserDefined[strings.ToLower(xhttp.AmzObjectLockMode)] != "COMPLIANCE" || stored.UserDefined[strings.ToLower(xhttp.AmzObjectLockRetainUntilDate)] != until {
|
||||||
|
t.Fatalf("%s copy=%v: a replica cleared COMPLIANCE: %+v %v", backend, copyRequest, stored.UserDefined, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Copyright (c) 2026 Feng Ruohang
|
||||||
|
//
|
||||||
|
// This file is part of Silo 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.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/minio/minio/internal/auth"
|
||||||
|
xhttp "github.com/minio/minio/internal/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReplicationControlsRequireSignedHeaders(t *testing.T) {
|
||||||
|
for _, header := range replicationRequestHeaders {
|
||||||
|
t.Run(header, func(t *testing.T) {
|
||||||
|
req, err := newTestSignedRequestV4(http.MethodGet, "http://localhost/bucket/key", 0, nil, "access-key", "secret-key", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Membership matters even for a present-empty internal header.
|
||||||
|
req.Header.Set(header, "")
|
||||||
|
if replicationHeadersSigned(req) {
|
||||||
|
t.Fatal("unsigned control granted replication trust")
|
||||||
|
}
|
||||||
|
if err := signRequestV4(req, "access-key", "secret-key"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !replicationHeadersSigned(req) {
|
||||||
|
t.Fatal("signed control rejected")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, authorization := range []string{"", "AWS access-key:signature", "Credential=access-key"} {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/bucket/key", nil)
|
||||||
|
req.Header.Set(xhttp.Authorization, authorization)
|
||||||
|
req.Header.Set(xhttp.MinIOSourceReplicationRequest, "true")
|
||||||
|
if replicationHeadersSigned(req) {
|
||||||
|
t.Fatalf("non-SigV4 authorization granted trust: %q", authorization)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://localhost/bucket/key", nil)
|
||||||
|
req.Header.Set(xhttp.MinIOSourceReplicationRequest, "true")
|
||||||
|
presignBoundaryRequest(t, req, UTCNow(), []string{"host", "x-minio-source-replication-request"}, auth.Credentials{AccessKey: "access-key", SecretKey: "secret-key"})
|
||||||
|
getRequestAuthType(req)
|
||||||
|
if replicationHeadersSigned(req) {
|
||||||
|
t.Fatal("presigned request granted peer trust")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIUnsignedReplicationControlsAreIgnored(t *testing.T) {
|
||||||
|
defer DetectTestLeak(t)()
|
||||||
|
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||||
|
t: t, endpoints: []string{"PutObject"},
|
||||||
|
objAPITest: func(obj ObjectLayer, backend, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||||
|
for _, signedMarker := range []bool{false, true} {
|
||||||
|
headers := map[string]string{}
|
||||||
|
if signedMarker {
|
||||||
|
headers[xhttp.MinIOSourceReplicationRequest] = "true"
|
||||||
|
}
|
||||||
|
body := []byte("replication controls are authenticated")
|
||||||
|
req, err := newTestSignedRequestV4(http.MethodPut, getPutObjectURL("", bucket, "key"), int64(len(body)), bytes.NewReader(body), cred.AccessKey, cred.SecretKey, headers)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req.Header.Set(xhttp.MinIOSourceReplicationRequest, "true")
|
||||||
|
req.Header.Set(xhttp.MinIOSourceMTime, "2100-01-01T00:00:00Z")
|
||||||
|
req.Header.Set(xhttp.MinIOSourceETag, "forged-source-etag")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
oi, err := obj.GetObjectInfo(t.Context(), bucket, "key", ObjectOptions{})
|
||||||
|
if rec.Code != http.StatusOK || err != nil || oi.ModTime.Year() == 2100 || oi.ETag == "forged-source-etag" {
|
||||||
|
t.Fatalf("%s signedMarker=%v: unsigned replication state was accepted: HTTP=%d info=%+v err=%v", backend, signedMarker, rec.Code, oi, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1850,6 +1850,12 @@ func testReplicaLockReconcileNullVersion(obj ObjectLayer, instanceType, bucketNa
|
|||||||
_ http.Handler, _ auth.Credentials, t *testing.T,
|
_ http.Handler, _ auth.Credentials, t *testing.T,
|
||||||
) {
|
) {
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
// A protected retransmit must preserve both the source ciphertext and
|
||||||
|
// its seal. Generate it once for the stored null version and its retry.
|
||||||
|
var ciphertext bytes.Buffer
|
||||||
|
if _, err := sio.Encrypt(&ciphertext, bytes.NewReader([]byte("data")), sio.Config{Key: bytes.Repeat([]byte{1}, 32)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
// completeNullMPU runs an SSE-C replica multipart upload addressing the null
|
// completeNullMPU runs an SSE-C replica multipart upload addressing the null
|
||||||
// version (VersionSuspended) carrying uploadLock, through the reconcile.
|
// version (VersionSuspended) carrying uploadLock, through the reconcile.
|
||||||
@@ -1861,13 +1867,6 @@ func testReplicaLockReconcileNullVersion(obj ObjectLayer, instanceType, bucketNa
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// PutObjectPart now validates the DARE stream length (#119). This
|
|
||||||
// object-layer fixture needs an encrypted body before it can exercise
|
|
||||||
// the completion-time null-version metadata reconciliation.
|
|
||||||
var ciphertext bytes.Buffer
|
|
||||||
if _, err := sio.Encrypt(&ciphertext, bytes.NewReader([]byte("data")), sio.Config{Key: bytes.Repeat([]byte{1}, 32)}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
part, err := obj.PutObjectPart(ctx, bucketName, object, res.UploadID, 1,
|
part, err := obj.PutObjectPart(ctx, bucketName, object, res.UploadID, 1,
|
||||||
mustGetPutObjReader(t, bytes.NewReader(ciphertext.Bytes()), int64(ciphertext.Len()), "", ""), ObjectOptions{})
|
mustGetPutObjReader(t, bytes.NewReader(ciphertext.Bytes()), int64(ciphertext.Len()), "", ""), ObjectOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1884,8 +1883,9 @@ func testReplicaLockReconcileNullVersion(obj ObjectLayer, instanceType, bucketNa
|
|||||||
object := "reconcile/null-vs-uuid"
|
object := "reconcile/null-vs-uuid"
|
||||||
// The null version holds a newer legal hold ON@10:00.
|
// The null version holds a newer legal hold ON@10:00.
|
||||||
if _, err := obj.PutObject(ctx, bucketName, object,
|
if _, err := obj.PutObject(ctx, bucketName, object,
|
||||||
mustGetPutObjReader(t, bytes.NewReader([]byte("data")), 4, "", ""),
|
mustGetPutObjReader(t, bytes.NewReader(ciphertext.Bytes()), int64(ciphertext.Len()), "", ""),
|
||||||
ObjectOptions{VersionSuspended: true, MTime: time.Date(2026, 9, 3, 10, 0, 0, 0, time.UTC), UserDefined: map[string]string{
|
ObjectOptions{VersionSuspended: true, MTime: time.Date(2026, 9, 3, 10, 0, 0, 0, time.UTC), UserDefined: map[string]string{
|
||||||
|
crypto.MetaSealedKeySSEC: "dummy-sealed-key",
|
||||||
strings.ToLower(xhttp.AmzObjectLockLegalHold): "ON",
|
strings.ToLower(xhttp.AmzObjectLockLegalHold): "ON",
|
||||||
ReservedMetadataPrefixLower + ObjectLockLegalHoldTimestamp: objectLockTestStamp1000,
|
ReservedMetadataPrefixLower + ObjectLockLegalHoldTimestamp: objectLockTestStamp1000,
|
||||||
}}); err != nil {
|
}}); err != nil {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
objectreplication "github.com/minio/minio/internal/bucket/replication"
|
objectreplication "github.com/minio/minio/internal/bucket/replication"
|
||||||
xhttp "github.com/minio/minio/internal/http"
|
xhttp "github.com/minio/minio/internal/http"
|
||||||
@@ -62,7 +64,7 @@ func isReplicaTrusted(ctx context.Context) bool {
|
|||||||
// grant must not turn client-controlled internal headers into trusted state.
|
// grant must not turn client-controlled internal headers into trusted state.
|
||||||
func replicationPermissionAllowed(ctx context.Context, r *http.Request, bucket, object string, action policy.Action) bool {
|
func replicationPermissionAllowed(ctx context.Context, r *http.Request, bucket, object string, action policy.Action) bool {
|
||||||
reqInfo := logger.GetReqInfo(ctx)
|
reqInfo := logger.GetReqInfo(ctx)
|
||||||
if reqInfo == nil || reqInfo.Cred.AccessKey == "" {
|
if reqInfo == nil || reqInfo.Cred.AccessKey == "" || !replicationHeadersSigned(r) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
reqInfo.BucketName = bucket
|
reqInfo.BucketName = bucket
|
||||||
@@ -70,6 +72,25 @@ func replicationPermissionAllowed(ctx context.Context, r *http.Request, bucket,
|
|||||||
return authorizeRequest(ctx, r, action) == ErrNone
|
return authorizeRequest(ctx, r, action) == ErrNone
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// replicationHeadersSigned binds every internal control to the authenticated
|
||||||
|
// header signature. Query grants and SigV2 requests cannot confer peer trust.
|
||||||
|
// Call only after the request's signature has been verified.
|
||||||
|
func replicationHeadersSigned(r *http.Request) bool {
|
||||||
|
if !isRequestSignatureV4(r) || isRequestPresignedSignatureV4(r) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
sig, errCode := parseSignV4(r.Header.Get(xhttp.Authorization), globalSite.Region(), serviceS3)
|
||||||
|
if errCode != ErrNone {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, name := range replicationRequestHeaders {
|
||||||
|
if _, present := r.Header[http.CanonicalHeaderKey(name)]; present && !slices.Contains(sig.SignedHeaders, strings.ToLower(name)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// evaluateReplicationTrust decides whether a request may carry replication
|
// evaluateReplicationTrust decides whether a request may carry replication
|
||||||
// semantics for the given action. A request that declares itself a replica
|
// semantics for the given action. A request that declares itself a replica
|
||||||
// without holding the replication permission is rejected. trusted reports that
|
// without holding the replication permission is rejected. trusted reports that
|
||||||
|
|||||||
+14
-20
@@ -144,13 +144,7 @@ func (api objectAPIHandlers) getObjectInArchiveFileHandler(ctx context.Context,
|
|||||||
|
|
||||||
zipInfo := zipObjInfo.ArchiveInfo(r.Header)
|
zipInfo := zipObjInfo.ArchiveInfo(r.Header)
|
||||||
if len(zipInfo) == 0 {
|
if len(zipInfo) == 0 {
|
||||||
opts.EncryptFn, err = zipObjInfo.metadataEncryptFn(r.Header)
|
zipInfo, _, err = readObjectZIPInfo(ctx, objectAPI, bucket, zipPath, opts)
|
||||||
if err != nil {
|
|
||||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
zipInfo, err = updateObjectMetadataWithZipInfo(ctx, objectAPI, bucket, zipPath, opts)
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
|
||||||
@@ -245,8 +239,7 @@ func listObjectsV2InArchive(ctx context.Context, objectAPI ObjectLayer, bucket,
|
|||||||
|
|
||||||
zipInfo := zipObjInfo.ArchiveInfo(h)
|
zipInfo := zipObjInfo.ArchiveInfo(h)
|
||||||
if len(zipInfo) == 0 {
|
if len(zipInfo) == 0 {
|
||||||
// Always update the latest version
|
zipInfo, _, err = readObjectZIPInfo(ctx, objectAPI, bucket, zipPath, ObjectOptions{})
|
||||||
zipInfo, err = updateObjectMetadataWithZipInfo(ctx, objectAPI, bucket, zipPath, ObjectOptions{})
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ListObjectsV2Info{}, err
|
return ListObjectsV2Info{}, err
|
||||||
@@ -437,12 +430,7 @@ func (api objectAPIHandlers) headObjectInArchiveFileHandler(ctx context.Context,
|
|||||||
|
|
||||||
zipInfo := zipObjInfo.ArchiveInfo(r.Header)
|
zipInfo := zipObjInfo.ArchiveInfo(r.Header)
|
||||||
if len(zipInfo) == 0 {
|
if len(zipInfo) == 0 {
|
||||||
opts.EncryptFn, err = zipObjInfo.metadataEncryptFn(r.Header)
|
zipInfo, _, err = readObjectZIPInfo(ctx, objectAPI, bucket, zipPath, opts)
|
||||||
if err != nil {
|
|
||||||
writeErrorResponseHeadersOnly(w, toAPIError(ctx, err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
zipInfo, err = updateObjectMetadataWithZipInfo(ctx, objectAPI, bucket, zipPath, opts)
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErrorResponseHeadersOnly(w, toAPIError(ctx, err))
|
writeErrorResponseHeadersOnly(w, toAPIError(ctx, err))
|
||||||
@@ -482,15 +470,22 @@ func (api objectAPIHandlers) headObjectInArchiveFileHandler(ctx context.Context,
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the passed zip object metadata with the zip contents info, file name, modtime, size, etc.
|
// readObjectZIPInfo builds an index without modifying the object. A GET, HEAD
|
||||||
// The returned zip index will de decrypted.
|
// or archive listing must remain read-only even when no index was stored.
|
||||||
func updateObjectMetadataWithZipInfo(ctx context.Context, objectAPI ObjectLayer, bucket, object string, opts ObjectOptions) ([]byte, error) {
|
func readObjectZIPInfo(ctx context.Context, objectAPI ObjectLayer, bucket, object string, opts ObjectOptions) ([]byte, ObjectInfo, error) {
|
||||||
files, srcInfo, err := getFilesListFromZIPObject(ctx, objectAPI, bucket, object, opts)
|
files, srcInfo, err := getFilesListFromZIPObject(ctx, objectAPI, bucket, object, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, srcInfo, err
|
||||||
}
|
}
|
||||||
files.OptimizeSize()
|
files.OptimizeSize()
|
||||||
zipInfo, err := files.Serialize()
|
zipInfo, err := files.Serialize()
|
||||||
|
return zipInfo, srcInfo, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateObjectMetadataWithZipInfo persists an index only for upload operations
|
||||||
|
// that explicitly request extraction. The returned index is decrypted.
|
||||||
|
func updateObjectMetadataWithZipInfo(ctx context.Context, objectAPI ObjectLayer, bucket, object string, opts ObjectOptions) ([]byte, error) {
|
||||||
|
zipInfo, srcInfo, err := readObjectZIPInfo(ctx, objectAPI, bucket, object, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -500,7 +495,6 @@ func updateObjectMetadataWithZipInfo(ctx context.Context, objectAPI ObjectLayer,
|
|||||||
at = archiveTypeEnc
|
at = archiveTypeEnc
|
||||||
zipInfoStr = string(opts.EncryptFn(archiveTypeEnc, zipInfo))
|
zipInfoStr = string(opts.EncryptFn(archiveTypeEnc, zipInfo))
|
||||||
}
|
}
|
||||||
srcInfo.UserDefined[archiveTypeMetadataKey] = at
|
|
||||||
popts := ObjectOptions{
|
popts := ObjectOptions{
|
||||||
MTime: srcInfo.ModTime,
|
MTime: srcInfo.ModTime,
|
||||||
VersionID: srcInfo.VersionID,
|
VersionID: srcInfo.VersionID,
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// Copyright (c) 2026 Feng Ruohang
|
||||||
|
//
|
||||||
|
// This file is part of Silo 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.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/minio/minio/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAPIArchiveReadsDoNotPersistMetadata(t *testing.T) {
|
||||||
|
defer DetectTestLeak(t)()
|
||||||
|
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||||
|
t: t, makeBucketOptions: MakeBucketOptions{LockEnabled: true},
|
||||||
|
objAPITest: func(obj ObjectLayer, backend, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||||
|
var archive bytes.Buffer
|
||||||
|
zw := zip.NewWriter(&archive)
|
||||||
|
file, err := zw.Create("entry.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = file.Write([]byte("archive content")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = zw.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reader := newObjectAttributesAuthzUser(t, backend, bucket, `"s3:GetObject","s3:ListBucket"`)
|
||||||
|
policy := fmt.Appendf(nil, `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":["s3:GetObject","s3:ListBucket"],"Resource":["arn:aws:s3:::%s","arn:aws:s3:::%s/*"]}]}`, bucket, bucket)
|
||||||
|
if _, err = globalBucketMetadataSys.Update(t.Context(), bucket, bucketPolicyConfig, policy); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, anonymous := range []bool{false, true} {
|
||||||
|
for _, method := range []string{http.MethodGet, http.MethodHead, "LIST"} {
|
||||||
|
key := fmt.Sprintf("archive-%v-%s.zip", anonymous, method)
|
||||||
|
before, err := obj.PutObject(t.Context(), bucket, key, mustGetPutObjReader(t, bytes.NewReader(archive.Bytes()), int64(archive.Len()), "", ""), ObjectOptions{Versioned: true, UserDefined: batchLockMeta("COMPLIANCE")})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before, err = obj.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: before.VersionID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
url := getGetObjectURL("", bucket, key+"/entry.txt")
|
||||||
|
verb := method
|
||||||
|
if method == "LIST" {
|
||||||
|
verb = http.MethodGet
|
||||||
|
url = "/" + bucket + "?list-type=2&prefix=" + key + "/"
|
||||||
|
}
|
||||||
|
req, err := newTestSignedRequestV4(verb, url, 0, nil, reader.AccessKey, reader.SecretKey, map[string]string{xMinIOExtract: "true"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if anonymous {
|
||||||
|
req.Header.Del("Authorization")
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("%s anonymous=%v %s: %d %s", backend, anonymous, method, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if method == http.MethodGet && rec.Body.String() != "archive content" {
|
||||||
|
t.Fatalf("wrong archive entry: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if method == "LIST" && !bytes.Contains(rec.Body.Bytes(), []byte("entry.txt")) {
|
||||||
|
t.Fatal("archive listing omitted entry")
|
||||||
|
}
|
||||||
|
after, err := obj.GetObjectInfo(t.Context(), bucket, key, ObjectOptions{VersionID: before.VersionID})
|
||||||
|
if err != nil || !maps.Equal(before.UserDefined, after.UserDefined) || !after.ModTime.Equal(before.ModTime) || after.ETag != before.ETag {
|
||||||
|
t.Fatalf("%s anonymous=%v %s changed stored metadata: before=%v after=%v err=%v", backend, anonymous, method, before.UserDefined, after.UserDefined, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Explicit extraction during an authorized upload still builds and
|
||||||
|
// persists the index for subsequent reads.
|
||||||
|
req, err := newTestSignedRequestV4(http.MethodPut, getPutObjectURL("", bucket, "indexed.zip"), int64(archive.Len()), bytes.NewReader(archive.Bytes()), cred.AccessKey, cred.SecretKey, map[string]string{xMinIOExtract: "true"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
oi, err := obj.GetObjectInfo(t.Context(), bucket, "indexed.zip", ObjectOptions{})
|
||||||
|
if rec.Code != http.StatusOK || err != nil || len(oi.ArchiveInfo(nil)) == 0 {
|
||||||
|
t.Fatalf("explicit extraction lost index: %d %s %v", rec.Code, rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// Copyright (c) 2026 Feng Ruohang
|
||||||
|
//
|
||||||
|
// This file is part of Silo 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.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/minio/minio/internal/auth"
|
||||||
|
xhttp "github.com/minio/minio/internal/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAPIStreamingAuthenticationBoundaries(t *testing.T) {
|
||||||
|
defer DetectTestLeak(t)()
|
||||||
|
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
|
||||||
|
t: t, endpoints: []string{"PutObject"},
|
||||||
|
objAPITest: func(obj ObjectLayer, backend, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
|
||||||
|
data := []byte("authenticated streaming payload")
|
||||||
|
for _, mode := range []string{"valid", "missing-algorithm", "wrong-algorithm", "wrong-signature"} {
|
||||||
|
req, err := newStreamingUnsignedTrailerRequest(http.MethodPut, getPutObjectURL("", bucket, mode), data, UTCNow())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := signRequestV4(req, cred.AccessKey, cred.SecretKey); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
authorization := req.Header.Get(xhttp.Authorization)
|
||||||
|
switch mode {
|
||||||
|
case "missing-algorithm":
|
||||||
|
authorization = strings.TrimPrefix(authorization, signV4Algorithm+" ")
|
||||||
|
case "wrong-algorithm":
|
||||||
|
authorization = strings.Replace(authorization, signV4Algorithm, "INVALID", 1)
|
||||||
|
case "wrong-signature":
|
||||||
|
prefix, _, found := strings.Cut(authorization, "Signature=")
|
||||||
|
if !found {
|
||||||
|
t.Fatal("signed request has no signature")
|
||||||
|
}
|
||||||
|
authorization = prefix + "Signature=" + strings.Repeat("0", 64)
|
||||||
|
}
|
||||||
|
req.Header.Set(xhttp.Authorization, authorization)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
_, stored := obj.GetObjectInfo(t.Context(), bucket, mode, ObjectOptions{})
|
||||||
|
if mode == "valid" {
|
||||||
|
if rec.Code != http.StatusOK || stored != nil {
|
||||||
|
t.Fatalf("%s %s: %d %s, object: %v", backend, mode, rec.Code, rec.Body.String(), stored)
|
||||||
|
}
|
||||||
|
} else if rec.Code < 400 || stored == nil {
|
||||||
|
t.Errorf("%s %s: malformed authentication wrote an object: %d %s", backend, mode, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, trailers := range []bool{false, true} {
|
||||||
|
req, err := newTestStreamingSignedRequest(http.MethodPut, getPutObjectURL("", bucket, "tagged"), int64(len(data)), 64*1024, bytes.NewReader(data), cred.AccessKey, cred.SecretKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if trailers {
|
||||||
|
req.Header.Set(xhttp.AmzContentSha256, streamingContentSHA256Trailer)
|
||||||
|
req.Header.Set(awsTrailerHeader, "x-amz-checksum-crc32")
|
||||||
|
if err := signRequestV4(req, cred.AccessKey, cred.SecretKey); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getRequestAuthType(req)
|
||||||
|
if _, _, _, _, code := calculateSeedSignature(req, trailers); code != ErrNone {
|
||||||
|
t.Fatalf("%s trailers=%v: valid seed rejected: %v", backend, trailers, code)
|
||||||
|
}
|
||||||
|
req.Header.Set(xhttp.AmzObjectTagging, "unsigned=injected")
|
||||||
|
if _, code := newSignV4ChunkedReader(req, trailers); code != ErrUnsignedHeaders {
|
||||||
|
t.Errorf("%s trailers=%v: unsigned tags accepted: %v", backend, trailers, code)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
if _, err := obj.GetObjectInfo(t.Context(), bucket, "tagged", ObjectOptions{}); rec.Code < 400 || err == nil {
|
||||||
|
t.Errorf("%s trailers=%v: injected metadata stored: %d", backend, trailers, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A public bucket can still use the unsigned trailer decoder without
|
||||||
|
// claiming credentials; authorization remains the handler's job.
|
||||||
|
req, err := newStreamingUnsignedTrailerRequest(http.MethodPut, "/public/key", data, UTCNow())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rd, code := newUnsignedV4ChunkedReader(req, true)
|
||||||
|
if code != ErrNone {
|
||||||
|
t.Fatal(code)
|
||||||
|
}
|
||||||
|
got, err := io.ReadAll(rd)
|
||||||
|
rd.Close()
|
||||||
|
if err != nil || !bytes.Equal(got, data) {
|
||||||
|
t.Fatalf("anonymous decoder: %q %v", got, err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -127,6 +127,9 @@ func calculateSeedSignature(r *http.Request, trailers bool) (cred auth.Credentia
|
|||||||
if errCode != ErrNone {
|
if errCode != ErrNone {
|
||||||
return cred, "", "", time.Time{}, errCode
|
return cred, "", "", time.Time{}, errCode
|
||||||
}
|
}
|
||||||
|
if errCode := checkUnsignedHeaders(extractedSignedHeaders, r); errCode != ErrNone {
|
||||||
|
return cred, "", "", time.Time{}, errCode
|
||||||
|
}
|
||||||
|
|
||||||
cred, _, errCode = checkKeyValid(r, signV4Values.Credential.accessKey)
|
cred, _, errCode = checkKeyValid(r, signV4Values.Credential.accessKey)
|
||||||
if errCode != ErrNone {
|
if errCode != ErrNone {
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
xhttp "github.com/minio/minio/internal/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newUnsignedV4ChunkedReader returns a new s3UnsignedChunkedReader that translates
|
// newUnsignedV4ChunkedReader returns a new s3UnsignedChunkedReader that translates
|
||||||
@@ -42,8 +44,10 @@ func newUnsignedV4ChunkedReader(req *http.Request, trailer bool) (io.ReadCloser,
|
|||||||
if isRequestPresignedSignatureV4(req) {
|
if isRequestPresignedSignatureV4(req) {
|
||||||
return nil, ErrSignatureVersionNotSupported
|
return nil, ErrSignatureVersionNotSupported
|
||||||
}
|
}
|
||||||
if isRequestSignatureV4(req) {
|
// Any claimed header credential must pass the complete SigV4 parser. The
|
||||||
if errCode := reqSignatureV4Verify(req, globalSite.Region(), serviceS3); errCode != ErrNone {
|
// streaming auth classification does not require the algorithm prefix.
|
||||||
|
if req.Header.Get(xhttp.Authorization) != "" {
|
||||||
|
if errCode := doesSignatureMatch(unsignedPayloadTrailer, req, globalSite.Region(), serviceS3); errCode != ErrNone {
|
||||||
return nil, errCode
|
return nil, errCode
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user