From 938603458d7af34ddf7d6a59b50a716e4cca13ba Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Tue, 1 Sep 2026 20:50:08 +0800 Subject: [PATCH 1/7] fix: harden CORS and replication request trust Keep pre-authentication CORS lookups resident-only so attacker-controlled path segments cannot trigger metadata I/O or grow the metadata cache. Preserve fail-closed behavior for startup, load failures, invalid metadata, and the internal namespace. Centralize replication request trust after authentication, distinguish general replication from replica-only privileges, and gate SSE-C ciphertext handling, source metadata, object-lock bypasses, event suppression, delete semantics, and replica status on the appropriate permission. Add least-privilege, multipart, PostPolicy, CORS amplification, and compatibility regressions. Signed-off-by: Feng Ruohang --- cmd/api-router.go | 11 +- cmd/bucket-cors-middleware_test.go | 269 ++++++++++- cmd/bucket-metadata-sys.go | 86 ++++ cmd/bucket-object-lock.go | 20 +- cmd/encryption-v1.go | 2 +- cmd/handler-utils.go | 3 +- cmd/handler-utils_test.go | 10 +- cmd/object-api-options.go | 80 ++-- cmd/object-handlers.go | 234 ++++++---- cmd/object-multipart-handlers.go | 79 +++- cmd/post-policy_test.go | 44 ++ cmd/replication-trust.go | 125 +++++ cmd/replication-trust_test.go | 560 +++++++++++++++++++++++ internal/bucket/object/lock/lock.go | 8 +- internal/bucket/object/lock/lock_test.go | 10 +- 15 files changed, 1377 insertions(+), 164 deletions(-) create mode 100644 cmd/replication-trust.go create mode 100644 cmd/replication-trust_test.go diff --git a/cmd/api-router.go b/cmd/api-router.go index 63164a06ce001..18305e427db65 100644 --- a/cmd/api-router.go +++ b/cmd/api-router.go @@ -787,7 +787,16 @@ func corsHandler(handler http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Origin") != "" { if bucket, _ := request2BucketObjectName(r); bucket != "" && globalBucketMetadataSys != nil { - cfg, _, err := globalBucketMetadataSys.GetCorsConfig(bucket) + // Resident-only lookup: this runs pre-auth for every + // Origin-bearing request using a client-supplied path segment as + // the bucket name. It must never load or cache metadata for + // arbitrary names (see GetResidentCorsConfig). GetResidentCorsConfig + // is the single decision point: it returns errInvalidArgument for + // the internal .minio.sys namespace (fail closed), a config for a + // resident bucket, errBucketMetadataNotInitialized for a real but + // unloaded bucket (fail closed), and errConfigNotFound otherwise + // (fall back to the global policy below). + cfg, _, err := globalBucketMetadataSys.GetResidentCorsConfig(bucket) if err == nil && cfg != nil { if applyBucketCors(w, r, cfg) { return diff --git a/cmd/bucket-cors-middleware_test.go b/cmd/bucket-cors-middleware_test.go index f168e0f31a6ef..f18bd23775f2c 100644 --- a/cmd/bucket-cors-middleware_test.go +++ b/cmd/bucket-cors-middleware_test.go @@ -19,6 +19,7 @@ package cmd import ( "context" + "fmt" "net/http" "net/http/httptest" "strings" @@ -355,7 +356,20 @@ func TestBucketCorsMissingBucketUsesGlobalFallback(t *testing.T) { }) } -func testBucketCorsMissingBucketUsesGlobalFallback(_ ObjectLayer, _ string, bucket string, _ http.Handler, _ auth.Credentials, t *testing.T) { +func testBucketCorsMissingBucketUsesGlobalFallback(obj ObjectLayer, _ string, bucket string, _ http.Handler, _ auth.Credentials, t *testing.T) { + // Model a fully started server: bucket metadata loading has completed, so + // a name that is not resident is genuinely not a CORS-bearing bucket. + restore := markBucketMetadataInitialized(t) + defer restore() + + // A non-resident bucket name must not cause any bucket-metadata disk read. + oldObjectAPI := newObjectLayerFn() + counting := &corsLookupCountingObjectLayer{ObjectLayer: obj} + setObjectLayer(counting) + defer setObjectLayer(oldObjectAPI) + + before := bucketMetadataMapLen() + wrapped := corsHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) })) @@ -373,6 +387,14 @@ func testBucketCorsMissingBucketUsesGlobalFallback(_ ObjectLayer, _ string, buck if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" { t.Fatalf("allow-credentials = %q", got) } + // Regression guard: the pre-auth CORS lookup for a non-existent bucket must + // neither read bucket metadata from disk nor cache a synthetic entry. + if got := counting.getObjectNInfoCalls.Load(); got != 0 { + t.Fatalf("missing-bucket CORS lookup performed %d bucket metadata reads", got) + } + if after := bucketMetadataMapLen(); after != before { + t.Fatalf("missing-bucket CORS lookup grew metadataMap from %d to %d", before, after) + } } func testBucketCorsNoConfigUsesGlobalFallback(_ ObjectLayer, _ string, bucket string, _ http.Handler, _ auth.Credentials, t *testing.T) { @@ -471,3 +493,248 @@ func requireCorsOriginVary(t *testing.T, header http.Header) { t.Fatalf("Vary = %q, missing Origin", values) } } + +// markBucketMetadataInitialized marks the global bucket-metadata subsystem as +// fully loaded, modelling a running server (the API test harness sets up the +// subsystem but does not run Init). It returns a function that restores the +// previous state. +func markBucketMetadataInitialized(t *testing.T) func() { + t.Helper() + sys := globalBucketMetadataSys + if sys == nil { + t.Fatal("globalBucketMetadataSys is nil") + } + sys.Lock() + prev := sys.initialized + sys.initialized = true + sys.Unlock() + return func() { + sys.Lock() + sys.initialized = prev + sys.Unlock() + } +} + +// bucketMetadataMapLen returns the number of resident bucket-metadata entries. +func bucketMetadataMapLen() int { + sys := globalBucketMetadataSys + if sys == nil { + return 0 + } + sys.RLock() + defer sys.RUnlock() + return len(sys.metadataMap) +} + +// TestBucketCorsUnknownBucketDoesNotGrowMetadata is the regression guard for +// the pre-auth resource-exhaustion path: an unauthenticated, Origin-bearing +// request whose first path segment is not a real bucket must fall back to the +// global CORS policy without loading bucket metadata from disk and without +// caching a synthetic entry. Before the resident-only lookup, each distinct +// name grew metadataMap by one and issued an erasure metadata probe. +func TestBucketCorsUnknownBucketDoesNotGrowMetadata(t *testing.T) { + ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{ + t: t, + objAPITest: testBucketCorsUnknownBucketDoesNotGrowMetadata, + endpoints: []string{"GetBucketCors"}, + }) +} + +func testBucketCorsUnknownBucketDoesNotGrowMetadata(obj ObjectLayer, _ string, _ string, _ http.Handler, _ auth.Credentials, t *testing.T) { + restore := markBucketMetadataInitialized(t) + defer restore() + + oldObjectAPI := newObjectLayerFn() + counting := &corsLookupCountingObjectLayer{ObjectLayer: obj} + setObjectLayer(counting) + defer setObjectLayer(oldObjectAPI) + + wrapped := corsHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + + before := bucketMetadataMapLen() + // Console/admin routes plus enough distinct valid names to make accidental + // cache growth or one metadata probe per name unambiguous. + names := []string{"minio", "api"} + for i := 0; i < 500; i++ { + names = append(names, fmt.Sprintf("cors-missing-%03d", i)) + } + for _, name := range names { + for _, method := range []string{http.MethodGet, http.MethodOptions} { + rec := httptest.NewRecorder() + req := httptest.NewRequest(method, getGetObjectURL("", name, "obj"), nil) + req.Header.Set("Origin", "https://app.example.com") + if method == http.MethodOptions { + req.Header.Set("Access-Control-Request-Method", http.MethodGet) + } + wrapped.ServeHTTP(rec, req) + + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://app.example.com" { + t.Fatalf("%s/%s: allow-origin = %q, want global fallback", name, method, got) + } + } + } + for _, path := range []string{"/../obj", "/A/obj", "/x/obj", "/minio/admin/v3/info", "/api/v1/login"} { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("Origin", "https://app.example.com") + wrapped.ServeHTTP(rec, req) + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://app.example.com" { + t.Fatalf("%s: allow-origin = %q, want global fallback", path, got) + } + } + + if got := counting.getObjectNInfoCalls.Load(); got != 0 { + t.Fatalf("unknown-bucket CORS lookups performed %d bucket metadata reads", got) + } + if after := bucketMetadataMapLen(); after != before { + t.Fatalf("unknown-bucket CORS lookups grew metadataMap from %d to %d", before, after) + } +} + +func TestBucketCorsStartupMissFailsClosedWithoutIO(t *testing.T) { + ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{ + t: t, + objAPITest: testBucketCorsStartupMissFailsClosedWithoutIO, + endpoints: []string{"GetBucketCors"}, + }) +} + +func testBucketCorsStartupMissFailsClosedWithoutIO(obj ObjectLayer, _ string, _ string, _ http.Handler, _ auth.Credentials, t *testing.T) { + oldObjectAPI := newObjectLayerFn() + oldMetadataSys := globalBucketMetadataSys + counting := &corsLookupCountingObjectLayer{ObjectLayer: obj} + setObjectLayer(counting) + globalBucketMetadataSys = NewBucketMetadataSys() + defer func() { + setObjectLayer(oldObjectAPI) + globalBucketMetadataSys = oldMetadataSys + }() + + innerCalled := false + wrapped := corsHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + innerCalled = true + w.WriteHeader(http.StatusNoContent) + })) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/startup-missing/object", nil) + req.Header.Set("Origin", "https://app.example.com") + wrapped.ServeHTTP(rec, req) + + if !innerCalled || rec.Code != http.StatusNoContent { + t.Fatalf("startup miss did not reach inner handler: called=%v status=%d", innerCalled, rec.Code) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("startup miss used permissive global CORS: %q", got) + } + if got := counting.getObjectNInfoCalls.Load(); got != 0 { + t.Fatalf("startup miss performed %d metadata reads", got) + } + if got := globalBucketMetadataSys.Count(); got != 0 { + t.Fatalf("startup miss grew metadataMap to %d", got) + } +} + +// markBucketMetadataLoadFailed records a bucket as one whose metadata failed to +// load at startup while the subsystem is Initialized, modelling the degraded +// state where a real bucket is not resident. Returns a restore function. +func markBucketMetadataLoadFailed(t *testing.T, bucket string) func() { + t.Helper() + sys := globalBucketMetadataSys + if sys == nil { + t.Fatal("globalBucketMetadataSys is nil") + } + sys.Lock() + _, had := sys.loadFailed[bucket] + sys.loadFailed[bucket] = struct{}{} + sys.Unlock() + return func() { + sys.Lock() + if !had { + delete(sys.loadFailed, bucket) + } + sys.Unlock() + } +} + +// TestBucketCorsLoadFailedBucketFailsClosed guards P1: a real bucket whose +// metadata could not be loaded at startup (present in loadFailed, subsystem +// Initialized) must NOT be answered with the permissive global CORS policy. We +// cannot rule out a restrictive per-bucket config for it, so it must fail +// closed — without a synchronous disk read. +func TestBucketCorsLoadFailedBucketFailsClosed(t *testing.T) { + ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{ + t: t, + objAPITest: testBucketCorsLoadFailedBucketFailsClosed, + endpoints: []string{"GetBucketCors"}, + }) +} + +func testBucketCorsLoadFailedBucketFailsClosed(obj ObjectLayer, _ string, _ string, _ http.Handler, _ auth.Credentials, t *testing.T) { + restoreInit := markBucketMetadataInitialized(t) + defer restoreInit() + restoreFail := markBucketMetadataLoadFailed(t, "strict-cors-bucket") + defer restoreFail() + + oldObjectAPI := newObjectLayerFn() + counting := &corsLookupCountingObjectLayer{ObjectLayer: obj} + setObjectLayer(counting) + defer setObjectLayer(oldObjectAPI) + + wrapped := corsHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + + for _, method := range []string{http.MethodGet, http.MethodOptions} { + rec := httptest.NewRecorder() + req := httptest.NewRequest(method, getGetObjectURL("", "strict-cors-bucket", "object"), nil) + req.Header.Set("Origin", "https://app.example.com") + if method == http.MethodOptions { + req.Header.Set("Access-Control-Request-Method", http.MethodGet) + } + wrapped.ServeHTTP(rec, req) + + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("%s: load-failed bucket fell back to global allow-origin %q", method, got) + } + if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "" { + t.Fatalf("%s: load-failed bucket fell back to global credentials %q", method, got) + } + } + if got := counting.getObjectNInfoCalls.Load(); got != 0 { + t.Fatalf("load-failed CORS lookup performed %d synchronous bucket metadata reads", got) + } +} + +// TestBucketCorsInternalBucketFailsClosed guards P2: an Origin-bearing request +// whose first path segment is the reserved .minio.sys namespace must preserve +// GetConfig's errInvalidArgument semantics and fail closed, not fall back to +// the permissive global CORS policy. +func TestBucketCorsInternalBucketFailsClosed(t *testing.T) { + ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{ + t: t, + objAPITest: testBucketCorsInternalBucketFailsClosed, + endpoints: []string{"GetBucketCors"}, + }) +} + +func testBucketCorsInternalBucketFailsClosed(_ ObjectLayer, _ string, _ string, _ http.Handler, _ auth.Credentials, t *testing.T) { + restoreInit := markBucketMetadataInitialized(t) + defer restoreInit() + + wrapped := corsHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, getGetObjectURL("", minioMetaBucket, "object"), nil) + req.Header.Set("Origin", "https://app.example.com") + wrapped.ServeHTTP(rec, req) + + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("internal bucket fell back to global allow-origin %q", got) + } + if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "" { + t.Fatalf("internal bucket fell back to global credentials %q", got) + } +} diff --git a/cmd/bucket-metadata-sys.go b/cmd/bucket-metadata-sys.go index ae2462115a317..fea022736fc0f 100644 --- a/cmd/bucket-metadata-sys.go +++ b/cmd/bucket-metadata-sys.go @@ -51,6 +51,14 @@ type BucketMetadataSys struct { initialized bool group *singleflight.Group metadataMap map[string]BucketMetadata + // loadFailed tracks real buckets whose metadata could not be loaded at + // startup (concurrentLoad) or during a refresh. Such buckets are NOT + // resident in metadataMap even though the subsystem is Initialized, so a + // plain map miss cannot distinguish "not a bucket" from "known bucket whose + // config we could not read". Callers that must fail closed for a real but + // unreadable bucket (e.g. per-bucket CORS) consult this set. It is bounded + // by the number of load failures and is empty in normal operation. + loadFailed map[string]struct{} } // Count returns number of bucket metadata map entries. @@ -67,6 +75,7 @@ func (sys *BucketMetadataSys) Remove(buckets ...string) { for _, bucket := range buckets { sys.group.Forget(bucket) delete(sys.metadataMap, bucket) + delete(sys.loadFailed, bucket) globalBucketMonitor.DeleteBucket(bucket) } sys.Unlock() @@ -84,6 +93,11 @@ func (sys *BucketMetadataSys) RemoveStaleBuckets(diskBuckets set.StringSet) { delete(sys.metadataMap, bucket) globalBucketMonitor.DeleteBucket(bucket) } + for bucket := range sys.loadFailed { + if !diskBuckets.Contains(bucket) { + delete(sys.loadFailed, bucket) + } + } } // Set - sets a new metadata in-memory. @@ -95,6 +109,7 @@ func (sys *BucketMetadataSys) Set(bucket string, meta BucketMetadata) { if !isMinioMetaBucketName(bucket) { sys.Lock() sys.metadataMap[bucket] = meta + delete(sys.loadFailed, bucket) sys.Unlock() } } @@ -379,6 +394,65 @@ func (sys *BucketMetadataSys) GetCorsConfig(bucket string) (*cors.Config, time.T return meta.corsConfig, meta.CorsConfigUpdatedAt, nil } +// GetResidentCorsConfig returns the CORS configuration for the given bucket +// using only bucket metadata that is already resident in memory. Unlike +// GetCorsConfig it never loads metadata from disk and never caches a new +// entry. +// +// The per-request CORS middleware runs before authentication, for every +// Origin-bearing request, using the validated first path segment as the bucket +// name. +// Routing that through GetCorsConfig (which loads and caches) let an +// unauthenticated client grow metadataMap without bound and trigger an +// erasure metadata probe for every distinct, attacker-controlled, +// non-existent name it sent with an Origin header (e.g. /minio/... , /api/... , +// or random buckets). Every bucket that can carry a CORS document is made +// resident when the document is written (Set) and when metadata is loaded at +// startup (Init/concurrentLoad), so a resident-only read is complete for real +// buckets while costing only an in-memory map lookup for everything else. +// +// While bucket metadata is still loading (not yet Initialized) a non-resident +// bucket returns errBucketMetadataNotInitialized so the caller fails closed +// rather than answering with the permissive global policy for a bucket whose +// restrictive CORS document may simply not be loaded yet. +func (sys *BucketMetadataSys) GetResidentCorsConfig(bucket string) (*cors.Config, time.Time, error) { + if isMinioMetaBucketName(bucket) { + // Preserve GetConfig's semantics for the internal namespace: this is + // not a real bucket, and returning a non-errConfigNotFound error makes + // the CORS middleware fail closed rather than answer for .minio.sys + // with the permissive global policy. + return nil, time.Time{}, errInvalidArgument + } + if isReservedOrInvalidBucket(bucket, true) { + return nil, time.Time{}, errConfigNotFound + } + sys.RLock() + meta, ok := sys.metadataMap[bucket] + _, failed := sys.loadFailed[bucket] + initialized := sys.initialized + sys.RUnlock() + if ok { + if meta.corsConfigErr != nil { + return nil, meta.CorsConfigUpdatedAt, meta.corsConfigErr + } + if meta.corsConfig == nil { + return nil, time.Time{}, errConfigNotFound + } + return meta.corsConfig, meta.CorsConfigUpdatedAt, nil + } + // Not resident. Two cases must not be conflated: + // - metadata is still loading (!initialized), or this is a real bucket + // whose metadata failed to load: we cannot rule out a restrictive CORS + // config, so fail closed rather than answer with the global policy. + // - a fully initialized subsystem with no record of the name: it is not a + // bucket that can carry CORS, so fall back to the global policy without + // loading or caching metadata for an arbitrary, client-supplied name. + if !initialized || failed { + return nil, time.Time{}, errBucketMetadataNotInitialized + } + return nil, time.Time{}, errConfigNotFound +} + // GetCorsConfigXML returns the raw stored CORS configuration XML for the // given bucket, preserving the document exactly as it was PUT (including // the S3 xmlns and any unmodeled elements). @@ -576,8 +650,13 @@ func (sys *BucketMetadataSys) concurrentLoad(ctx context.Context, buckets []stri sys.Lock() for i, meta := range bucketMetas { if errs[i] != nil { + // Real bucket whose metadata could not be loaded: record it so + // consumers that must fail closed (per-bucket CORS) can tell it + // apart from a name that is not a bucket at all. + sys.loadFailed[buckets[i]] = struct{}{} continue } + delete(sys.loadFailed, buckets[i]) sys.metadataMap[buckets[i]] = meta } sys.Unlock() @@ -627,6 +706,9 @@ func (sys *BucketMetadataSys) refreshBucketsMetadataLoop(ctx context.Context) { meta, err := loadBucketMetadata(ctx, sys.objAPI, bucket) if err != nil { internalLogIf(ctx, err, logger.WarningKind) + sys.Lock() + sys.loadFailed[bucket] = struct{}{} + sys.Unlock() wait() // wait to proceed to next entry. continue } @@ -637,6 +719,8 @@ func (sys *BucketMetadataSys) refreshBucketsMetadataLoop(ctx context.Context) { updated = true sys.metadataMap[bucket] = meta } + // A successful (re)load clears any earlier load failure. + delete(sys.loadFailed, bucket) sys.Unlock() if updated { @@ -684,6 +768,7 @@ func (sys *BucketMetadataSys) init(ctx context.Context, buckets []string) { func (sys *BucketMetadataSys) Reset() { sys.Lock() clear(sys.metadataMap) + clear(sys.loadFailed) sys.Unlock() } @@ -691,6 +776,7 @@ func (sys *BucketMetadataSys) Reset() { func NewBucketMetadataSys() *BucketMetadataSys { return &BucketMetadataSys{ metadataMap: make(map[string]BucketMetadata), + loadFailed: make(map[string]struct{}), group: &singleflight.Group{}, } } diff --git a/cmd/bucket-object-lock.go b/cmd/bucket-object-lock.go index 33a0ebda5635c..b4ef9292fe8c4 100644 --- a/cmd/bucket-object-lock.go +++ b/cmd/bucket-object-lock.go @@ -25,8 +25,6 @@ import ( "github.com/minio/minio/internal/auth" objectlock "github.com/minio/minio/internal/bucket/object/lock" - "github.com/minio/minio/internal/bucket/replication" - xhttp "github.com/minio/minio/internal/http" "github.com/minio/minio/internal/logger" "github.com/minio/pkg/v3/policy" ) @@ -150,7 +148,11 @@ func enforceRetentionBypassForDelete(ctx context.Context, r *http.Request, bucke } // https://docs.aws.amazon.com/AmazonS3/latest/dev/object-lock-overview.html#object-lock-retention-modes // If you try to delete objects protected by governance mode and have s3:BypassGovernanceRetention, the operation will succeed. - if checkRequestAuthType(ctx, r, policy.BypassGovernanceRetentionAction, bucket, object.ObjectName) != ErrNone { + if reqInfo := logger.GetReqInfo(ctx); reqInfo != nil { + reqInfo.BucketName = bucket + reqInfo.ObjectName = object.ObjectName + } + if authorizeRequest(ctx, r, policy.BypassGovernanceRetentionAction) != ErrNone { return errAuthentication } } @@ -242,7 +244,7 @@ func enforceRetentionBypassForPut(ctx context.Context, r *http.Request, oi Objec // For objects in "Compliance" mode, retention date cannot be shortened, and mode cannot be altered. // For objects with legal hold header set, the s3:PutObjectLegalHold permission is expected to be set // Both legal hold and retention can be applied independently on an object -func checkPutObjectLockAllowed(ctx context.Context, rq *http.Request, bucket, object string, getObjectInfoFn GetObjectInfoFn, retentionPermErr, legalHoldPermErr APIErrorCode) (objectlock.RetMode, objectlock.RetentionDate, objectlock.ObjectLegalHold, APIErrorCode) { +func checkPutObjectLockAllowed(ctx context.Context, rq *http.Request, bucket, object string, getObjectInfoFn GetObjectInfoFn, retentionPermErr, legalHoldPermErr APIErrorCode, replicaTrusted bool) (objectlock.RetMode, objectlock.RetentionDate, objectlock.ObjectLegalHold, APIErrorCode) { var mode objectlock.RetMode var retainDate objectlock.RetentionDate var legalHold objectlock.ObjectLegalHold @@ -269,9 +271,7 @@ func checkPutObjectLockAllowed(ctx context.Context, rq *http.Request, bucket, ob return mode, retainDate, legalHold, toAPIErrorCode(ctx, err) } - replica := rq.Header.Get(xhttp.AmzBucketReplicationStatus) == replication.Replica.String() - - if opts.VersionID != "" && !replica { + if opts.VersionID != "" && !replicaTrusted { if objInfo, err := getObjectInfoFn(ctx, bucket, object, opts); err == nil { r := objectlock.GetObjectRetentionMeta(objInfo.UserDefined) t, err := objectlock.UTCNowNTP() @@ -307,8 +307,8 @@ func checkPutObjectLockAllowed(ctx context.Context, rq *http.Request, bucket, ob if err != nil { return mode, retainDate, legalHold, toAPIErrorCode(ctx, err) } - rMode, rDate, err := objectlock.ParseObjectLockRetentionHeaders(rq.Header) - if err != nil && (!replica || rMode != "" || !rDate.IsZero()) { + rMode, rDate, err := objectlock.ParseObjectLockRetentionHeaders(rq.Header, replicaTrusted) + if err != nil && (!replicaTrusted || rMode != "" || !rDate.IsZero()) { return mode, retainDate, legalHold, toAPIErrorCode(ctx, err) } if retentionPermErr != ErrNone { @@ -316,7 +316,7 @@ func checkPutObjectLockAllowed(ctx context.Context, rq *http.Request, bucket, ob } return rMode, rDate, legalHold, ErrNone } - if replica { // replica inherits retention metadata only from source + if replicaTrusted { // replica inherits retention metadata only from source return "", objectlock.RetentionDate{}, legalHold, ErrNone } if !retentionRequested && retentionCfg.Validity > 0 { diff --git a/cmd/encryption-v1.go b/cmd/encryption-v1.go index 36857851cd354..2da9d229fd1a8 100644 --- a/cmd/encryption-v1.go +++ b/cmd/encryption-v1.go @@ -1049,7 +1049,7 @@ func DecryptObjectInfo(info *ObjectInfo, r *http.Request) (encrypted bool, err e if encrypted { if crypto.SSEC.IsEncrypted(info.UserDefined) { if !crypto.SSEC.IsRequested(headers) && !crypto.SSECopy.IsRequested(headers) { - if r.Header.Get(xhttp.MinIOSourceReplicationRequest) != "true" { + if !isReplicaTrusted(r.Context()) { return encrypted, errEncryptedObject } } diff --git a/cmd/handler-utils.go b/cmd/handler-utils.go index 1e26d897d47b9..8bbaa4cf14542 100644 --- a/cmd/handler-utils.go +++ b/cmd/handler-utils.go @@ -83,7 +83,6 @@ var supportedHeaders = []string{ xhttp.AmzStorageClass, xhttp.AmzObjectTagging, "expires", - xhttp.AmzBucketReplicationStatus, "X-Minio-Replication-Server-Side-Encryption-Sealed-Key", "X-Minio-Replication-Server-Side-Encryption-Seal-Algorithm", "X-Minio-Replication-Server-Side-Encryption-Iv", @@ -332,7 +331,7 @@ func extractReqParams(r *http.Request) map[string]string { m["range"] = rangeField } - if _, ok := r.Header[xhttp.MinIOSourceReplicationRequest]; ok { + if isTrustedReplication(r.Context()) { m[xhttp.MinIOSourceReplicationRequest] = "" } return m diff --git a/cmd/handler-utils_test.go b/cmd/handler-utils_test.go index 6a7bcd6117f9e..5ae2b1619436d 100644 --- a/cmd/handler-utils_test.go +++ b/cmd/handler-utils_test.go @@ -307,7 +307,7 @@ func TestGetCopyObjectMetadataFromHeaderReplication(t *testing.T) { } } -func TestCloneRequestWithoutCopyReplicationHeaders(t *testing.T) { +func TestCloneRequestWithoutReplicationHeaders(t *testing.T) { req, err := http.NewRequest(http.MethodPut, "http://localhost/test", nil) if err != nil { t.Fatal(err) @@ -320,9 +320,12 @@ func TestCloneRequestWithoutCopyReplicationHeaders(t *testing.T) { req.Header.Set(xhttp.MinIOSourceObjectLegalHoldTimestamp, "2026-04-15T10:00:00Z") req.Header.Set(xhttp.MinIOReplicationActualObjectSize, "123") req.Header.Set(ReplicationSsecChecksumHeader, "checksum") + req.Header.Set(xhttp.AmzBucketReplicationStatus, "REPLICA") + req.Header.Set(xhttp.MinIOSourceDeleteMarker, "true") + req.Header.Set("X-Minio-Replication-Server-Side-Encryption-Sealed-Key", "sealed") req.Header.Set("Content-Type", "application/octet-stream") - clone := cloneRequestWithoutCopyReplicationHeaders(req) + clone := cloneRequestWithoutReplicationHeaders(req, t.Context()) if clone == req { t.Fatal("expected cloned request") } @@ -336,6 +339,9 @@ func TestCloneRequestWithoutCopyReplicationHeaders(t *testing.T) { xhttp.MinIOSourceObjectLegalHoldTimestamp, xhttp.MinIOReplicationActualObjectSize, ReplicationSsecChecksumHeader, + xhttp.AmzBucketReplicationStatus, + xhttp.MinIOSourceDeleteMarker, + "X-Minio-Replication-Server-Side-Encryption-Sealed-Key", } { if got := clone.Header.Get(header); got != "" { t.Fatalf("expected %s to be stripped, got %q", header, got) diff --git a/cmd/object-api-options.go b/cmd/object-api-options.go index c20a40b8eabfd..6ea1610c46674 100644 --- a/cmd/object-api-options.go +++ b/cmd/object-api-options.go @@ -41,9 +41,6 @@ func getDefaultOpts(header http.Header, copySource bool, metadata map[string]str opts.ProxyHeaderSet = true opts.ProxyRequest = strings.Join(v, "") == "true" } - if _, ok := header[xhttp.MinIOSourceReplicationRequest]; ok { - opts.ReplicationRequest = true - } opts.Speedtest = header.Get(globalObjectPerfUserMetadata) != "" if copySource { @@ -116,12 +113,15 @@ func getOpts(ctx context.Context, r *http.Request, bucket, object string) (Objec } opts.PartNumber = partNumber opts.VersionID = vid + opts.ReplicationRequest = isTrustedReplication(ctx) - delMarker, err := parseBoolHeader(bucket, object, r.Header, xhttp.MinIOSourceDeleteMarker) - if err != nil { - return opts, err + if opts.ReplicationRequest { + delMarker, err := parseBoolHeader(bucket, object, r.Header, xhttp.MinIOSourceDeleteMarker) + if err != nil { + return opts, err + } + opts.DeleteMarker = delMarker } - opts.DeleteMarker = delMarker replReadyCheck, err := parseBoolHeader(bucket, object, r.Header, xhttp.MinIOCheckDMReplicationReady) if err != nil { @@ -297,20 +297,22 @@ func delOpts(ctx context.Context, r *http.Request, bucket, object string) (opts opts.VersionID = nullVersionID } - delMarker, err := parseBoolHeader(bucket, object, r.Header, xhttp.MinIOSourceDeleteMarker) - if err != nil { - return opts, err - } - opts.DeleteMarker = delMarker - - mtime := strings.TrimSpace(r.Header.Get(xhttp.MinIOSourceMTime)) - if mtime != "" { - opts.MTime, err = time.Parse(time.RFC3339Nano, mtime) + if isTrustedReplication(ctx) { + delMarker, err := parseBoolHeader(bucket, object, r.Header, xhttp.MinIOSourceDeleteMarker) if err != nil { - return opts, InvalidArgument{ - Bucket: bucket, - Object: object, - Err: fmt.Errorf("Unable to parse %s, failed with %w", xhttp.MinIOSourceMTime, err), + return opts, err + } + opts.DeleteMarker = delMarker + + mtime := strings.TrimSpace(r.Header.Get(xhttp.MinIOSourceMTime)) + if mtime != "" { + opts.MTime, err = time.Parse(time.RFC3339Nano, mtime) + if err != nil { + return opts, InvalidArgument{ + Bucket: bucket, + Object: object, + Err: fmt.Errorf("Unable to parse %s, failed with %w", xhttp.MinIOSourceMTime, err), + } } } } @@ -319,10 +321,10 @@ func delOpts(ctx context.Context, r *http.Request, bucket, object string) (opts // get ObjectOptions for PUT calls from encryption headers and metadata func putOptsFromReq(ctx context.Context, r *http.Request, bucket, object string, metadata map[string]string) (opts ObjectOptions, err error) { - return putOpts(ctx, bucket, object, r.Form.Get(xhttp.VersionID), r.Header, metadata) + return putOpts(ctx, bucket, object, r.Form.Get(xhttp.VersionID), r.Header, metadata, isTrustedReplication(ctx)) } -func putOpts(ctx context.Context, bucket, object, vid string, hdrs http.Header, metadata map[string]string) (opts ObjectOptions, err error) { +func putOpts(ctx context.Context, bucket, object, vid string, hdrs http.Header, metadata map[string]string, trustedReplication bool) (opts ObjectOptions, err error) { versioned := globalBucketVersioningSys.PrefixEnabled(bucket, object) versionSuspended := globalBucketVersioningSys.PrefixSuspended(bucket, object) @@ -344,7 +346,7 @@ func putOpts(ctx context.Context, bucket, object, vid string, hdrs http.Header, } } } - opts, err = putOptsFromHeaders(ctx, hdrs, metadata) + opts, err = putOptsFromHeaders(ctx, hdrs, metadata, trustedReplication) if err != nil { return opts, InvalidArgument{ Bucket: bucket, @@ -365,8 +367,15 @@ func putOpts(ctx context.Context, bucket, object, vid string, hdrs http.Header, return opts, nil } -func putOptsFromHeaders(ctx context.Context, hdr http.Header, metadata map[string]string) (opts ObjectOptions, err error) { - mtimeStr := strings.TrimSpace(hdr.Get(xhttp.MinIOSourceMTime)) +func putOptsFromHeaders(ctx context.Context, hdr http.Header, metadata map[string]string, trustedReplication bool) (opts ObjectOptions, err error) { + var mtimeStr, retaintimeStr, lholdtimeStr, tagtimeStr, etag string + if trustedReplication { + mtimeStr = strings.TrimSpace(hdr.Get(xhttp.MinIOSourceMTime)) + retaintimeStr = strings.TrimSpace(hdr.Get(xhttp.MinIOSourceObjectRetentionTimestamp)) + lholdtimeStr = strings.TrimSpace(hdr.Get(xhttp.MinIOSourceObjectLegalHoldTimestamp)) + tagtimeStr = strings.TrimSpace(hdr.Get(xhttp.MinIOSourceTaggingTimestamp)) + etag = strings.TrimSpace(hdr.Get(xhttp.MinIOSourceETag)) + } var mtime time.Time if mtimeStr != "" { mtime, err = time.Parse(time.RFC3339Nano, mtimeStr) @@ -374,7 +383,6 @@ func putOptsFromHeaders(ctx context.Context, hdr http.Header, metadata map[strin return opts, fmt.Errorf("Unable to parse %s, failed with %w", xhttp.MinIOSourceMTime, err) } } - retaintimeStr := strings.TrimSpace(hdr.Get(xhttp.MinIOSourceObjectRetentionTimestamp)) var retaintimestmp time.Time if retaintimeStr != "" { retaintimestmp, err = time.Parse(time.RFC3339, retaintimeStr) @@ -383,7 +391,6 @@ func putOptsFromHeaders(ctx context.Context, hdr http.Header, metadata map[strin } } - lholdtimeStr := strings.TrimSpace(hdr.Get(xhttp.MinIOSourceObjectLegalHoldTimestamp)) var lholdtimestmp time.Time if lholdtimeStr != "" { lholdtimestmp, err = time.Parse(time.RFC3339, lholdtimeStr) @@ -391,7 +398,6 @@ func putOptsFromHeaders(ctx context.Context, hdr http.Header, metadata map[strin return opts, fmt.Errorf("Unable to parse %s, failed with %w", xhttp.MinIOSourceObjectLegalHoldTimestamp, err) } } - tagtimeStr := strings.TrimSpace(hdr.Get(xhttp.MinIOSourceTaggingTimestamp)) var taggingtimestmp time.Time if tagtimeStr != "" { taggingtimestmp, err = time.Parse(time.RFC3339, tagtimeStr) @@ -404,7 +410,6 @@ func putOptsFromHeaders(ctx context.Context, hdr http.Header, metadata map[strin metadata = make(map[string]string) } - etag := strings.TrimSpace(hdr.Get(xhttp.MinIOSourceETag)) if crypto.S3KMS.IsRequested(hdr) { keyID, context, err := crypto.S3KMS.ParseHTTP(hdr) if err != nil { @@ -419,6 +424,7 @@ func putOptsFromHeaders(ctx context.Context, hdr http.Header, metadata map[strin UserDefined: metadata, MTime: mtime, PreserveETag: etag, + ReplicationRequest: trustedReplication, } return op, nil } @@ -429,6 +435,7 @@ func putOptsFromHeaders(ctx context.Context, hdr http.Header, metadata map[strin } opts.MTime = mtime + opts.ReplicationRequest = trustedReplication opts.ReplicationSourceLegalholdTimestamp = lholdtimestmp opts.ReplicationSourceRetentionTimestamp = retaintimestmp opts.ReplicationSourceTaggingTimestamp = taggingtimestmp @@ -454,12 +461,17 @@ func copySrcOpts(ctx context.Context, r *http.Request, bucket, object string) (O if err != nil { return opts, err } + opts.ReplicationRequest = isReplicaTrusted(ctx) return opts, nil } // get ObjectOptions for CompleteMultipart calls func completeMultipartOpts(ctx context.Context, r *http.Request, bucket, object string) (opts ObjectOptions, err error) { - mtimeStr := strings.TrimSpace(r.Header.Get(xhttp.MinIOSourceMTime)) + trustedReplication := isTrustedReplication(ctx) + var mtimeStr string + if trustedReplication { + mtimeStr = strings.TrimSpace(r.Header.Get(xhttp.MinIOSourceMTime)) + } var mtime time.Time if mtimeStr != "" { mtime, err = time.Parse(time.RFC3339Nano, mtimeStr) @@ -495,12 +507,12 @@ func completeMultipartOpts(ctx context.Context, r *http.Request, bucket, object } } } - if _, ok := r.Header[xhttp.MinIOSourceReplicationRequest]; ok { + if trustedReplication { opts.ReplicationRequest = true opts.UserDefined[ReservedMetadataPrefix+"Actual-Object-Size"] = r.Header.Get(xhttp.MinIOReplicationActualObjectSize) - } - if r.Header.Get(ReplicationSsecChecksumHeader) != "" { - opts.UserDefined[ReplicationSsecChecksumHeader] = r.Header.Get(ReplicationSsecChecksumHeader) + if r.Header.Get(ReplicationSsecChecksumHeader) != "" { + opts.UserDefined[ReplicationSsecChecksumHeader] = r.Header.Get(ReplicationSsecChecksumHeader) + } } return opts, nil } diff --git a/cmd/object-handlers.go b/cmd/object-handlers.go index cd4917dd51a27..00143dcd68909 100644 --- a/cmd/object-handlers.go +++ b/cmd/object-handlers.go @@ -356,6 +356,18 @@ func (api objectAPIHandlers) getObjectHandler(ctx context.Context, objectAPI Obj writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL) return } + if hasReplicationMarkerHeader(r.Header) { + trusted := hasReplicationMarker(r.Header) && + replicationPermissionAllowed(ctx, r, bucket, object, policy.ReplicateObjectAction) + ctx, r = applyReplicationTrust(ctx, r, trusted, trusted) + if trusted { + opts, err = getOpts(ctx, r, bucket, object) + if err != nil { + writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) + return + } + } + } getObjectNInfo := objectAPI.GetObjectNInfo @@ -494,8 +506,8 @@ func (api objectAPIHandlers) getObjectHandler(ctx context.Context, objectAPI Obj } // filter object lock metadata if permission does not permit - getRetPerms := checkRequestAuthType(ctx, r, policy.GetObjectRetentionAction, bucket, object) - legalHoldPerms := checkRequestAuthType(ctx, r, policy.GetObjectLegalHoldAction, bucket, object) + getRetPerms := authorizeRequest(ctx, r, policy.GetObjectRetentionAction) + legalHoldPerms := authorizeRequest(ctx, r, policy.GetObjectLegalHoldAction) // filter object lock metadata if permission does not permit objInfo.UserDefined = objectlock.FilterObjectLockMetadata(objInfo.UserDefined, getRetPerms != ErrNone, legalHoldPerms != ErrNone) @@ -599,10 +611,16 @@ func (api objectAPIHandlers) getObjectAttributesHandler(ctx context.Context, obj writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL) return } + if hasReplicationMarkerHeader(r.Header) { + trusted := hasReplicationMarker(r.Header) && + replicationPermissionAllowed(ctx, r, bucket, object, policy.ReplicateObjectAction) + ctx, r = applyReplicationTrust(ctx, r, trusted, trusted) + opts.ReplicationRequest = trusted + } objInfo, err := objectAPI.GetObjectInfo(ctx, bucket, object, opts) if err != nil { - s3Error = checkRequestAuthType(ctx, r, policy.ListBucketAction, bucket, object) + s3Error = authorizeRequest(ctx, r, policy.ListBucketAction) if s3Error == ErrNone { writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) return @@ -622,9 +640,7 @@ func (api objectAPIHandlers) getObjectAttributesHandler(ctx context.Context, obj // Only a caller authorized to replicate this object may read SSE-C // attributes without presenting the customer key. The header alone is // client controlled, so it cannot stand in for that authorization. - trustedReplicationRequest := r.Header.Get(xhttp.MinIOSourceReplicationRequest) == "true" && - checkRequestAuthType(ctx, r, policy.ReplicateObjectAction, bucket, object) == ErrNone - if crypto.SSEC.IsEncrypted(objInfo.UserDefined) && !trustedReplicationRequest { + if crypto.SSEC.IsEncrypted(objInfo.UserDefined) && !isReplicaTrusted(ctx) { if _, err = crypto.SSEC.UnsealObjectKey(r.Header, objInfo.UserDefined, bucket, object); err != nil { writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) return @@ -800,6 +816,18 @@ func (api objectAPIHandlers) headObjectHandler(ctx context.Context, objectAPI Ob writeErrorResponseHeadersOnly(w, errorCodes.ToAPIErr(s3Error)) return } + if hasReplicationMarkerHeader(r.Header) { + trusted := hasReplicationMarker(r.Header) && + replicationPermissionAllowed(ctx, r, bucket, object, policy.ReplicateObjectAction) + ctx, r = applyReplicationTrust(ctx, r, trusted, trusted) + if trusted { + opts, err = getOpts(ctx, r, bucket, object) + if err != nil { + writeErrorResponseHeadersOnly(w, toAPIError(ctx, err)) + return + } + } + } // Get request range. var rs *HTTPRangeSpec @@ -911,8 +939,8 @@ func (api objectAPIHandlers) headObjectHandler(ctx context.Context, objectAPI Ob } // filter object lock metadata if permission does not permit - getRetPerms := checkRequestAuthType(ctx, r, policy.GetObjectRetentionAction, bucket, object) - legalHoldPerms := checkRequestAuthType(ctx, r, policy.GetObjectLegalHoldAction, bucket, object) + getRetPerms := authorizeRequest(ctx, r, policy.GetObjectRetentionAction) + legalHoldPerms := authorizeRequest(ctx, r, policy.GetObjectLegalHoldAction) // filter object lock metadata if permission does not permit objInfo.UserDefined = objectlock.FilterObjectLockMetadata(objInfo.UserDefined, getRetPerms != ErrNone, legalHoldPerms != ErrNone) @@ -938,10 +966,12 @@ func (api objectAPIHandlers) headObjectHandler(ctx context.Context, objectAPI Ob w.Header().Set(xhttp.AmzServerSideEncryptionKmsContext, kmsCtx) } case crypto.SSEC: - // Validate the SSE-C Key set in the header. - if _, err = crypto.SSEC.UnsealObjectKey(r.Header, objInfo.UserDefined, bucket, object); err != nil { - writeErrorResponseHeadersOnly(w, toAPIError(ctx, err)) - return + if !isReplicaTrusted(ctx) { + // Validate the SSE-C Key set in the header for ordinary reads. + if _, err = crypto.SSEC.UnsealObjectKey(r.Header, objInfo.UserDefined, bucket, object); err != nil { + writeErrorResponseHeadersOnly(w, toAPIError(ctx, err)) + return + } } w.Header().Set(xhttp.AmzServerSideEncryptionCustomerAlgorithm, r.Header.Get(xhttp.AmzServerSideEncryptionCustomerAlgorithm)) w.Header().Set(xhttp.AmzServerSideEncryptionCustomerKeyMD5, r.Header.Get(xhttp.AmzServerSideEncryptionCustomerKeyMD5)) @@ -1093,31 +1123,6 @@ func getCpObjMetadataFromHeader(ctx context.Context, r *http.Request, userMeta m return defaultMeta, nil } -func cloneRequestWithoutCopyReplicationHeaders(r *http.Request) *http.Request { - if r == nil { - return nil - } - - clone := new(http.Request) - *clone = *r - clone.Header = r.Header.Clone() - - for _, header := range []string{ - xhttp.MinIOSourceReplicationRequest, - xhttp.MinIOSourceETag, - xhttp.MinIOSourceMTime, - xhttp.MinIOSourceTaggingTimestamp, - xhttp.MinIOSourceObjectRetentionTimestamp, - xhttp.MinIOSourceObjectLegalHoldTimestamp, - xhttp.MinIOReplicationActualObjectSize, - ReplicationSsecChecksumHeader, - } { - clone.Header.Del(header) - } - - return clone -} - func copyDestinationSSEHeaders(h http.Header) http.Header { dst := h.Clone() dst.Del(xhttp.AmzServerSideEncryptionCopyCustomerAlgorithm) @@ -1303,28 +1308,30 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrInvalidStorageClass), r.URL) return } - allowReplicationMetadata := false - if r.Header.Get(xhttp.AmzBucketReplicationStatus) == replication.Replica.String() { - if s3Error := checkRequestAuthType(ctx, r, policy.ReplicateObjectAction, dstBucket, dstObject); s3Error != ErrNone { - writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL) - return - } - allowReplicationMetadata = true + rawReplica := hasReplicaStatus(r.Header) + markerExact := hasReplicationMarker(r.Header) + replicationPermitted := false + if rawReplica || markerExact { + replicationPermitted = replicationPermissionAllowed(ctx, r, dstBucket, dstObject, policy.ReplicateObjectAction) + } + if rawReplica && !replicationPermitted { + writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL) + return } - trustedReplicationRequest := allowReplicationMetadata && r.Header.Get(xhttp.MinIOSourceReplicationRequest) == "true" - optsReq := r - if !trustedReplicationRequest { - optsReq = cloneRequestWithoutCopyReplicationHeaders(r) + trustedReplication := markerExact && replicationPermitted + replicaTrusted := trustedReplication && rawReplica + if hasReplicationRequestHeaders(r.Header) { + ctx, r = applyReplicationTrust(ctx, r, trustedReplication, replicaTrusted) } + allowReplicationMetadata := replicaTrusted // Check if bucket encryption is enabled sseConfig, _ := globalBucketSSEConfigSys.Get(dstBucket) sseConfig.Apply(r.Header, sse.ApplyOptions{ AutoEncrypt: globalAutoEncryption, }) - var srcOpts, dstOpts ObjectOptions - srcOpts, err = copySrcOpts(ctx, optsReq, srcBucket, srcObject) + srcOpts, err = copySrcOpts(ctx, r, srcBucket, srcObject) if err != nil { writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) return @@ -1336,14 +1343,14 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re VersionID: srcOpts.VersionID, Versioned: srcOpts.Versioned, VersionSuspended: srcOpts.VersionSuspended, - ReplicationRequest: trustedReplicationRequest, + ReplicationRequest: replicaTrusted, } getSSE := encrypt.SSE(srcOpts.ServerSideEncryption) if getSSE != srcOpts.ServerSideEncryption { getOpts.ServerSideEncryption = getSSE } - dstOpts, err = copyDstOpts(ctx, optsReq, dstBucket, dstObject, nil) + dstOpts, err = copyDstOpts(ctx, r, dstBucket, dstObject, nil) if err != nil { writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) return @@ -1353,7 +1360,7 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re getObjectNInfo := objectAPI.GetObjectNInfo checkCopyPrecondFn := func(o ObjectInfo) bool { - if _, err := DecryptObjectInfo(&o, optsReq); err != nil { + if _, err := DecryptObjectInfo(&o, r); err != nil { writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) return true } @@ -1465,7 +1472,7 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re return } // Encryption parameters not present for this object. - if crypto.SSEC.IsEncrypted(srcInfo.UserDefined) && !crypto.SSECopy.IsRequested(r.Header) && !trustedReplicationRequest { + if crypto.SSEC.IsEncrypted(srcInfo.UserDefined) && !crypto.SSECopy.IsRequested(r.Header) && !replicaTrusted { writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrInvalidSSECustomerAlgorithm), r.URL) return } @@ -1716,7 +1723,7 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re getObjectInfo := objectAPI.GetObjectInfo // apply default bucket configuration/governance headers for dest side. - retentionMode, retentionDate, legalHold, s3Err := checkPutObjectLockAllowed(ctx, r, dstBucket, dstObject, getObjectInfo, retPerms, holdPerms) + retentionMode, retentionDate, legalHold, s3Err := checkPutObjectLockAllowed(ctx, r, dstBucket, dstObject, getObjectInfo, retPerms, holdPerms, replicaTrusted) if s3Err == ErrNone && retentionMode.Valid() { lastretentionTimestamp := srcInfo.UserDefined[ReservedMetadataPrefixLower+ObjectLockRetentionTimestamp] if dstOpts.ReplicationRequest { @@ -2076,18 +2083,32 @@ func (api objectAPIHandlers) PutObjectHandler(w http.ResponseWriter, r *http.Req writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) return } - if r.Header.Get(xhttp.AmzBucketReplicationStatus) == replication.Replica.String() { - if s3Err = isPutActionAllowed(ctx, getRequestAuthType(r), bucket, object, r, policy.ReplicateObjectAction); s3Err != ErrNone { - writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Err), r.URL) - return - } + rawReplica := hasReplicaStatus(r.Header) + markerExact := hasReplicationMarker(r.Header) + replicationPermitted := false + if rawReplica || markerExact { + replicationPermitted = replicationPermissionAllowed(ctx, r, bucket, object, policy.ReplicateObjectAction) + } + if rawReplica && !replicationPermitted { + writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL) + return + } + trustedReplication := markerExact && replicationPermitted + replicaTrusted := trustedReplication && rawReplica + if hasReplicationRequestHeaders(r.Header) { + ctx, r = applyReplicationTrust(ctx, r, trustedReplication, replicaTrusted) + } + if replicaTrusted { if err = extractReplicationMetadataFromMime(ctx, textproto.MIMEHeader(r.Header), metadata); err != nil { writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) return } + metadata[xhttp.AmzBucketReplicationStatus] = replication.Replica.String() metadata[ReservedMetadataPrefixLower+ReplicaStatus] = replication.Replica.String() metadata[ReservedMetadataPrefixLower+ReplicaTimestamp] = UTCNow().Format(time.RFC3339Nano) defer globalReplicationStats.Load().UpdateReplicaStat(bucket, size) + } else { + delete(metadata, xhttp.AmzBucketReplicationStatus) } // Check if bucket encryption is enabled @@ -2186,7 +2207,7 @@ func (api objectAPIHandlers) PutObjectHandler(w http.ResponseWriter, r *http.Req getObjectInfo := objectAPI.GetObjectInfo - retentionMode, retentionDate, legalHold, s3Err := checkPutObjectLockAllowed(ctx, r, bucket, object, getObjectInfo, retPerms, holdPerms) + retentionMode, retentionDate, legalHold, s3Err := checkPutObjectLockAllowed(ctx, r, bucket, object, getObjectInfo, retPerms, holdPerms, isReplicaTrusted(ctx)) if s3Err == ErrNone && retentionMode.Valid() { metadata[strings.ToLower(xhttp.AmzObjectLockMode)] = string(retentionMode) metadata[strings.ToLower(xhttp.AmzObjectLockRetainUntilDate)] = amztime.ISO8601Format(retentionDate.UTC()) @@ -2491,14 +2512,20 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h sseConfig.Apply(r.Header, sse.ApplyOptions{ AutoEncrypt: globalAutoEncryption, }) + rawReplica := hasReplicaStatus(r.Header) + markerExact := hasReplicationMarker(r.Header) + trustedRequestCtx := withReplicationTrust(ctx, true, rawReplica) + trustedRequest := r.WithContext(trustedRequestCtx) + cleanRequestCtx := withReplicationTrust(ctx, false, false) + cleanRequest := cloneRequestWithoutReplicationHeaders(r, cleanRequestCtx) + trustedReqParams := extractReqParams(trustedRequest) + cleanReqParams := extractReqParams(cleanRequest) retPerms := isPutActionAllowed(ctx, getRequestAuthType(r), bucket, object, r, policy.PutObjectRetentionAction) holdPerms := isPutActionAllowed(ctx, getRequestAuthType(r), bucket, object, r, policy.PutObjectLegalHoldAction) getObjectInfo := objectAPI.GetObjectInfo - // These are static for all objects extracted. - reqParams := extractReqParams(r) respElements := map[string]string{ "requestId": w.Header().Get(xhttp.AmzRequestID), "nodeId": w.Header().Get(xhttp.AmzRequestHostID), @@ -2513,13 +2540,31 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Err), r.URL) return errors.New(errorCodes.ToAPIErr(s3Err).Code) } + replicationPermitted := false + if rawReplica || markerExact { + replicationPermitted = replicationPermissionAllowed(ctx, r, bucket, object, policy.ReplicateObjectAction) + } + if rawReplica && !replicationPermitted { + s3Err = ErrAccessDenied + return errors.New(errorCodes.ToAPIErr(s3Err).Code) + } + entryTrusted := markerExact && replicationPermitted + replicaTrusted := entryTrusted && rawReplica + entryCtx := cleanRequestCtx + entryReq := cleanRequest + reqParams := cleanReqParams + if entryTrusted { + entryCtx = trustedRequestCtx + entryReq = trustedRequest + reqParams = trustedReqParams + } metadata := map[string]string{ xhttp.AmzStorageClass: sc, // save same storage-class as incoming stream. } actualSize := size var idxCb func() []byte - if isCompressible(r.Header, object) && size > minCompressibleSize { + if isCompressible(entryReq.Header, object) && size > minCompressibleSize { // Storing the compression metadata. metadata[ReservedMetadataPrefix+"compression"] = compressionAlgorithmV2 metadata[ReservedMetadataPrefix+"actual-size"] = strconv.FormatInt(size, 10) @@ -2530,7 +2575,7 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h } // Set compression metrics. - wantEncryption := crypto.Requested(r.Header) + wantEncryption := crypto.Requested(entryReq.Header) s2c, cb := newS2CompressReader(actualReader, actualSize, wantEncryption) defer s2c.Close() idxCb = cb @@ -2546,15 +2591,11 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h rawReader := hashReader pReader := NewPutObjReader(rawReader) - allowReplicationMetadata := false - if r.Header.Get(xhttp.AmzBucketReplicationStatus) == replication.Replica.String() { - if s3Err = isPutActionAllowed(ctx, getRequestAuthType(r), bucket, object, r, policy.ReplicateObjectAction); s3Err != ErrNone { - return errors.New(errorCodes.ToAPIErr(s3Err).Code) - } - allowReplicationMetadata = true - if err = extractReplicationMetadataFromMime(ctx, textproto.MIMEHeader(r.Header), metadata); err != nil { + if replicaTrusted { + if err = extractReplicationMetadataFromMime(entryCtx, textproto.MIMEHeader(entryReq.Header), metadata); err != nil { return err } + metadata[xhttp.AmzBucketReplicationStatus] = replication.Replica.String() metadata[ReservedMetadataPrefixLower+ReplicaStatus] = replication.Replica.String() metadata[ReservedMetadataPrefixLower+ReplicaTimestamp] = UTCNow().Format(time.RFC3339Nano) } @@ -2576,22 +2617,25 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h hdrs.Set(k, v) } } - m, err := extractMetadata(ctx, textproto.MIMEHeader(hdrs)) + if !entryTrusted { + stripReplicationRequestHeaders(hdrs) + } + m, err := extractMetadata(entryCtx, textproto.MIMEHeader(hdrs)) if err != nil { return err } - if allowReplicationMetadata { - if err = extractReplicationMetadataFromMime(ctx, textproto.MIMEHeader(hdrs), m); err != nil { + if replicaTrusted { + if err = extractReplicationMetadataFromMime(entryCtx, textproto.MIMEHeader(hdrs), m); err != nil { return err } } maps.Copy(metadata, m) } else { - versionID = r.Form.Get(xhttp.VersionID) - hdrs = r.Header + versionID = entryReq.Form.Get(xhttp.VersionID) + hdrs = entryReq.Header } - opts, err := putOpts(ctx, bucket, object, versionID, hdrs, metadata) + opts, err := putOpts(entryCtx, bucket, object, versionID, hdrs, metadata, entryTrusted) if err != nil { return err } @@ -2602,7 +2646,7 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h } opts.IndexCB = idxCb - retentionMode, retentionDate, legalHold, s3err := checkPutObjectLockAllowed(ctx, r, bucket, object, getObjectInfo, retPerms, holdPerms) + retentionMode, retentionDate, legalHold, s3err := checkPutObjectLockAllowed(entryCtx, entryReq, bucket, object, getObjectInfo, retPerms, holdPerms, replicaTrusted) if s3err == ErrNone && retentionMode.Valid() { metadata[strings.ToLower(xhttp.AmzObjectLockMode)] = string(retentionMode) metadata[strings.ToLower(xhttp.AmzObjectLockRetainUntilDate)] = amztime.ISO8601Format(retentionDate.UTC()) @@ -2623,12 +2667,12 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h } var objectEncryptionKey crypto.ObjectKey - if crypto.Requested(r.Header) { - if crypto.SSECopy.IsRequested(r.Header) { + if crypto.Requested(entryReq.Header) { + if crypto.SSECopy.IsRequested(entryReq.Header) { return errInvalidEncryptionParameters } - reader, objectEncryptionKey, err = EncryptRequest(hashReader, r, bucket, object, metadata) + reader, objectEncryptionKey, err = EncryptRequest(hashReader, entryReq, bucket, object, metadata) if err != nil { return err } @@ -2673,7 +2717,7 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h } origETag := objInfo.ETag - objInfo.ETag = getDecryptedETag(r.Header, objInfo, false) + objInfo.ETag = getDecryptedETag(entryReq.Header, objInfo, false) if dsc := mustReplicate(ctx, bucket, object, getMustReplicateOptions(metadata, "", "", replication.ObjectReplicationType, opts)); dsc.ReplicateAny() { scheduleReplication(ctx, objInfo, objectAPI, dsc, replication.ObjectReplicationType) @@ -2686,8 +2730,8 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h Object: objInfo, ReqParams: reqParams, RespElements: respElements, - UserAgent: r.UserAgent(), - Host: handlers.GetSourceIP(r), + UserAgent: entryReq.UserAgent(), + Host: handlers.GetSourceIP(entryReq), } sendEvent(evt) @@ -2757,12 +2801,20 @@ func (api objectAPIHandlers) DeleteObjectHandler(w http.ResponseWriter, r *http. return } - replica := r.Header.Get(xhttp.AmzBucketReplicationStatus) == replication.Replica.String() - if replica { - if s3Error := checkRequestAuthType(ctx, r, policy.ReplicateDeleteAction, bucket, object); s3Error != ErrNone { - writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL) - return - } + rawReplica := hasReplicaStatus(r.Header) + markerExact := hasReplicationMarker(r.Header) + replicationPermitted := false + if rawReplica || markerExact { + replicationPermitted = replicationPermissionAllowed(ctx, r, bucket, object, policy.ReplicateDeleteAction) + } + if rawReplica && !replicationPermitted { + writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL) + return + } + trustedReplication := markerExact && replicationPermitted + replica := trustedReplication && rawReplica + if hasReplicationRequestHeaders(r.Header) { + ctx, r = applyReplicationTrust(ctx, r, trustedReplication, replica) } if globalDNSConfig != nil { diff --git a/cmd/object-multipart-handlers.go b/cmd/object-multipart-handlers.go index 37ff9f8808ac3..546ddf54e02c1 100644 --- a/cmd/object-multipart-handlers.go +++ b/cmd/object-multipart-handlers.go @@ -171,6 +171,21 @@ func (api objectAPIHandlers) NewMultipartUploadHandler(w http.ResponseWriter, r writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL) return } + rawReplica := hasReplicaStatus(r.Header) + markerExact := hasReplicationMarker(r.Header) + replicationPermitted := false + if rawReplica || markerExact { + replicationPermitted = replicationPermissionAllowed(ctx, r, bucket, object, policy.ReplicateObjectAction) + } + if rawReplica && !replicationPermitted { + writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL) + return + } + trustedReplication := markerExact && replicationPermitted + replicaTrusted := trustedReplication && rawReplica + if hasReplicationRequestHeaders(r.Header) { + ctx, r = applyReplicationTrust(ctx, r, trustedReplication, replicaTrusted) + } // Check if bucket encryption is enabled sseConfig, _ := globalBucketSSEConfigSys.Get(bucket) @@ -205,7 +220,6 @@ func (api objectAPIHandlers) NewMultipartUploadHandler(w http.ResponseWriter, r return } - _, sourceReplReq := r.Header[xhttp.MinIOSourceReplicationRequest] ssecRepHeaders := []string{ "X-Minio-Replication-Server-Side-Encryption-Seal-Algorithm", "X-Minio-Replication-Server-Side-Encryption-Sealed-Key", @@ -218,7 +232,7 @@ func (api objectAPIHandlers) NewMultipartUploadHandler(w http.ResponseWriter, r break } } - if !ssecRep || !sourceReplReq { + if !ssecRep || !replicaTrusted { if err = setEncryptionMetadata(r, bucket, object, encMetadata); err != nil { writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) return @@ -242,24 +256,23 @@ func (api objectAPIHandlers) NewMultipartUploadHandler(w http.ResponseWriter, r return } } - if r.Header.Get(xhttp.AmzBucketReplicationStatus) == replication.Replica.String() { - if s3Err := isPutActionAllowed(ctx, getRequestAuthType(r), bucket, object, r, policy.ReplicateObjectAction); s3Err != ErrNone { - writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Err), r.URL) - return - } + if replicaTrusted { if err = extractReplicationMetadataFromMime(ctx, textproto.MIMEHeader(r.Header), metadata); err != nil { writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) return } + metadata[xhttp.AmzBucketReplicationStatus] = replication.Replica.String() metadata[ReservedMetadataPrefixLower+ReplicaStatus] = replication.Replica.String() metadata[ReservedMetadataPrefixLower+ReplicaTimestamp] = UTCNow().Format(time.RFC3339Nano) + } else { + delete(metadata, xhttp.AmzBucketReplicationStatus) } retPerms := isPutActionAllowed(ctx, getRequestAuthType(r), bucket, object, r, policy.PutObjectRetentionAction) holdPerms := isPutActionAllowed(ctx, getRequestAuthType(r), bucket, object, r, policy.PutObjectLegalHoldAction) getObjectInfo := objectAPI.GetObjectInfo - retentionMode, retentionDate, legalHold, s3Err := checkPutObjectLockAllowed(ctx, r, bucket, object, getObjectInfo, retPerms, holdPerms) + retentionMode, retentionDate, legalHold, s3Err := checkPutObjectLockAllowed(ctx, r, bucket, object, getObjectInfo, retPerms, holdPerms, replicaTrusted) if s3Err == ErrNone && retentionMode.Valid() { metadata[strings.ToLower(xhttp.AmzObjectLockMode)] = string(retentionMode) metadata[strings.ToLower(xhttp.AmzObjectLockRetainUntilDate)] = amztime.ISO8601Format(retentionDate.UTC()) @@ -408,6 +421,14 @@ func (api objectAPIHandlers) CopyObjectPartHandler(w http.ResponseWriter, r *htt writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL) return } + if hasReplicaStatus(r.Header) && + !replicationPermissionAllowed(ctx, r, dstBucket, dstObject, policy.ReplicateObjectAction) { + writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL) + return + } + if hasReplicationRequestHeaders(r.Header) { + ctx, r = applyReplicationTrust(ctx, r, false, false) + } uploadID := r.Form.Get(xhttp.UploadID) partIDString := r.Form.Get(xhttp.PartNumber) @@ -849,6 +870,22 @@ func (api objectAPIHandlers) PutObjectPartHandler(w http.ResponseWriter, r *http writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) return } + rawReplica := hasReplicaStatus(r.Header) + markerExact := hasReplicationMarker(r.Header) + replicationPermitted := false + if rawReplica || markerExact { + replicationPermitted = replicationPermissionAllowed(ctx, r, bucket, object, policy.ReplicateObjectAction) + } + if rawReplica && !replicationPermitted { + writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL) + return + } + trustedReplication := markerExact && replicationPermitted + storedReplica := mi.UserDefined[xhttp.AmzBucketReplicationStatus] == replication.Replica.String() + replicaTrusted := trustedReplication && storedReplica + if hasReplicationRequestHeaders(r.Header) { + ctx, r = applyReplicationTrust(ctx, r, trustedReplication, replicaTrusted) + } // Read compression metadata preserved in the init multipart for the decision. _, isCompressed := mi.UserDefined[ReservedMetadataPrefix+"compression"] @@ -917,11 +954,9 @@ func (api objectAPIHandlers) PutObjectPartHandler(w http.ResponseWriter, r *http pReader.setChecksumReader(checksumReader) _, isEncrypted := crypto.IsEncrypted(mi.UserDefined) - _, replicationStatus := mi.UserDefined[xhttp.AmzBucketReplicationStatus] - _, sourceReplReq := r.Header[xhttp.MinIOSourceReplicationRequest] var objectEncryptionKey crypto.ObjectKey if isEncrypted { - if !crypto.SSEC.IsRequested(r.Header) && crypto.SSEC.IsEncrypted(mi.UserDefined) && !replicationStatus { + if !crypto.SSEC.IsRequested(r.Header) && crypto.SSEC.IsEncrypted(mi.UserDefined) && !replicaTrusted { writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrSSEMultipartEncrypted), r.URL) return } @@ -941,7 +976,7 @@ func (api objectAPIHandlers) PutObjectPartHandler(w http.ResponseWriter, r *http } } - if !sourceReplReq || !crypto.SSEC.IsEncrypted(mi.UserDefined) { + if !replicaTrusted || !crypto.SSEC.IsEncrypted(mi.UserDefined) { // Calculating object encryption key key, err = decryptObjectMeta(key, bucket, object, mi.UserDefined) if err != nil { @@ -1000,7 +1035,7 @@ func (api objectAPIHandlers) PutObjectPartHandler(w http.ResponseWriter, r *http } opts.IndexCB = idxCb - opts.ReplicationRequest = sourceReplReq + opts.ReplicationRequest = trustedReplication putObjectPart := objectAPI.PutObjectPart partInfo, err := putObjectPart(ctx, bucket, object, uploadID, partID, pReader, opts) @@ -1075,6 +1110,20 @@ func (api objectAPIHandlers) CompleteMultipartUploadHandler(w http.ResponseWrite writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL) return } + rawReplica := hasReplicaStatus(r.Header) + markerExact := hasReplicationMarker(r.Header) + replicationPermitted := false + if rawReplica || markerExact { + replicationPermitted = replicationPermissionAllowed(ctx, r, bucket, object, policy.ReplicateObjectAction) + } + if rawReplica && !replicationPermitted { + writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL) + return + } + trustedReplication := markerExact && replicationPermitted + if hasReplicationRequestHeaders(r.Header) { + ctx, r = applyReplicationTrust(ctx, r, trustedReplication, trustedReplication && rawReplica) + } // Get upload id. uploadID, _, _, _, s3Error := getObjectResources(r.Form) @@ -1117,7 +1166,7 @@ func (api objectAPIHandlers) CompleteMultipartUploadHandler(w http.ResponseWrite return } - if _, _, _, s3Err := checkPutObjectLockAllowed(ctx, r, bucket, object, objectAPI.GetObjectInfo, ErrNone, ErrNone); s3Err != ErrNone { + if _, _, _, s3Err := checkPutObjectLockAllowed(ctx, r, bucket, object, objectAPI.GetObjectInfo, ErrNone, ErrNone, false); s3Err != ErrNone { writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Err), r.URL) return } @@ -1200,7 +1249,7 @@ func (api objectAPIHandlers) CompleteMultipartUploadHandler(w http.ResponseWrite if dsc := mustReplicate(ctx, bucket, object, objInfo.getMustReplicateOptions(replication.ObjectReplicationType, opts)); dsc.ReplicateAny() { scheduleReplication(ctx, objInfo, objectAPI, dsc, replication.ObjectReplicationType) } - if _, ok := r.Header[xhttp.MinIOSourceReplicationRequest]; ok { + if isTrustedReplication(ctx) { actualSize, _ := objInfo.GetActualSize() defer globalReplicationStats.Load().UpdateReplicaStat(bucket, actualSize) } diff --git a/cmd/post-policy_test.go b/cmd/post-policy_test.go index 9a02ca8464f0e..7fb84dbd66b7e 100644 --- a/cmd/post-policy_test.go +++ b/cmd/post-policy_test.go @@ -33,6 +33,7 @@ import ( "time" "github.com/dustin/go-humanize" + xhttp "github.com/minio/minio/internal/http" ) const ( @@ -184,6 +185,49 @@ func TestPostPolicyBucketHandler(t *testing.T) { ExecObjectLayerTest(t, testPostPolicyBucketHandler) } +func TestPostPolicyCannotForgeReplicationStatus(t *testing.T) { + ExecObjectLayerTest(t, testPostPolicyCannotForgeReplicationStatus) +} + +func testPostPolicyCannotForgeReplicationStatus(obj ObjectLayer, instanceType string, t TestErrHandler) { + if err := newTestConfig(globalMinioDefaultRegion, obj); err != nil { + t.Fatalf("Initializing config.json failed") + } + bucketName := getRandomBucketName() + if err := obj.MakeBucket(context.Background(), bucketName, MakeBucketOptions{}); err != nil { + t.Fatalf("%s: make bucket: %v", instanceType, err) + } + apiRouter := initTestAPIEndPoints(obj, []string{"PostPolicy"}) + credentials := globalActiveCred + now := UTCNow() + region := globalMinioDefaultRegion + objectPrefix := "post-policy-replication-status" + policyBytes := buildGenericPolicy(now, credentials.AccessKey, region, bucketName, objectPrefix, false) + policyText := strings.TrimSuffix(string(policyBytes), "]}") + + `,["eq","$x-amz-replication-status","REPLICA"]]}` + req, err := newPostRequestV4Generic("", bucketName, objectPrefix, []byte("post policy payload"), + credentials.AccessKey, credentials.SecretKey, region, now, []byte(policyText), + map[string]string{xhttp.AmzBucketReplicationStatus: "REPLICA"}, false, false, false) + if err != nil { + t.Fatalf("%s: create post request: %v", instanceType, err) + } + rec := httptest.NewRecorder() + apiRouter.ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("%s: POST status %d: %s", instanceType, rec.Code, rec.Body.String()) + } + info, err := obj.GetObjectInfo(context.Background(), bucketName, objectPrefix+"/upload.txt", ObjectOptions{}) + if err != nil { + t.Fatalf("%s: get object info: %v", instanceType, err) + } + if got := info.UserDefined[xhttp.AmzBucketReplicationStatus]; got != "" { + t.Fatalf("%s: forged replication status persisted as %q", instanceType, got) + } + if !info.ReplicationStatus.Empty() { + t.Fatalf("%s: forged replication status reached ObjectInfo: %q", instanceType, info.ReplicationStatus) + } +} + // testPostPolicyBucketHandler - Tests validate post policy handler uploading objects. func testPostPolicyBucketHandler(obj ObjectLayer, instanceType string, t TestErrHandler) { if err := newTestConfig(globalMinioDefaultRegion, obj); err != nil { diff --git a/cmd/replication-trust.go b/cmd/replication-trust.go new file mode 100644 index 0000000000000..a58bf19f2d1d9 --- /dev/null +++ b/cmd/replication-trust.go @@ -0,0 +1,125 @@ +// 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 ( + "context" + "net/http" + + objectreplication "github.com/minio/minio/internal/bucket/replication" + xhttp "github.com/minio/minio/internal/http" + "github.com/minio/minio/internal/logger" + "github.com/minio/pkg/v3/policy" +) + +type replicationTrustKey struct{} +type replicaTrustKey struct{} + +// hasReplicationMarker reports whether the internal replication marker has +// its one accepted wire value. Header presence alone is never a trust signal. +func hasReplicationMarker(h http.Header) bool { + values, ok := h[http.CanonicalHeaderKey(xhttp.MinIOSourceReplicationRequest)] + return ok && len(values) == 1 && values[0] == "true" +} + +func hasReplicationMarkerHeader(h http.Header) bool { + _, ok := h[http.CanonicalHeaderKey(xhttp.MinIOSourceReplicationRequest)] + return ok +} + +func hasReplicaStatus(h http.Header) bool { + return h.Get(xhttp.AmzBucketReplicationStatus) == objectreplication.Replica.String() +} + +func withReplicationTrust(ctx context.Context, trusted, replicaTrusted bool) context.Context { + ctx = context.WithValue(ctx, replicationTrustKey{}, trusted) + return context.WithValue(ctx, replicaTrustKey{}, trusted && replicaTrusted) +} + +func isTrustedReplication(ctx context.Context) bool { + trusted, _ := ctx.Value(replicationTrustKey{}).(bool) + return trusted +} + +func isReplicaTrusted(ctx context.Context) bool { + trusted, _ := ctx.Value(replicaTrustKey{}).(bool) + return trusted +} + +// replicationPermissionAllowed must be called only after the request's +// existing authentication/signature path has succeeded and populated ReqInfo. +// Replication peers are authenticated principals; an anonymous bucket-policy +// 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 { + reqInfo := logger.GetReqInfo(ctx) + if reqInfo == nil || reqInfo.Cred.AccessKey == "" { + return false + } + reqInfo.BucketName = bucket + reqInfo.ObjectName = object + return authorizeRequest(ctx, r, action) == ErrNone +} + +// replicationRequestHeaders are internal request controls. They are removed +// only after signature verification when a request has not earned replication +// trust. Public S3/SSE/checksum headers, proxy loop guards, and replication +// validity/readiness probes are intentionally not listed here. +var replicationRequestHeaders = []string{ + xhttp.MinIOSourceReplicationRequest, + xhttp.MinIOSourceETag, + xhttp.MinIOSourceMTime, + xhttp.MinIOSourceDeleteMarker, + xhttp.MinIOSourceDeleteMarkerDelete, + xhttp.MinIOSourceTaggingTimestamp, + xhttp.MinIOSourceObjectRetentionTimestamp, + xhttp.MinIOSourceObjectLegalHoldTimestamp, + "X-Minio-Replication-Server-Side-Encryption-Sealed-Key", + "X-Minio-Replication-Server-Side-Encryption-Seal-Algorithm", + "X-Minio-Replication-Server-Side-Encryption-Iv", + "X-Minio-Replication-Encrypted-Multipart", + xhttp.MinIOReplicationActualObjectSize, + ReplicationSsecChecksumHeader, + xhttp.AmzBucketReplicationStatus, +} + +func stripReplicationRequestHeaders(h http.Header) { + for _, name := range replicationRequestHeaders { + h.Del(name) + } +} + +func hasReplicationRequestHeaders(h http.Header) bool { + for _, name := range replicationRequestHeaders { + if _, ok := h[http.CanonicalHeaderKey(name)]; ok { + return true + } + } + return false +} + +func cloneRequestWithoutReplicationHeaders(r *http.Request, ctx context.Context) *http.Request { + clone := new(http.Request) + *clone = *r + clone.Header = r.Header.Clone() + stripReplicationRequestHeaders(clone.Header) + return clone.WithContext(ctx) +} + +// applyReplicationTrust binds the handler context to the effective request. +// The context marker is the authorization source of truth; header removal is +// defense in depth for option builders and future call sites. +func applyReplicationTrust(ctx context.Context, r *http.Request, trusted, replicaTrusted bool) (context.Context, *http.Request) { + ctx = withReplicationTrust(ctx, trusted, replicaTrusted) + if trusted { + return ctx, r.WithContext(ctx) + } + return ctx, cloneRequestWithoutReplicationHeaders(r, ctx) +} diff --git a/cmd/replication-trust_test.go b/cmd/replication-trust_test.go new file mode 100644 index 0000000000000..c6ef841d21fba --- /dev/null +++ b/cmd/replication-trust_test.go @@ -0,0 +1,560 @@ +// 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" + "context" + "crypto/md5" + "encoding/base64" + "encoding/xml" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + "time" + + "github.com/minio/minio/internal/auth" + xhttp "github.com/minio/minio/internal/http" +) + +func TestAPIReplicationTrustProtectsSSECReads(t *testing.T) { + defer DetectTestLeak(t)() + ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{ + t: t, + objAPITest: testAPIReplicationTrustProtectsSSECReads, + }) +} + +func testAPIReplicationTrustProtectsSSECReads(_ ObjectLayer, instanceType, bucketName string, + apiRouter http.Handler, credentials auth.Credentials, t *testing.T, +) { + previousTLS := globalIsTLS + globalIsTLS = true + defer func() { globalIsTLS = previousTLS }() + + key := bytes.Repeat([]byte{0x31}, 32) + keyMD5 := md5.Sum(key) + data := bytes.Repeat([]byte("replication-trust-ssec-"), 256) + sseHeaders := map[string]string{ + xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES, + xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(key), + xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(keyMD5[:]), + } + object := "replication-trust/ssec-read" + putCopyChecksumSource(t, apiRouter, credentials, bucketName, object, data, sseHeaders) + + readerOnly := newObjectAttributesAuthzUser(t, instanceType, bucketName, `"s3:GetObject"`) + replicator := newObjectAttributesAuthzUser(t, instanceType, bucketName, `"s3:GetObject","s3:ReplicateObject"`) + + marker := map[string]string{xhttp.MinIOSourceReplicationRequest: "true"} + wrongCaseMarker := map[string]string{xhttp.MinIOSourceReplicationRequest: "TRUE"} + conditionalMarker := map[string]string{ + xhttp.MinIOSourceReplicationRequest: "true", + xhttp.IfNoneMatch: "*", + } + + for _, test := range []struct { + name string + method string + creds auth.Credentials + headers map[string]string + wantStatus int + wantPlain bool + wantCipher bool + }{ + {name: "get/reader/fake-marker", method: http.MethodGet, creds: readerOnly, headers: marker, wantStatus: http.StatusBadRequest}, + {name: "get/replicator/trusted", method: http.MethodGet, creds: replicator, headers: marker, wantStatus: http.StatusOK, wantCipher: true}, + {name: "get/root/trusted", method: http.MethodGet, creds: credentials, headers: marker, wantStatus: http.StatusOK, wantCipher: true}, + {name: "get/replicator/wrong-case", method: http.MethodGet, creds: replicator, headers: wrongCaseMarker, wantStatus: http.StatusBadRequest}, + {name: "get/reader/key", method: http.MethodGet, creds: readerOnly, headers: sseHeaders, wantStatus: http.StatusOK, wantPlain: true}, + {name: "head/reader/fake-marker", method: http.MethodHead, creds: readerOnly, headers: marker, wantStatus: http.StatusBadRequest}, + {name: "head/reader/conditional-oracle", method: http.MethodHead, creds: readerOnly, headers: conditionalMarker, wantStatus: http.StatusBadRequest}, + {name: "head/replicator/trusted", method: http.MethodHead, creds: replicator, headers: marker, wantStatus: http.StatusOK}, + {name: "head/replicator/wrong-case", method: http.MethodHead, creds: replicator, headers: wrongCaseMarker, wantStatus: http.StatusBadRequest}, + } { + t.Run(test.name, func(t *testing.T) { + req, err := newTestSignedRequestV4(test.method, getGetObjectURL("", bucketName, object), 0, nil, + test.creds.AccessKey, test.creds.SecretKey, test.headers) + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + apiRouter.ServeHTTP(rec, req) + if rec.Code != test.wantStatus { + t.Fatalf("%s: status %d, want %d: %s", instanceType, rec.Code, test.wantStatus, rec.Body.String()) + } + if test.wantPlain && !bytes.Equal(rec.Body.Bytes(), data) { + t.Fatal("ordinary SSE-C GET did not return plaintext") + } + if test.wantCipher && (len(rec.Body.Bytes()) == 0 || bytes.Equal(rec.Body.Bytes(), data)) { + t.Fatal("trusted replication GET did not return ciphertext") + } + }) + } +} + +func TestReplicationTrustControlsInternalOptionsAndEvents(t *testing.T) { + mtime := time.Date(2026, 8, 31, 12, 34, 56, 123, time.UTC) + headers := make(http.Header) + headers.Set(xhttp.MinIOSourceReplicationRequest, "true") + headers.Set(xhttp.MinIOSourceETag, "source-etag") + headers.Set(xhttp.MinIOSourceMTime, mtime.Format(time.RFC3339Nano)) + headers.Set(xhttp.MinIOReplicationActualObjectSize, "123") + headers.Set(ReplicationSsecChecksumHeader, "checksum") + + ordinary, err := putOptsFromHeaders(t.Context(), headers, nil, false) + if err != nil { + t.Fatal(err) + } + if ordinary.ReplicationRequest || ordinary.PreserveETag != "" || !ordinary.MTime.IsZero() { + t.Fatalf("ordinary options trusted internal headers: %#v", ordinary) + } + + trusted, err := putOptsFromHeaders(t.Context(), headers, nil, true) + if err != nil { + t.Fatal(err) + } + if !trusted.ReplicationRequest || trusted.PreserveETag != "source-etag" || !trusted.MTime.Equal(mtime) { + t.Fatalf("trusted options lost source state: %#v", trusted) + } + + completeReq := &http.Request{Header: headers.Clone(), Form: make(url.Values)} + ordinaryComplete, err := completeMultipartOpts(t.Context(), completeReq, "bucket", "object") + if err != nil { + t.Fatal(err) + } + if ordinaryComplete.ReplicationRequest || len(ordinaryComplete.UserDefined) != 0 { + t.Fatalf("ordinary completion trusted internal metadata: %#v", ordinaryComplete) + } + trustedCompleteCtx := withReplicationTrust(t.Context(), true, false) + trustedComplete, err := completeMultipartOpts(trustedCompleteCtx, completeReq.WithContext(trustedCompleteCtx), "bucket", "object") + if err != nil { + t.Fatal(err) + } + if !trustedComplete.ReplicationRequest || trustedComplete.UserDefined[ReservedMetadataPrefix+"Actual-Object-Size"] != "123" || + trustedComplete.UserDefined[ReplicationSsecChecksumHeader] != "checksum" { + t.Fatalf("trusted completion lost internal metadata: %#v", trustedComplete) + } + + req := &http.Request{Header: headers.Clone(), Form: make(url.Values)} + req = req.WithContext(context.Background()) + if _, ok := extractReqParams(req)[xhttp.MinIOSourceReplicationRequest]; ok { + t.Fatal("untrusted marker suppressed events") + } + trustedCtx := withReplicationTrust(req.Context(), true, false) + req = req.WithContext(trustedCtx) + if _, ok := extractReqParams(req)[xhttp.MinIOSourceReplicationRequest]; !ok { + t.Fatal("trusted replication marker was not propagated to events") + } +} + +func TestAPIPutObjectReplicationTrust(t *testing.T) { + defer DetectTestLeak(t)() + ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{ + t: t, + objAPITest: testAPIPutObjectReplicationTrust, + }) +} + +func testAPIPutObjectReplicationTrust(obj ObjectLayer, instanceType, bucketName string, + apiRouter http.Handler, _ auth.Credentials, t *testing.T, +) { + putOnly := newObjectAttributesAuthzUser(t, instanceType, bucketName, `"s3:PutObject"`) + replicator := newObjectAttributesAuthzUser(t, instanceType, bucketName, `"s3:PutObject","s3:ReplicateObject"`) + payload := []byte("replication trust put payload") + sourceMTime := time.Date(2024, 1, 2, 3, 4, 5, 6, time.UTC) + + request := func(t *testing.T, object string, creds auth.Credentials, status string) *httptest.ResponseRecorder { + t.Helper() + headers := map[string]string{ + xhttp.MinIOSourceReplicationRequest: "true", + xhttp.MinIOSourceETag: "source-etag", + xhttp.MinIOSourceMTime: sourceMTime.Format(time.RFC3339Nano), + } + if status != "" { + headers[xhttp.AmzBucketReplicationStatus] = status + } + req, err := newTestSignedRequestV4(http.MethodPut, getPutObjectURL("", bucketName, object), + int64(len(payload)), bytes.NewReader(payload), creds.AccessKey, creds.SecretKey, headers) + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + apiRouter.ServeHTTP(rec, req) + return rec + } + + t.Run("untrusted marker is ordinary", func(t *testing.T) { + object := "replication-trust/put-ordinary" + if rec := request(t, object, putOnly, "PENDING"); rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + info, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{}) + if err != nil { + t.Fatal(err) + } + if info.ETag == "source-etag" || info.ModTime.Equal(sourceMTime) { + t.Fatalf("untrusted source state was preserved: ETag=%q MTime=%v", info.ETag, info.ModTime) + } + assertObjectMetadataKeysAbsent(t, info.UserDefined, xhttp.AmzBucketReplicationStatus) + }) + + t.Run("unauthorized replica is denied", func(t *testing.T) { + object := "replication-trust/put-denied-replica" + if rec := request(t, object, putOnly, "REPLICA"); rec.Code != http.StatusForbidden { + t.Fatalf("status %d, want 403: %s", rec.Code, rec.Body.String()) + } + if _, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{}); err == nil { + t.Fatal("unauthorized replica write created an object") + } + }) + + t.Run("trusted batch preserves source state", func(t *testing.T) { + object := "replication-trust/put-batch" + if rec := request(t, object, replicator, ""); rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + info, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{}) + if err != nil { + t.Fatal(err) + } + if info.ETag != "source-etag" || !info.ModTime.Equal(sourceMTime) { + t.Fatalf("trusted source state lost: ETag=%q MTime=%v", info.ETag, info.ModTime) + } + assertObjectMetadataKeysAbsent(t, info.UserDefined, xhttp.AmzBucketReplicationStatus) + }) + + t.Run("trusted replica persists replica state", func(t *testing.T) { + object := "replication-trust/put-replica" + if rec := request(t, object, replicator, "REPLICA"); rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } + info, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{}) + if err != nil { + t.Fatal(err) + } + if info.UserDefined[xhttp.AmzBucketReplicationStatus] != "REPLICA" { + t.Fatalf("replica status not persisted: %#v", info.UserDefined) + } + }) +} + +func TestAPICopyObjectMarkerOnlyDoesNotCopyCiphertext(t *testing.T) { + defer DetectTestLeak(t)() + ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{ + t: t, + objAPITest: testAPICopyObjectMarkerOnlyDoesNotCopyCiphertext, + }) +} + +func testAPICopyObjectMarkerOnlyDoesNotCopyCiphertext(obj ObjectLayer, instanceType, bucketName string, + apiRouter http.Handler, credentials auth.Credentials, t *testing.T, +) { + previousTLS := globalIsTLS + globalIsTLS = true + defer func() { globalIsTLS = previousTLS }() + + key := bytes.Repeat([]byte{0x57}, 32) + keyMD5 := md5.Sum(key) + data := bytes.Repeat([]byte("copy marker-only plaintext "), 256) + sseHeaders := map[string]string{ + xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES, + xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(key), + xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(keyMD5[:]), + } + srcObject := "replication-trust/copy-ssec-source" + dstObject := "replication-trust/copy-marker-only" + putCopyChecksumSource(t, apiRouter, credentials, bucketName, srcObject, data, sseHeaders) + + replicator := newObjectAttributesAuthzUser(t, instanceType, bucketName, + `"s3:GetObject","s3:PutObject","s3:ReplicateObject"`) + headers := map[string]string{ + xhttp.AmzCopySource: url.QueryEscape(SlashSeparator + bucketName + SlashSeparator + srcObject), + xhttp.MinIOSourceReplicationRequest: "true", + xhttp.AmzServerSideEncryptionCopyCustomerAlgorithm: xhttp.AmzEncryptionAES, + xhttp.AmzServerSideEncryptionCopyCustomerKey: base64.StdEncoding.EncodeToString(key), + xhttp.AmzServerSideEncryptionCopyCustomerKeyMD5: base64.StdEncoding.EncodeToString(keyMD5[:]), + } + req, err := newTestSignedRequestV4(http.MethodPut, getCopyObjectURL("", bucketName, dstObject), 0, nil, + replicator.AccessKey, replicator.SecretKey, headers) + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + apiRouter.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("%s: CopyObject status %d: %s", instanceType, rec.Code, rec.Body.String()) + } + assertObjectContents(t, obj, bucketName, dstObject, data) +} + +func TestAPIDeleteObjectReplicationTrust(t *testing.T) { + defer DetectTestLeak(t)() + ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{ + t: t, + objAPITest: testAPIDeleteObjectReplicationTrust, + }) +} + +func testAPIDeleteObjectReplicationTrust(obj ObjectLayer, instanceType, bucketName string, + apiRouter http.Handler, _ auth.Credentials, t *testing.T, +) { + deleteOnly := newObjectAttributesAuthzUser(t, instanceType, bucketName, `"s3:DeleteObject"`) + replicator := newObjectAttributesAuthzUser(t, instanceType, bucketName, `"s3:DeleteObject","s3:ReplicateDelete"`) + payload := []byte("delete replication trust") + + put := func(t *testing.T, object string) { + t.Helper() + if _, err := obj.PutObject(t.Context(), bucketName, object, + mustGetPutObjReader(t, bytes.NewReader(payload), int64(len(payload)), "", ""), ObjectOptions{}); err != nil { + t.Fatal(err) + } + } + remove := func(t *testing.T, object string, creds auth.Credentials) *httptest.ResponseRecorder { + t.Helper() + headers := map[string]string{ + xhttp.MinIOSourceReplicationRequest: "true", + xhttp.AmzBucketReplicationStatus: "REPLICA", + } + req, err := newTestSignedRequestV4(http.MethodDelete, getDeleteObjectURL("", bucketName, object), + 0, nil, creds.AccessKey, creds.SecretKey, headers) + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + apiRouter.ServeHTTP(rec, req) + return rec + } + + t.Run("replica status without ReplicateDelete is denied", func(t *testing.T) { + object := "replication-trust/delete-denied" + put(t, object) + if rec := remove(t, object, deleteOnly); rec.Code != http.StatusForbidden { + t.Fatalf("status %d, want 403: %s", rec.Code, rec.Body.String()) + } + if _, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{}); err != nil { + t.Fatalf("denied delete removed object: %v", err) + } + }) + + t.Run("trusted replica delete remains supported", func(t *testing.T) { + object := "replication-trust/delete-allowed" + put(t, object) + if rec := remove(t, object, replicator); rec.Code != http.StatusNoContent { + t.Fatalf("status %d, want 204: %s", rec.Code, rec.Body.String()) + } + }) +} + +func TestAPISSECMultipartReplicationTrust(t *testing.T) { + defer DetectTestLeak(t)() + ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{ + t: t, + objAPITest: testAPISSECMultipartReplicationTrust, + }) +} + +func testAPISSECMultipartReplicationTrust(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"`) + key := bytes.Repeat([]byte{0x42}, 32) + keyMD5 := md5.Sum(key) + data := bytes.Repeat([]byte("trusted multipart replication "), 4096) + sseHeaders := map[string]string{ + xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES, + xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(key), + xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(keyMD5[:]), + } + + // A marker alone must not let an ordinary writer upload raw bytes into an + // SSE-C multipart upload without presenting the customer key. + fakeObject := "replication-trust/ssec-multipart-fake" + fakeNewReq, err := newTestSignedRequestV4(http.MethodPost, getNewMultipartURL("", bucketName, fakeObject), + 0, nil, credentials.AccessKey, credentials.SecretKey, sseHeaders) + if err != nil { + t.Fatal(err) + } + fakeNewRec := httptest.NewRecorder() + apiRouter.ServeHTTP(fakeNewRec, fakeNewReq) + if fakeNewRec.Code != http.StatusOK { + t.Fatalf("fake-path NewMultipart status %d: %s", fakeNewRec.Code, fakeNewRec.Body.String()) + } + var fakeInit InitiateMultipartUploadResponse + if err = xmlDecoder(fakeNewRec.Body, &fakeInit, int64(fakeNewRec.Body.Len())); err != nil { + t.Fatal(err) + } + fakePartReq, err := newTestSignedRequestV4(http.MethodPut, + getPutObjectPartURL("", bucketName, fakeObject, fakeInit.UploadID, "1"), int64(len(data)), bytes.NewReader(data), + putOnly.AccessKey, putOnly.SecretKey, map[string]string{xhttp.MinIOSourceReplicationRequest: "true"}) + if err != nil { + t.Fatal(err) + } + fakePartRec := httptest.NewRecorder() + apiRouter.ServeHTTP(fakePartRec, fakePartReq) + if fakePartRec.Code != http.StatusBadRequest { + t.Fatalf("fake marker PutPart status %d, want 400: %s", fakePartRec.Code, fakePartRec.Body.String()) + } + + object := "replication-trust/ssec-multipart" + + // Create the source as a real SSE-C multipart object so the encrypted part + // layout and metadata match what the replication worker reads. + newReq, err := newTestSignedRequestV4(http.MethodPost, getNewMultipartURL("", bucketName, object), + 0, nil, credentials.AccessKey, credentials.SecretKey, sseHeaders) + if err != nil { + t.Fatal(err) + } + newRec := httptest.NewRecorder() + 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()) + } + sourcePartETag := canonicalizeETag(partRec.Header()[xhttp.ETag][0]) + sourceCompleteBody, err := xml.Marshal(CompleteMultipartUpload{Parts: []CompletePart{{PartNumber: 1, ETag: sourcePartETag}}}) + if err != nil { + t.Fatal(err) + } + completeReq, err := newTestSignedRequestV4(http.MethodPost, + getCompleteMultipartUploadURL("", bucketName, object, sourceInit.UploadID), int64(len(sourceCompleteBody)), + bytes.NewReader(sourceCompleteBody), 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) + } + if len(rawPart) == 0 || bytes.Equal(rawPart, data) { + t.Fatal("source replication read did not return encrypted bytes") + } + + 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] + } + } + + // Start the destination upload over the same key. The existing object stays + // readable until Complete, so buffering rawPart above mirrors a remote peer. + 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) + } + + replPartHeaders := map[string]string{xhttp.MinIOSourceReplicationRequest: "true"} + replPartReq, err := newTestSignedRequestV4(http.MethodPut, + getPutObjectPartURL("", bucketName, object, replicaInit.UploadID, "1"), int64(len(rawPart)), bytes.NewReader(rawPart), + replicator.AccessKey, replicator.SecretKey, replPartHeaders) + 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()) + } + replPartETag := canonicalizeETag(replPartRec.Header()[xhttp.ETag][0]) + replCompleteBody, err := xml.Marshal(CompleteMultipartUpload{Parts: []CompletePart{{PartNumber: 1, ETag: replPartETag}}}) + if err != nil { + t.Fatal(err) + } + actualSize, err := sourceInfo.GetActualSize() + if err != nil { + t.Fatal(err) + } + replCompleteHeaders := map[string]string{ + xhttp.MinIOSourceReplicationRequest: "true", + xhttp.MinIOSourceMTime: sourceInfo.ModTime.Format(time.RFC3339Nano), + xhttp.MinIOSourceETag: sourceInfo.ETag, + xhttp.MinIOReplicationActualObjectSize: strconv.FormatInt(actualSize, 10), + } + replCompleteReq, err := newTestSignedRequestV4(http.MethodPost, + getCompleteMultipartUploadURL("", bucketName, object, replicaInit.UploadID), int64(len(replCompleteBody)), + bytes.NewReader(replCompleteBody), replicator.AccessKey, replicator.SecretKey, replCompleteHeaders) + 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()) + } + + 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 object status %d: %s", getRec.Code, getRec.Body.String()) + } + if !bytes.Equal(getRec.Body.Bytes(), data) { + t.Fatal("replicated SSE-C multipart object did not decrypt to source plaintext") + } +} diff --git a/internal/bucket/object/lock/lock.go b/internal/bucket/object/lock/lock.go index 410011b96427e..4555f91c5168e 100644 --- a/internal/bucket/object/lock/lock.go +++ b/internal/bucket/object/lock/lock.go @@ -26,13 +26,11 @@ import ( "io" "maps" "net/http" - "net/textproto" "strings" "time" "github.com/beevik/ntp" "github.com/minio/minio/internal/amztime" - xhttp "github.com/minio/minio/internal/http" "github.com/minio/minio/internal/logger" "github.com/minio/pkg/v3/env" @@ -435,7 +433,7 @@ func IsObjectLockRequested(h http.Header) bool { } // ParseObjectLockRetentionHeaders parses http headers to extract retention mode and retention date -func ParseObjectLockRetentionHeaders(h http.Header) (rmode RetMode, r RetentionDate, err error) { +func ParseObjectLockRetentionHeaders(h http.Header, allowPastRetainDate bool) (rmode RetMode, r RetentionDate, err error) { retMode := h.Get(AmzObjectLockMode) dateStr := h.Get(AmzObjectLockRetainUntilDate) if len(retMode) == 0 || len(dateStr) == 0 { @@ -455,15 +453,13 @@ func ParseObjectLockRetentionHeaders(h http.Header) (rmode RetMode, r RetentionD if err != nil { return rmode, r, ErrInvalidRetentionDate } - _, replReq := h[textproto.CanonicalMIMEHeaderKey(xhttp.MinIOSourceReplicationRequest)] - t, err := UTCNowNTP() if err != nil { lockLogIf(context.Background(), err) return rmode, r, ErrPastObjectLockRetainDate } - if retDate.Before(t) && !replReq { + if retDate.Before(t) && !allowPastRetainDate { return rmode, r, ErrPastObjectLockRetainDate } diff --git a/internal/bucket/object/lock/lock_test.go b/internal/bucket/object/lock/lock_test.go index be7975e28c084..c53d77b869a1a 100644 --- a/internal/bucket/object/lock/lock_test.go +++ b/internal/bucket/object/lock/lock_test.go @@ -386,7 +386,7 @@ func TestParseObjectLockRetentionHeaders(t *testing.T) { } for i, tt := range tests { - _, _, err := ParseObjectLockRetentionHeaders(tt.header) + _, _, err := ParseObjectLockRetentionHeaders(tt.header, false) //nolint:gocritic if tt.expectedErr == nil { if err != nil { @@ -398,6 +398,14 @@ func TestParseObjectLockRetentionHeaders(t *testing.T) { t.Fatalf("Case %d error: expected = %v, got = %v", i, tt.expectedErr, err) } } + + past := http.Header{ + xhttp.AmzObjectLockMode: []string{"governance"}, + xhttp.AmzObjectLockRetainUntilDate: []string{"2017-01-02T15:04:05Z"}, + } + if _, _, err := ParseObjectLockRetentionHeaders(past, true); err != nil { + t.Fatalf("trusted replica past retention date: %v", err) + } } func TestGetObjectRetentionMeta(t *testing.T) { From f3438b260265f3731291aec62f617bbcbe1a6a13 Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Tue, 1 Sep 2026 23:16:04 +0800 Subject: [PATCH 2/7] fix: validate CORS state in replication status Count only valid live CORS states in per-site summaries. Treat baselines and tombstones as absent, and diagnose malformed payloads or missing source timestamps.\n\nRefs: #77 Signed-off-by: Feng Ruohang --- cmd/bucket-cors-site-replication_test.go | 38 ++++++++++++++++--- ...site-replication-status-accounting_test.go | 19 +++++----- cmd/site-replication.go | 13 +++---- 3 files changed, 49 insertions(+), 21 deletions(-) diff --git a/cmd/bucket-cors-site-replication_test.go b/cmd/bucket-cors-site-replication_test.go index 2c0997e98c99d..f8d673903740b 100644 --- a/cmd/bucket-cors-site-replication_test.go +++ b/cmd/bucket-cors-site-replication_test.go @@ -634,7 +634,7 @@ func testSiteReplicationStatusCountsCorsPerSite(obj ObjectLayer, _ string, bucke globalSiteReplicationSys.Unlock() }() - check := func(name string, wantLocal, wantRemote int) { + check := func(name string, wantLocal, wantRemote int, wantMismatch, wantReplicated bool) { t.Helper() status, err := globalSiteReplicationSys.siteReplicationStatus(ctx, obj, madmin.SRStatusOptions{Buckets: true}) if err != nil { @@ -646,23 +646,51 @@ func testSiteReplicationStatusCountsCorsPerSite(obj ObjectLayer, _ string, bucke if got := status.StatsSummary[remoteID].TotalCorsConfigCount; got != wantRemote { t.Fatalf("%s: remote TotalCorsConfigCount = %d, want %d", name, got, wantRemote) } + for _, id := range []string{localID, remoteID} { + bucketStatus := status.BucketStats[bucket][id] + wantSet := wantLocal != 0 + if id == remoteID { + wantSet = wantRemote != 0 + } + if bucketStatus.HasCorsCfgSet != wantSet { + t.Fatalf("%s: %s HasCorsCfgSet = %v, want %v", name, id, bucketStatus.HasCorsCfgSet, wantSet) + } + if bucketStatus.CorsCfgMismatch != wantMismatch { + t.Fatalf("%s: %s CorsCfgMismatch = %v, want %v", name, id, bucketStatus.CorsCfgMismatch, wantMismatch) + } + gotReplicated := status.StatsSummary[id].ReplicatedCorsConfig != 0 + if gotReplicated != wantReplicated { + t.Fatalf("%s: %s ReplicatedCorsConfig = %d, want replicated %v", name, id, status.StatsSummary[id].ReplicatedCorsConfig, wantReplicated) + } + } } - check("neither site", 0, 0) + check("neither site", 0, 0, false, false) t1 := meta.Created.Add(time.Second) if err = globalSiteReplicationSys.PeerBucketCorsConfigHandler(ctx, bucket, &encoded, t1); err != nil { t.Fatal(err) } - check("local site only", 1, 0) + check("local site only", 1, 0, true, false) t2 := t1.Add(time.Second) if err = globalSiteReplicationSys.PeerBucketCorsConfigHandler(ctx, bucket, nil, t2); err != nil { t.Fatal(err) } + remoteInfo.Buckets[bucket] = madmin.SRBucketInfo{ + Bucket: bucket, CreatedAt: meta.Created, CorsConfig: &encoded, + } + check("live remote without timestamp", 0, 0, true, false) + + invalidXML := base64.StdEncoding.EncodeToString([]byte(`not xml`)) + remoteInfo.Buckets[bucket] = madmin.SRBucketInfo{ + Bucket: bucket, CreatedAt: meta.Created, CorsConfig: &invalidXML, CorsConfigUpdatedAt: t2, + } + check("invalid remote XML", 0, 0, true, false) + remoteInfo.Buckets[bucket] = madmin.SRBucketInfo{ Bucket: bucket, CreatedAt: meta.Created, CorsConfig: &encoded, CorsConfigUpdatedAt: t2, } - check("remote site only", 0, 1) + check("remote site only", 0, 1, true, false) t3 := t2.Add(time.Second) if err = globalSiteReplicationSys.PeerBucketCorsConfigHandler(ctx, bucket, &encoded, t3); err != nil { @@ -671,7 +699,7 @@ func testSiteReplicationStatusCountsCorsPerSite(obj ObjectLayer, _ string, bucke remoteInfo.Buckets[bucket] = madmin.SRBucketInfo{ Bucket: bucket, CreatedAt: meta.Created, CorsConfig: &encoded, CorsConfigUpdatedAt: t3, } - check("both sites", 1, 1) + check("both sites", 1, 1, false, true) } func TestCORSReplicationStateOrdering(t *testing.T) { diff --git a/cmd/site-replication-status-accounting_test.go b/cmd/site-replication-status-accounting_test.go index ff4deeef16e96..bebc4b182bc86 100644 --- a/cmd/site-replication-status-accounting_test.go +++ b/cmd/site-replication-status-accounting_test.go @@ -93,15 +93,16 @@ func testSiteReplicationStatusAccountsPerSiteAndSurvivesMalformedConfig(obj Obje CreatedAt: localMeta.Created, }, remoteBucket: { - Bucket: remoteBucket, - CreatedAt: remoteBucketMeta.Created, - Tags: encode(tagXML), - Versioning: encode(versioningXML), - ObjectLockConfig: encode(objectLockXML), - SSEConfig: encode(sseXML), - QuotaConfig: encode(quotaJSON), - Policy: remotePolicy, - CorsConfig: encode([]byte(testSiteReplicationCORSDoc)), + Bucket: remoteBucket, + CreatedAt: remoteBucketMeta.Created, + Tags: encode(tagXML), + Versioning: encode(versioningXML), + ObjectLockConfig: encode(objectLockXML), + SSEConfig: encode(sseXML), + QuotaConfig: encode(quotaJSON), + Policy: remotePolicy, + CorsConfig: encode([]byte(testSiteReplicationCORSDoc)), + CorsConfigUpdatedAt: remoteBucketMeta.Created, }, }, } diff --git a/cmd/site-replication.go b/cmd/site-replication.go index 31232fc4dc38e..3992605833ed4 100644 --- a/cmd/site-replication.go +++ b/cmd/site-replication.go @@ -3557,13 +3557,12 @@ func (c *SiteReplicationSys) siteReplicationStatus(ctx context.Context, objAPI O logInvalid("sse", err) } } - if s.CorsConfig != nil { - if _, err := decodeCORSReplicationPayload(s.CorsConfig); err == nil { - validCorsCfg[i] = true - corsCfgCount++ - } else { - logInvalid("cors", err) - } + corsState, err := corsReplicationStateFromInfo(s.SRBucketInfo) + if err != nil { + logInvalid("cors", err) + } else if corsState.kind == corsReplicationLive { + validCorsCfg[i] = true + corsCfgCount++ } ss, ok := info.StatsSummary[s.DeploymentID] if !ok { From c9ad746732e1f1fd7140f83fc7b309a6b1e43126 Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Tue, 1 Sep 2026 23:16:09 +0800 Subject: [PATCH 3/7] fix: verify replication permissions in validity probes Evaluate ReplicateObject or ReplicateDelete before returning the no-op validation response, so underprivileged target credentials fail during replication setup instead of at runtime. Signed-off-by: Feng Ruohang --- cmd/object-handlers.go | 31 +++++++------- cmd/replication-trust_test.go | 80 +++++++++++++++++++++++++++++++---- 2 files changed, 86 insertions(+), 25 deletions(-) diff --git a/cmd/object-handlers.go b/cmd/object-handlers.go index 00143dcd68909..d940efa0f27e5 100644 --- a/cmd/object-handlers.go +++ b/cmd/object-handlers.go @@ -2073,16 +2073,6 @@ func (api objectAPIHandlers) PutObjectHandler(w http.ResponseWriter, r *http.Req } } - if _, ok := r.Header[xhttp.MinIOSourceReplicationCheck]; ok { - // requests to just validate replication settings and permissions are not allowed to write data - writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrReplicationPermissionCheckError), r.URL) - return - } - - if err := enforceBucketQuotaHard(ctx, bucket, size); err != nil { - writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) - return - } rawReplica := hasReplicaStatus(r.Header) markerExact := hasReplicationMarker(r.Header) replicationPermitted := false @@ -2093,6 +2083,16 @@ func (api objectAPIHandlers) PutObjectHandler(w http.ResponseWriter, r *http.Req writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL) return } + if _, ok := r.Header[xhttp.MinIOSourceReplicationCheck]; ok { + // requests to just validate replication settings and permissions are not allowed to write data + writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrReplicationPermissionCheckError), r.URL) + return + } + + if err := enforceBucketQuotaHard(ctx, bucket, size); err != nil { + writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) + return + } trustedReplication := markerExact && replicationPermitted replicaTrusted := trustedReplication && rawReplica if hasReplicationRequestHeaders(r.Header) { @@ -2795,12 +2795,6 @@ func (api objectAPIHandlers) DeleteObjectHandler(w http.ResponseWriter, r *http. writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Error), r.URL) return } - if _, ok := r.Header[xhttp.MinIOSourceReplicationCheck]; ok { - // requests to just validate replication settings and permissions are not allowed to delete data - writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrReplicationPermissionCheckError), r.URL) - return - } - rawReplica := hasReplicaStatus(r.Header) markerExact := hasReplicationMarker(r.Header) replicationPermitted := false @@ -2811,6 +2805,11 @@ func (api objectAPIHandlers) DeleteObjectHandler(w http.ResponseWriter, r *http. writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL) return } + if _, ok := r.Header[xhttp.MinIOSourceReplicationCheck]; ok { + // requests to just validate replication settings and permissions are not allowed to delete data + writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrReplicationPermissionCheckError), r.URL) + return + } trustedReplication := markerExact && replicationPermitted replica := trustedReplication && rawReplica if hasReplicationRequestHeaders(r.Header) { diff --git a/cmd/replication-trust_test.go b/cmd/replication-trust_test.go index c6ef841d21fba..e406611e6373c 100644 --- a/cmd/replication-trust_test.go +++ b/cmd/replication-trust_test.go @@ -175,7 +175,7 @@ func testAPIPutObjectReplicationTrust(obj ObjectLayer, instanceType, bucketName payload := []byte("replication trust put payload") sourceMTime := time.Date(2024, 1, 2, 3, 4, 5, 6, time.UTC) - request := func(t *testing.T, object string, creds auth.Credentials, status string) *httptest.ResponseRecorder { + request := func(t *testing.T, object string, creds auth.Credentials, status string, check bool) *httptest.ResponseRecorder { t.Helper() headers := map[string]string{ xhttp.MinIOSourceReplicationRequest: "true", @@ -185,6 +185,9 @@ func testAPIPutObjectReplicationTrust(obj ObjectLayer, instanceType, bucketName if status != "" { headers[xhttp.AmzBucketReplicationStatus] = status } + if check { + headers[xhttp.MinIOSourceReplicationCheck] = "true" + } req, err := newTestSignedRequestV4(http.MethodPut, getPutObjectURL("", bucketName, object), int64(len(payload)), bytes.NewReader(payload), creds.AccessKey, creds.SecretKey, headers) if err != nil { @@ -197,7 +200,7 @@ func testAPIPutObjectReplicationTrust(obj ObjectLayer, instanceType, bucketName t.Run("untrusted marker is ordinary", func(t *testing.T) { object := "replication-trust/put-ordinary" - if rec := request(t, object, putOnly, "PENDING"); rec.Code != http.StatusOK { + if rec := request(t, object, putOnly, "PENDING", false); rec.Code != http.StatusOK { t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) } info, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{}) @@ -212,7 +215,7 @@ func testAPIPutObjectReplicationTrust(obj ObjectLayer, instanceType, bucketName t.Run("unauthorized replica is denied", func(t *testing.T) { object := "replication-trust/put-denied-replica" - if rec := request(t, object, putOnly, "REPLICA"); rec.Code != http.StatusForbidden { + if rec := request(t, object, putOnly, "REPLICA", false); rec.Code != http.StatusForbidden { t.Fatalf("status %d, want 403: %s", rec.Code, rec.Body.String()) } if _, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{}); err == nil { @@ -222,7 +225,7 @@ func testAPIPutObjectReplicationTrust(obj ObjectLayer, instanceType, bucketName t.Run("trusted batch preserves source state", func(t *testing.T) { object := "replication-trust/put-batch" - if rec := request(t, object, replicator, ""); rec.Code != http.StatusOK { + if rec := request(t, object, replicator, "", false); rec.Code != http.StatusOK { t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) } info, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{}) @@ -237,7 +240,7 @@ func testAPIPutObjectReplicationTrust(obj ObjectLayer, instanceType, bucketName t.Run("trusted replica persists replica state", func(t *testing.T) { object := "replication-trust/put-replica" - if rec := request(t, object, replicator, "REPLICA"); rec.Code != http.StatusOK { + if rec := request(t, object, replicator, "REPLICA", false); rec.Code != http.StatusOK { t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) } info, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{}) @@ -248,6 +251,25 @@ func testAPIPutObjectReplicationTrust(obj ObjectLayer, instanceType, bucketName t.Fatalf("replica status not persisted: %#v", info.UserDefined) } }) + + for _, test := range []struct { + name string + creds auth.Credentials + wantStatus int + }{ + {name: "validity check requires ReplicateObject", creds: putOnly, wantStatus: http.StatusForbidden}, + {name: "validity check succeeds for replicator", creds: replicator, wantStatus: http.StatusBadRequest}, + } { + t.Run(test.name, func(t *testing.T) { + object := "replication-trust/put-check-" + strconv.Itoa(test.wantStatus) + if rec := request(t, object, test.creds, "REPLICA", true); rec.Code != test.wantStatus { + t.Fatalf("status %d, want %d: %s", rec.Code, test.wantStatus, rec.Body.String()) + } + if _, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{}); err == nil { + t.Fatal("replication validity check created an object") + } + }) + } } func TestAPICopyObjectMarkerOnlyDoesNotCopyCiphertext(t *testing.T) { @@ -321,13 +343,23 @@ func testAPIDeleteObjectReplicationTrust(obj ObjectLayer, instanceType, bucketNa t.Fatal(err) } } - remove := func(t *testing.T, object string, creds auth.Credentials) *httptest.ResponseRecorder { + remove := func(t *testing.T, object string, creds auth.Credentials, versionID string, deleteMarker, check bool) *httptest.ResponseRecorder { t.Helper() headers := map[string]string{ xhttp.MinIOSourceReplicationRequest: "true", xhttp.AmzBucketReplicationStatus: "REPLICA", } - req, err := newTestSignedRequestV4(http.MethodDelete, getDeleteObjectURL("", bucketName, object), + if deleteMarker { + headers[xhttp.MinIOSourceDeleteMarker] = "true" + } + if check { + headers[xhttp.MinIOSourceReplicationCheck] = "true" + } + target := getDeleteObjectURL("", bucketName, object) + if versionID != "" { + target += "?" + url.Values{xhttp.VersionID: {versionID}}.Encode() + } + req, err := newTestSignedRequestV4(http.MethodDelete, target, 0, nil, creds.AccessKey, creds.SecretKey, headers) if err != nil { t.Fatal(err) @@ -340,7 +372,7 @@ func testAPIDeleteObjectReplicationTrust(obj ObjectLayer, instanceType, bucketNa t.Run("replica status without ReplicateDelete is denied", func(t *testing.T) { object := "replication-trust/delete-denied" put(t, object) - if rec := remove(t, object, deleteOnly); rec.Code != http.StatusForbidden { + if rec := remove(t, object, deleteOnly, "", false, false); rec.Code != http.StatusForbidden { t.Fatalf("status %d, want 403: %s", rec.Code, rec.Body.String()) } if _, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{}); err != nil { @@ -351,10 +383,40 @@ func testAPIDeleteObjectReplicationTrust(obj ObjectLayer, instanceType, bucketNa t.Run("trusted replica delete remains supported", func(t *testing.T) { object := "replication-trust/delete-allowed" put(t, object) - if rec := remove(t, object, replicator); rec.Code != http.StatusNoContent { + if rec := remove(t, object, replicator, "", false, false); rec.Code != http.StatusNoContent { t.Fatalf("status %d, want 204: %s", rec.Code, rec.Body.String()) } }) + + for _, shape := range []struct { + name string + deleteMarker bool + }{ + {name: "delete-marker", deleteMarker: true}, + {name: "version-purge"}, + } { + t.Run("validity check/"+shape.name, func(t *testing.T) { + for _, test := range []struct { + name string + creds auth.Credentials + wantStatus int + }{ + {name: "requires ReplicateDelete", creds: deleteOnly, wantStatus: http.StatusForbidden}, + {name: "succeeds for replicator", creds: replicator, wantStatus: http.StatusBadRequest}, + } { + t.Run(test.name, func(t *testing.T) { + object := "replication-trust/delete-check-" + shape.name + "-" + strconv.Itoa(test.wantStatus) + put(t, object) + if rec := remove(t, object, test.creds, mustGetUUID(), shape.deleteMarker, true); rec.Code != test.wantStatus { + t.Fatalf("status %d, want %d: %s", rec.Code, test.wantStatus, rec.Body.String()) + } + if _, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{}); err != nil { + t.Fatalf("replication validity check removed object: %v", err) + } + }) + } + }) + } } func TestAPISSECMultipartReplicationTrust(t *testing.T) { From ff44527a3ce7d39e4a9343ef4dc933b95198d94d Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Tue, 1 Sep 2026 23:45:02 +0800 Subject: [PATCH 4/7] fix: isolate Snowball replication trust per entry Evaluate PutObject and ReplicateObject permissions with immutable per-entry request snapshots during concurrent Snowball extraction. Preserve the first API error without sharing mutable handler state, and cover prefix-scoped trust under the race detector. Signed-off-by: Feng Ruohang --- cmd/auth-handler.go | 16 +++- cmd/object-handlers.go | 55 +++++++++---- cmd/replication-trust.go | 6 +- cmd/replication-trust_test.go | 151 ++++++++++++++++++++++++++++++++++ 4 files changed, 207 insertions(+), 21 deletions(-) diff --git a/cmd/auth-handler.go b/cmd/auth-handler.go index d5df286241885..412bbf7820ed0 100644 --- a/cmd/auth-handler.go +++ b/cmd/auth-handler.go @@ -786,10 +786,20 @@ func isPutActionAllowedWithRequestTags(ctx context.Context, atype authType, buck return s3Err } - logger.GetReqInfo(ctx).Cred = cred - logger.GetReqInfo(ctx).Owner = owner - logger.GetReqInfo(ctx).Region = region + reqInfo := logger.GetReqInfo(ctx) + if reqInfo == nil { + return ErrAccessDenied + } + reqInfo.Lock() + reqInfo.Cred = cred + reqInfo.Owner = owner + reqInfo.Region = region + reqInfo.Unlock() + + return isPutActionAllowedWithCred(bucketName, objectName, r, action, requestTags, cred, owner) +} +func isPutActionAllowedWithCred(bucketName, objectName string, r *http.Request, action policy.Action, requestTags *string, cred auth.Credentials, owner bool) APIErrorCode { // Do not check for PutObjectRetentionAction permission, // if mode and retain until date are not set. // Can happen when bucket has default lock config set diff --git a/cmd/object-handlers.go b/cmd/object-handlers.go index d940efa0f27e5..87c63f9b6dcc5 100644 --- a/cmd/object-handlers.go +++ b/cmd/object-handlers.go @@ -2491,6 +2491,11 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h sha256hex = getContentSha256Cksum(r, serviceS3) } } + entryRequestBase := r.Clone(ctx) + // The streaming reader fills r.Trailer while untar writes small entries in + // parallel. Entry authorization never consumes trailers, so keep them out + // of the immutable request template cloned by those goroutines. + entryRequestBase.Trailer = nil hreader, err := hash.NewReader(ctx, reader, size, md5hex, sha256hex, size) if err != nil { @@ -2515,9 +2520,9 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h rawReplica := hasReplicaStatus(r.Header) markerExact := hasReplicationMarker(r.Header) trustedRequestCtx := withReplicationTrust(ctx, true, rawReplica) - trustedRequest := r.WithContext(trustedRequestCtx) + trustedRequest := entryRequestBase.WithContext(trustedRequestCtx) cleanRequestCtx := withReplicationTrust(ctx, false, false) - cleanRequest := cloneRequestWithoutReplicationHeaders(r, cleanRequestCtx) + cleanRequest := cloneRequestWithoutReplicationHeaders(entryRequestBase, cleanRequestCtx) trustedReqParams := extractReqParams(trustedRequest) cleanReqParams := extractReqParams(cleanRequest) @@ -2533,29 +2538,44 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h if sc == "" { sc = storageclass.STANDARD } + reqInfo := logger.GetReqInfo(ctx) + if reqInfo == nil { + writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL) + return + } + reqInfo.RLock() + tarCred := reqInfo.Cred + tarOwner := reqInfo.Owner + reqInfo.RUnlock() + var tarS3Err atomic.Int32 + setTarS3Err := func(code APIErrorCode) { + tarS3Err.CompareAndSwap(int32(ErrNone), int32(code)) + } putObjectTar := func(reader io.Reader, info os.FileInfo, object string) error { size := info.Size() - if s3Err = isPutActionAllowed(ctx, getRequestAuthType(r), bucket, object, r, policy.PutObjectAction); s3Err != ErrNone { - writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Err), r.URL) - return errors.New(errorCodes.ToAPIErr(s3Err).Code) + entryAuthReq := entryRequestBase.Clone(ctx) + entryS3Err := isPutActionAllowedWithCred(bucket, object, entryAuthReq, policy.PutObjectAction, nil, tarCred, tarOwner) + if entryS3Err != ErrNone { + setTarS3Err(entryS3Err) + return errors.New(errorCodes.ToAPIErr(entryS3Err).Code) } replicationPermitted := false - if rawReplica || markerExact { - replicationPermitted = replicationPermissionAllowed(ctx, r, bucket, object, policy.ReplicateObjectAction) + if tarCred.AccessKey != "" && (rawReplica || markerExact) { + replicationPermitted = isPutActionAllowedWithCred(bucket, object, entryAuthReq, policy.ReplicateObjectAction, nil, tarCred, tarOwner) == ErrNone } if rawReplica && !replicationPermitted { - s3Err = ErrAccessDenied - return errors.New(errorCodes.ToAPIErr(s3Err).Code) + setTarS3Err(ErrAccessDenied) + return errors.New(errorCodes.ToAPIErr(ErrAccessDenied).Code) } entryTrusted := markerExact && replicationPermitted replicaTrusted := entryTrusted && rawReplica entryCtx := cleanRequestCtx - entryReq := cleanRequest + entryReq := cloneRequestWithoutReplicationHeaders(entryAuthReq, cleanRequestCtx) reqParams := cleanReqParams if entryTrusted { entryCtx = trustedRequestCtx - entryReq = trustedRequest + entryReq = entryAuthReq.WithContext(trustedRequestCtx) reqParams = trustedReqParams } metadata := map[string]string{ @@ -2592,7 +2612,7 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h pReader := NewPutObjReader(rawReader) if replicaTrusted { - if err = extractReplicationMetadataFromMime(entryCtx, textproto.MIMEHeader(entryReq.Header), metadata); err != nil { + if err := extractReplicationMetadataFromMime(entryCtx, textproto.MIMEHeader(entryReq.Header), metadata); err != nil { return err } metadata[xhttp.AmzBucketReplicationStatus] = replication.Replica.String() @@ -2657,7 +2677,7 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h } if s3err != ErrNone { - s3Err = s3err + setTarS3Err(s3err) return ObjectLocked{} } @@ -2746,7 +2766,14 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h return nil } - if err = untar(ctx, hreader, putObjectTar, opts); err != nil { + err = untar(ctx, hreader, putObjectTar, opts) + if code := APIErrorCode(tarS3Err.Load()); code != ErrNone { + s3Err = code + if err == nil { + err = errors.New(errorCodes.ToAPIErr(code).Code) + } + } + if err != nil { apiErr := errorCodes.ToAPIErr(s3Err) // If not set, convert or use BadRequest if s3Err == ErrNone { diff --git a/cmd/replication-trust.go b/cmd/replication-trust.go index a58bf19f2d1d9..9fc93b33d90ea 100644 --- a/cmd/replication-trust.go +++ b/cmd/replication-trust.go @@ -106,11 +106,9 @@ func hasReplicationRequestHeaders(h http.Header) bool { } func cloneRequestWithoutReplicationHeaders(r *http.Request, ctx context.Context) *http.Request { - clone := new(http.Request) - *clone = *r - clone.Header = r.Header.Clone() + clone := r.Clone(ctx) stripReplicationRequestHeaders(clone.Header) - return clone.WithContext(ctx) + return clone } // applyReplicationTrust binds the handler context to the effective request. diff --git a/cmd/replication-trust_test.go b/cmd/replication-trust_test.go index e406611e6373c..b01489d50f8d6 100644 --- a/cmd/replication-trust_test.go +++ b/cmd/replication-trust_test.go @@ -11,6 +11,7 @@ package cmd import ( + "archive/tar" "bytes" "context" "crypto/md5" @@ -21,11 +22,14 @@ import ( "net/http/httptest" "net/url" "strconv" + "strings" "testing" "time" + "github.com/minio/madmin-go/v3" "github.com/minio/minio/internal/auth" xhttp "github.com/minio/minio/internal/http" + "github.com/minio/pkg/v3/policy" ) func TestAPIReplicationTrustProtectsSSECReads(t *testing.T) { @@ -272,6 +276,153 @@ func testAPIPutObjectReplicationTrust(obj ObjectLayer, instanceType, bucketName } } +func TestAPISnowballReplicationTrustIsPerEntry(t *testing.T) { + defer DetectTestLeak(t)() + ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{ + t: t, + objAPITest: testAPISnowballReplicationTrustIsPerEntry, + }) +} + +func testAPISnowballReplicationTrustIsPerEntry(obj ObjectLayer, instanceType, bucketName string, + apiRouter http.Handler, _ auth.Credentials, t *testing.T, +) { + const ( + allowedPrefix = "snowball/allowed/" + deniedPrefix = "snowball/denied/" + sourceETag = "0123456789abcdef0123456789abcdef" + ) + creds := newSnowballReplicationTrustUser(t, instanceType, bucketName, allowedPrefix) + + var body bytes.Buffer + tw := tar.NewWriter(&body) + objects := make([]struct { + name string + trusted bool + }, 0, 32) + for i := 0; i < 16; i++ { + for _, entry := range []struct { + prefix string + trusted bool + }{ + {prefix: allowedPrefix, trusted: true}, + {prefix: deniedPrefix}, + } { + name := entry.prefix + strconv.Itoa(i) + data := []byte("snowball replication trust " + name) + if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o600, Size: int64(len(data))}); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(data); err != nil { + t.Fatal(err) + } + objects = append(objects, struct { + name string + trusted bool + }{name: name, trusted: entry.trusted}) + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + + headers := map[string]string{ + xhttp.AmzSnowballExtract: "true", + xhttp.MinIOSourceReplicationRequest: "true", + xhttp.MinIOSourceETag: sourceETag, + } + for _, test := range []struct { + name string + trailer bool + }{ + {name: "signed-v4"}, + {name: "streaming-unsigned-trailer", trailer: true}, + } { + t.Run(test.name, func(t *testing.T) { + var req *http.Request + var err error + if test.trailer { + req, err = newStreamingUnsignedTrailerRequest(http.MethodPut, + getPutObjectURL("", bucketName, "snowball.tar"), body.Bytes(), UTCNow()) + if err == nil { + for name, value := range headers { + req.Header.Set(name, value) + } + err = signRequestV4(req, creds.AccessKey, creds.SecretKey) + } + } else { + req, err = newTestSignedRequestV4(http.MethodPut, getPutObjectURL("", bucketName, "snowball.tar"), + int64(body.Len()), bytes.NewReader(body.Bytes()), creds.AccessKey, creds.SecretKey, headers) + } + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + apiRouter.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("%s: Snowball PUT status %d: %s", instanceType, rec.Code, rec.Body.String()) + } + + for _, object := range objects { + info, err := obj.GetObjectInfo(t.Context(), bucketName, object.name, ObjectOptions{}) + if err != nil { + t.Fatalf("%s: get %s: %v", instanceType, object.name, err) + } + if object.trusted && info.ETag != sourceETag { + t.Errorf("%s: trusted entry %s ETag = %q, want source ETag", instanceType, object.name, info.ETag) + } + if !object.trusted && info.ETag == sourceETag { + t.Errorf("%s: untrusted entry %s preserved source ETag", instanceType, object.name) + } + } + }) + } +} + +func newSnowballReplicationTrustUser(t *testing.T, instanceType, bucketName, allowedPrefix string) auth.Credentials { + t.Helper() + ctx := t.Context() + accessKey, secretKey, err := auth.GenerateCredentials() + if err != nil { + t.Fatalf("%s: generate credentials: %v", instanceType, err) + } + creds := auth.Credentials{AccessKey: accessKey, SecretKey: secretKey} + if _, err = globalIAMSys.CreateUser(ctx, creds.AccessKey, madmin.AddOrUpdateUserReq{ + SecretKey: creds.SecretKey, + Status: madmin.AccountEnabled, + }); err != nil { + t.Fatalf("%s: create Snowball user: %v", instanceType, err) + } + + policyJSON := `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:PutObject"], + "Resource": ["arn:aws:s3:::` + bucketName + `/*"] + }, + { + "Effect": "Allow", + "Action": ["s3:ReplicateObject"], + "Resource": ["arn:aws:s3:::` + bucketName + `/` + allowedPrefix + `*"] + } + ] +}` + parsed, err := policy.ParseConfig(strings.NewReader(policyJSON)) + if err != nil { + t.Fatalf("%s: parse Snowball policy: %v", instanceType, err) + } + policyName := "snowball-replication-trust-" + mustGetUUID() + if _, err = globalIAMSys.SetPolicy(ctx, policyName, *parsed); err != nil { + t.Fatalf("%s: install Snowball policy: %v", instanceType, err) + } + if _, err = globalIAMSys.PolicyDBSet(ctx, creds.AccessKey, policyName, regUser, false); err != nil { + t.Fatalf("%s: attach Snowball policy: %v", instanceType, err) + } + return creds +} + func TestAPICopyObjectMarkerOnlyDoesNotCopyCiphertext(t *testing.T) { defer DetectTestLeak(t)() ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{ From 5db7be4ee40d55fefb0a3b4548e05f23109f9c4b Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Wed, 2 Sep 2026 00:03:33 +0800 Subject: [PATCH 5/7] fix: validate replication within the rule prefix Place synthetic permission-check objects under each enabled rule's effective prefix, so least-privilege target policies are validated against the namespace they will actually replicate. Signed-off-by: Feng Ruohang --- cmd/bucket-replication-handlers.go | 6 +++++- cmd/bucket-replication_test.go | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/cmd/bucket-replication-handlers.go b/cmd/bucket-replication-handlers.go index 05a9d2e859c1a..02f8f442f68c4 100644 --- a/cmd/bucket-replication-handlers.go +++ b/cmd/bucket-replication-handlers.go @@ -617,7 +617,7 @@ func (api objectAPIHandlers) ValidateBucketReplicationCredsHandler(w http.Respon ReplicationValidityCheck: true, // set this to validate the replication config }, } - obj := path.Join(minioReservedBucket, globalLocalNodeNameHex, "deleteme") + obj := replicationValidationObject(rule) ui, err := c.PutObject(ctx, clnt.Bucket, obj, reader, int64(len(buf)), "", "", putOpts) if err != nil && !isReplicationPermissionCheck(ErrorRespToObjectError(err, bucket, obj)) { writeErrorResponse(ctx, w, errorCodes.ToAPIErrWithErr(ErrReplicationValidationError, fmt.Errorf("s3:ReplicateObject permissions missing for replication user: %w", err)), r.URL) @@ -658,3 +658,7 @@ func (api objectAPIHandlers) ValidateBucketReplicationCredsHandler(w http.Respon // Write success response. writeSuccessResponseHeadersOnly(w) } + +func replicationValidationObject(rule replication.Rule) string { + return path.Join(rule.Prefix(), minioReservedBucket, globalLocalNodeNameHex, "deleteme") +} diff --git a/cmd/bucket-replication_test.go b/cmd/bucket-replication_test.go index ada944d20bb1b..db57f5eb01590 100644 --- a/cmd/bucket-replication_test.go +++ b/cmd/bucket-replication_test.go @@ -20,6 +20,7 @@ package cmd import ( "fmt" "net/http" + "path" "testing" "time" @@ -287,3 +288,22 @@ func TestReplicationResyncwrapper(t *testing.T) { } } } + +func TestReplicationValidationObjectUsesRulePrefix(t *testing.T) { + tests := []struct { + name string + rule replication.Rule + want string + }{ + {name: "empty prefix", rule: replication.Rule{}, want: path.Join(minioReservedBucket, globalLocalNodeNameHex, "deleteme")}, + {name: "filter prefix", rule: replication.Rule{Filter: replication.Filter{Prefix: "data/"}}, want: path.Join("data", minioReservedBucket, globalLocalNodeNameHex, "deleteme")}, + {name: "and prefix", rule: replication.Rule{Filter: replication.Filter{And: replication.And{Prefix: "archive/"}}}, want: path.Join("archive", minioReservedBucket, globalLocalNodeNameHex, "deleteme")}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := replicationValidationObject(test.rule); got != test.want { + t.Fatalf("replicationValidationObject() = %q, want %q", got, test.want) + } + }) + } +} From ab3ae99ca30c7abb0f20b68fb2e6af29ccc6d071 Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Wed, 2 Sep 2026 00:20:06 +0800 Subject: [PATCH 6/7] fix: preserve Snowball request defaults across workers Snapshot per-entry requests after applying bucket encryption defaults but before streaming trailers are consumed. Keep authorization failures fatal while retaining Snowball ignore-errors behavior for object-lock failures. Signed-off-by: Feng Ruohang --- cmd/object-handlers.go | 17 +++++----- cmd/replication-trust_test.go | 59 +++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/cmd/object-handlers.go b/cmd/object-handlers.go index 87c63f9b6dcc5..b3c706be1e3d9 100644 --- a/cmd/object-handlers.go +++ b/cmd/object-handlers.go @@ -2491,12 +2491,6 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h sha256hex = getContentSha256Cksum(r, serviceS3) } } - entryRequestBase := r.Clone(ctx) - // The streaming reader fills r.Trailer while untar writes small entries in - // parallel. Entry authorization never consumes trailers, so keep them out - // of the immutable request template cloned by those goroutines. - entryRequestBase.Trailer = nil - hreader, err := hash.NewReader(ctx, reader, size, md5hex, sha256hex, size) if err != nil { writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) @@ -2517,6 +2511,12 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h sseConfig.Apply(r.Header, sse.ApplyOptions{ AutoEncrypt: globalAutoEncryption, }) + entryRequestBase := r.Clone(ctx) + // The streaming reader fills r.Trailer while untar writes small entries in + // parallel. Entry authorization never consumes trailers, so keep them out + // of the immutable request template cloned by those goroutines. Snapshot + // after applying bucket defaults so extracted objects retain encryption. + entryRequestBase.Trailer = nil rawReplica := hasReplicaStatus(r.Header) markerExact := hasReplicationMarker(r.Header) trustedRequestCtx := withReplicationTrust(ctx, true, rawReplica) @@ -2551,6 +2551,7 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h setTarS3Err := func(code APIErrorCode) { tarS3Err.CompareAndSwap(int32(ErrNone), int32(code)) } + ignoreEntryErrors := opts.ignoreErrs putObjectTar := func(reader io.Reader, info os.FileInfo, object string) error { size := info.Size() @@ -2677,7 +2678,9 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h } if s3err != ErrNone { - setTarS3Err(s3err) + if !ignoreEntryErrors { + setTarS3Err(s3err) + } return ObjectLocked{} } diff --git a/cmd/replication-trust_test.go b/cmd/replication-trust_test.go index b01489d50f8d6..8b74443ac341c 100644 --- a/cmd/replication-trust_test.go +++ b/cmd/replication-trust_test.go @@ -28,7 +28,9 @@ import ( "github.com/minio/madmin-go/v3" "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" "github.com/minio/pkg/v3/policy" ) @@ -379,6 +381,63 @@ func testAPISnowballReplicationTrustIsPerEntry(obj ObjectLayer, instanceType, bu } } +func TestAPISnowballInheritsBucketEncryption(t *testing.T) { + defer DetectTestLeak(t)() + ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{ + t: t, + objAPITest: testAPISnowballInheritsBucketEncryption, + }) +} + +func testAPISnowballInheritsBucketEncryption(obj ObjectLayer, instanceType, bucketName string, + apiRouter http.Handler, credentials auth.Credentials, t *testing.T, +) { + previousKMS := GlobalKMS + GlobalKMS = kms.NewStub("snowball-default-encryption") + defer func() { GlobalKMS = previousKMS }() + sseXML := []byte(`AES256`) + if _, err := globalBucketMetadataSys.Update(t.Context(), bucketName, bucketSSEConfig, sseXML); err != nil { + t.Fatalf("%s: configure bucket encryption: %v", instanceType, err) + } + + var body bytes.Buffer + tw := tar.NewWriter(&body) + objects := []string{"encrypted/one", "encrypted/two"} + for _, object := range objects { + data := []byte("snowball bucket encryption " + object) + if err := tw.WriteHeader(&tar.Header{Name: object, Mode: 0o600, Size: int64(len(data))}); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(data); err != nil { + t.Fatal(err) + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + + req, err := newTestSignedRequestV4(http.MethodPut, getPutObjectURL("", bucketName, "encrypted-snowball.tar"), + int64(body.Len()), bytes.NewReader(body.Bytes()), credentials.AccessKey, credentials.SecretKey, + map[string]string{xhttp.AmzSnowballExtract: "true"}) + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + apiRouter.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("%s: Snowball PUT status %d: %s", instanceType, rec.Code, rec.Body.String()) + } + for _, object := range objects { + info, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{}) + if err != nil { + t.Fatalf("%s: get %s: %v", instanceType, object, err) + } + if _, encrypted := crypto.IsEncrypted(info.UserDefined); !encrypted { + t.Errorf("%s: extracted entry %s did not inherit bucket encryption", instanceType, object) + } + } +} + func newSnowballReplicationTrustUser(t *testing.T, instanceType, bucketName, allowedPrefix string) auth.Credentials { t.Helper() ctx := t.Context() From 04b097fd9ff24c909a4cadde2b9b253b7b7ab847 Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Wed, 2 Sep 2026 02:31:20 +0800 Subject: [PATCH 7/7] chore: refresh compatibility and lint baselines Accept the new CORS test routes, resident getter, and replication header literals in the rebrand guard. Apply gofumpt, context-first helper ordering, and spelling fixes required by CI. Signed-off-by: Feng Ruohang --- buildscripts/rebrand-guard/compat-baseline.json | 11 +++++++++++ cmd/bucket-cors-middleware_test.go | 4 ++-- cmd/handler-utils_test.go | 2 +- cmd/object-handlers.go | 4 ++-- cmd/replication-trust.go | 10 ++++++---- 5 files changed, 22 insertions(+), 9 deletions(-) diff --git a/buildscripts/rebrand-guard/compat-baseline.json b/buildscripts/rebrand-guard/compat-baseline.json index 2f8006ae5bdb7..af5e9f8478eed 100644 --- a/buildscripts/rebrand-guard/compat-baseline.json +++ b/buildscripts/rebrand-guard/compat-baseline.json @@ -693,6 +693,7 @@ "/%s/us-east-1/s3/aws4_request", "/*", "/../../etc", + "/../obj", "/./abc/def", "/.dockerenv", "/.trash", @@ -705,6 +706,7 @@ "//contains/double-forwardslash-prefix", "/?", "/?X-Amz-Algorithm=AWS4-HMAC-SHA256\u0026X-Amz-Credential=USWUXHGYZQYFYFFIT3RE%2F20170529%2Fus-east-1%2Fs3%2Faws4_request\u0026X-Amz-Date=20170529T190139Z\u0026X-Amz-Expires=600\u0026X-Amz-Signature=19b58080999df54b446fc97304eb8dda60d3df1812ae97f3e8783351bfd9781d\u0026X-Amz-SignedHeaders=host\u0026prefix=Hello%2AWorld%2A", + "/A/obj", "/a", "/a/b/c", "/a/b/c/d/e/f/g", @@ -721,6 +723,7 @@ "/admin", "/afile", "/api/requests", + "/api/v1/login", "/apis", "/audit", "/background-heal/status", @@ -852,6 +855,7 @@ "/ls", "/metrics", "/metrics/v3", + "/minio/admin/v3/info", "/minio/grid/", "/minio/grid/lock/", "/minio/health/cluster", @@ -970,6 +974,7 @@ "/speedtest/site", "/start-job", "/startprofiling", + "/startup-missing/object", "/status", "/status-job", "/storage", @@ -1020,6 +1025,7 @@ "/version", "/vfile", "/wall", + "/x/obj", "/xl.meta", "/{bucket}", "/{object:.+}" @@ -3139,6 +3145,7 @@ "cmd:cmd:method:BucketMetadataSys.GetPolicyConfig", "cmd:cmd:method:BucketMetadataSys.GetQuotaConfig", "cmd:cmd:method:BucketMetadataSys.GetReplicationConfig", + "cmd:cmd:method:BucketMetadataSys.GetResidentCorsConfig", "cmd:cmd:method:BucketMetadataSys.GetSSEConfig", "cmd:cmd:method:BucketMetadataSys.GetTaggingConfig", "cmd:cmd:method:BucketMetadataSys.GetVersioningConfig", @@ -10258,6 +10265,10 @@ "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\"", + "cmd/replication-trust.go=\"X-Minio-Replication-Server-Side-Encryption-Sealed-Key\"", "cmd/s3-zip-handlers.go=\"x-minio-extract\"", "cmd/server-startup-msg.go=\"https://silo.pgsty.com/reference/minio-mc/#quickstart\"", "cmd/storage-rest-server.go=\"X-Minio-Time\"", diff --git a/cmd/bucket-cors-middleware_test.go b/cmd/bucket-cors-middleware_test.go index f18bd23775f2c..dd8a934adcb78 100644 --- a/cmd/bucket-cors-middleware_test.go +++ b/cmd/bucket-cors-middleware_test.go @@ -495,7 +495,7 @@ func requireCorsOriginVary(t *testing.T, header http.Header) { } // markBucketMetadataInitialized marks the global bucket-metadata subsystem as -// fully loaded, modelling a running server (the API test harness sets up the +// fully loaded, modeling a running server (the API test harness sets up the // subsystem but does not run Init). It returns a function that restores the // previous state. func markBucketMetadataInitialized(t *testing.T) func() { @@ -637,7 +637,7 @@ func testBucketCorsStartupMissFailsClosedWithoutIO(obj ObjectLayer, _ string, _ } // markBucketMetadataLoadFailed records a bucket as one whose metadata failed to -// load at startup while the subsystem is Initialized, modelling the degraded +// load at startup while the subsystem is Initialized, modeling the degraded // state where a real bucket is not resident. Returns a restore function. func markBucketMetadataLoadFailed(t *testing.T, bucket string) func() { t.Helper() diff --git a/cmd/handler-utils_test.go b/cmd/handler-utils_test.go index 5ae2b1619436d..15c206dc4cfad 100644 --- a/cmd/handler-utils_test.go +++ b/cmd/handler-utils_test.go @@ -325,7 +325,7 @@ func TestCloneRequestWithoutReplicationHeaders(t *testing.T) { req.Header.Set("X-Minio-Replication-Server-Side-Encryption-Sealed-Key", "sealed") req.Header.Set("Content-Type", "application/octet-stream") - clone := cloneRequestWithoutReplicationHeaders(req, t.Context()) + clone := cloneRequestWithoutReplicationHeaders(t.Context(), req) if clone == req { t.Fatal("expected cloned request") } diff --git a/cmd/object-handlers.go b/cmd/object-handlers.go index b3c706be1e3d9..54eb6ace7d16f 100644 --- a/cmd/object-handlers.go +++ b/cmd/object-handlers.go @@ -2522,7 +2522,7 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h trustedRequestCtx := withReplicationTrust(ctx, true, rawReplica) trustedRequest := entryRequestBase.WithContext(trustedRequestCtx) cleanRequestCtx := withReplicationTrust(ctx, false, false) - cleanRequest := cloneRequestWithoutReplicationHeaders(entryRequestBase, cleanRequestCtx) + cleanRequest := cloneRequestWithoutReplicationHeaders(cleanRequestCtx, entryRequestBase) trustedReqParams := extractReqParams(trustedRequest) cleanReqParams := extractReqParams(cleanRequest) @@ -2572,7 +2572,7 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h entryTrusted := markerExact && replicationPermitted replicaTrusted := entryTrusted && rawReplica entryCtx := cleanRequestCtx - entryReq := cloneRequestWithoutReplicationHeaders(entryAuthReq, cleanRequestCtx) + entryReq := cloneRequestWithoutReplicationHeaders(cleanRequestCtx, entryAuthReq) reqParams := cleanReqParams if entryTrusted { entryCtx = trustedRequestCtx diff --git a/cmd/replication-trust.go b/cmd/replication-trust.go index 9fc93b33d90ea..508a13db05dc8 100644 --- a/cmd/replication-trust.go +++ b/cmd/replication-trust.go @@ -20,8 +20,10 @@ import ( "github.com/minio/pkg/v3/policy" ) -type replicationTrustKey struct{} -type replicaTrustKey struct{} +type ( + replicationTrustKey struct{} + replicaTrustKey struct{} +) // hasReplicationMarker reports whether the internal replication marker has // its one accepted wire value. Header presence alone is never a trust signal. @@ -105,7 +107,7 @@ func hasReplicationRequestHeaders(h http.Header) bool { return false } -func cloneRequestWithoutReplicationHeaders(r *http.Request, ctx context.Context) *http.Request { +func cloneRequestWithoutReplicationHeaders(ctx context.Context, r *http.Request) *http.Request { clone := r.Clone(ctx) stripReplicationRequestHeaders(clone.Header) return clone @@ -119,5 +121,5 @@ func applyReplicationTrust(ctx context.Context, r *http.Request, trusted, replic if trusted { return ctx, r.WithContext(ctx) } - return ctx, cloneRequestWithoutReplicationHeaders(r, ctx) + return ctx, cloneRequestWithoutReplicationHeaders(ctx, r) }