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 <rh@vonng.com>
This commit is contained in:
Feng Ruohang
2026-09-01 20:50:08 +08:00
parent d5e763b072
commit 938603458d
15 changed files with 1377 additions and 164 deletions
+10 -1
View File
@@ -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
+268 -1
View File
@@ -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)
}
}
+86
View File
@@ -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{},
}
}
+10 -10
View File
@@ -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 {
+1 -1
View File
@@ -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
}
}
+1 -2
View File
@@ -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
+8 -2
View File
@@ -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)
+46 -34
View File
@@ -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
}
+143 -91
View File
@@ -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)
}
trustedReplicationRequest := allowReplicationMetadata && r.Header.Get(xhttp.MinIOSourceReplicationRequest) == "true"
optsReq := r
if !trustedReplicationRequest {
optsReq = cloneRequestWithoutCopyReplicationHeaders(r)
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)
}
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 {
+64 -15
View File
@@ -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)
}
+44
View File
@@ -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 {
+125
View File
@@ -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)
}
+560
View File
@@ -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")
}
}
+2 -6
View File
@@ -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
}
+9 -1
View File
@@ -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) {