diff --git a/SECURITY.md b/SECURITY.md
index fcd954b01..dcfd6e4ea 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -10,6 +10,25 @@ Security fixes are tracked on the active development branch and summarized in
[docs/security/advisories.md](docs/security/advisories.md). Only the current
Silo release line is supported unless an advisory says otherwise.
+## Inherited Fix Evidence
+
+The canonical ledger also records security fixes inherited from upstream when
+they are part of the Silo release baseline. Source and fork commits are linked
+separately even when the fork preserves the original commit object and SHA.
+
+- [CVE-2025-62506](https://github.com/advisories/GHSA-jjjj-jwhf-8rgr):
+ upstream [PR #21642](https://github.com/minio/minio/pull/21642) merged as
+ [`minio/minio@c1a49490`](https://github.com/minio/minio/commit/c1a49490c78e9c3ebcad86ba0662319138ace190),
+ inherited unchanged as
+ [`pgsty/silo@c1a49490`](https://github.com/pgsty/silo/commit/c1a49490c78e9c3ebcad86ba0662319138ace190),
+ and is present in every Silo community release beginning with
+ [`RELEASE.2025-12-03T12-00-00Z`](https://github.com/pgsty/silo/releases/tag/RELEASE.2025-12-03T12-00-00Z).
+ The inherited [service-account](https://github.com/pgsty/silo/blob/c1a49490c78e9c3ebcad86ba0662319138ace190/cmd/admin-handlers-users_test.go#L211-L212)
+ and [STS](https://github.com/pgsty/silo/blob/c1a49490c78e9c3ebcad86ba0662319138ace190/cmd/sts-handlers_test.go#L45-L46)
+ regression groups remain part of `go test ./cmd`; see the
+ [canonical ledger](docs/security/advisories.md#inherited-upstream-advisory-baseline)
+ for the operator-facing record.
+
## Reporting a Vulnerability
For vulnerabilities in this fork:
diff --git a/cmd/admin-handlers-site-replication.go b/cmd/admin-handlers-site-replication.go
index ef74c9676..14dca5542 100644
--- a/cmd/admin-handlers-site-replication.go
+++ b/cmd/admin-handlers-site-replication.go
@@ -255,7 +255,7 @@ func (a adminAPIHandlers) SRPeerReplicateBucketItem(w http.ResponseWriter, r *ht
case madmin.SRBucketMetaTypeTags:
err = globalSiteReplicationSys.PeerBucketTaggingHandler(ctx, item.Bucket, item.Tags, item.UpdatedAt)
case madmin.SRBucketMetaTypeObjectLockConfig:
- err = globalSiteReplicationSys.PeerBucketObjectLockConfigHandler(ctx, item.Bucket, item.ObjectLockConfig, item.UpdatedAt)
+ err = globalSiteReplicationSys.peerBucketObjectLockConfigItem(ctx, item)
case madmin.SRBucketMetaTypeSSEConfig:
err = globalSiteReplicationSys.PeerBucketSSEConfigHandler(ctx, item.Bucket, item.SSEConfig, item.UpdatedAt)
case madmin.SRBucketMetaTypeCorsConfig:
diff --git a/cmd/admin-handlers-users.go b/cmd/admin-handlers-users.go
index c9ac0f4d8..e5dc080c6 100644
--- a/cmd/admin-handlers-users.go
+++ b/cmd/admin-handlers-users.go
@@ -355,18 +355,25 @@ func (a adminAPIHandlers) ListGroups(w http.ResponseWriter, r *http.Request) {
}
// SetGroupStatus - PUT /minio/admin/v3/set-group-status?group=mygroup1&status=enabled
+func setGroupStatusAdminAction(status string) policy.AdminAction {
+ if madmin.GroupStatus(status) == madmin.GroupDisabled {
+ return policy.DisableGroupAdminAction
+ }
+ return policy.EnableGroupAdminAction
+}
+
func (a adminAPIHandlers) SetGroupStatus(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
- objectAPI, _ := validateAdminReq(ctx, w, r, policy.EnableGroupAdminAction)
- if objectAPI == nil {
- return
- }
-
vars := mux.Vars(r)
group := vars["group"]
status := vars["status"]
+ objectAPI, _ := validateAdminReq(ctx, w, r, setGroupStatusAdminAction(status))
+ if objectAPI == nil {
+ return
+ }
+
var (
err error
updatedAt time.Time
diff --git a/cmd/admin-handlers-users_test.go b/cmd/admin-handlers-users_test.go
index 74ad6b8a3..9eea58877 100644
--- a/cmd/admin-handlers-users_test.go
+++ b/cmd/admin-handlers-users_test.go
@@ -68,6 +68,26 @@ func TestSetUserStatusAdminAction(t *testing.T) {
}
}
+func TestSetGroupStatusAdminAction(t *testing.T) {
+ tests := []struct {
+ name string
+ status string
+ want policy.AdminAction
+ }{
+ {name: "enable", status: string(madmin.GroupEnabled), want: policy.EnableGroupAdminAction},
+ {name: "disable", status: string(madmin.GroupDisabled), want: policy.DisableGroupAdminAction},
+ {name: "invalid preserves authenticated default", status: "invalid", want: policy.EnableGroupAdminAction},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := setGroupStatusAdminAction(tt.status); got != tt.want {
+ t.Fatalf("setGroupStatusAdminAction(%q) = %q, want %q", tt.status, got, tt.want)
+ }
+ })
+ }
+}
+
// API suite container for IAM
type TestSuiteIAM struct {
TestSuiteCommon
@@ -224,6 +244,7 @@ func TestIAMInternalIDPServerSuite(t *testing.T) {
suite.SetUpSuite(c)
suite.TestUserCreate(c)
suite.TestUserStatusActionAuthorization(c)
+ suite.TestGroupStatusActionAuthorization(c)
suite.TestUserPolicyEscalationBug(c)
suite.TestPolicyCreate(c)
suite.TestServiceAccountBareARNPolicyRejected(c)
@@ -413,6 +434,106 @@ func (s *TestSuiteIAM) TestUserStatusActionAuthorization(c *check) {
}
}
+func (s *TestSuiteIAM) TestGroupStatusActionAuthorization(c *check) {
+ ctx, cancel := context.WithTimeout(context.Background(), testDefaultTimeout)
+ defer cancel()
+
+ var createdUsers []string
+ var createdPolicies []string
+ group := getRandomBucketName()
+ var groupCreated bool
+ defer func() {
+ if groupCreated {
+ if err := s.adm.UpdateGroupMembers(ctx, madmin.GroupAddRemove{
+ Group: group,
+ Members: createdUsers[:1],
+ IsRemove: true,
+ }); err != nil {
+ c.Errorf("unable to remove group member: %v", err)
+ }
+ if err := s.adm.UpdateGroupMembers(ctx, madmin.GroupAddRemove{Group: group, IsRemove: true}); err != nil {
+ c.Errorf("unable to remove test group: %v", err)
+ }
+ }
+ for _, user := range createdUsers {
+ if err := s.adm.RemoveUser(ctx, user); err != nil {
+ c.Errorf("unable to remove test user %s: %v", user, err)
+ }
+ }
+ for _, policyName := range createdPolicies {
+ if err := s.adm.RemoveCannedPolicy(ctx, policyName); err != nil {
+ c.Errorf("unable to remove test policy %s: %v", policyName, err)
+ }
+ }
+ }()
+
+ createUser := func() (string, string) {
+ accessKey, secretKey := mustGenerateCredentials(c)
+ if err := s.adm.SetUser(ctx, accessKey, secretKey, madmin.AccountEnabled); err != nil {
+ c.Fatalf("unable to create test user: %v", err)
+ }
+ createdUsers = append(createdUsers, accessKey)
+ return accessKey, secretKey
+ }
+
+ targetAccessKey, _ := createUser()
+ if err := s.adm.UpdateGroupMembers(ctx, madmin.GroupAddRemove{
+ Group: group,
+ Members: []string{targetAccessKey},
+ }); err != nil {
+ c.Fatalf("unable to create test group: %v", err)
+ }
+ groupCreated = true
+
+ createStatusClient := func(action policy.AdminAction) *madmin.AdminClient {
+ accessKey, secretKey := createUser()
+ policyName := getRandomBucketName()
+ policyBytes := fmt.Appendf(nil, `{
+ "Version": "2012-10-17",
+ "Statement": [{
+ "Effect": "Allow",
+ "Action": ["%s"]
+ }]
+}`, action)
+ if err := s.adm.AddCannedPolicy(ctx, policyName, policyBytes); err != nil {
+ c.Fatalf("unable to add group status policy: %v", err)
+ }
+ createdPolicies = append(createdPolicies, policyName)
+ if _, err := s.adm.AttachPolicy(ctx, madmin.PolicyAssociationReq{
+ Policies: []string{policyName},
+ User: accessKey,
+ }); err != nil {
+ c.Fatalf("unable to attach group status policy: %v", err)
+ }
+
+ client, err := madmin.NewWithOptions(s.endpoint, &madmin.Options{
+ Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
+ Secure: s.secure,
+ })
+ if err != nil {
+ c.Fatalf("unable to create group status admin client: %v", err)
+ }
+ client.SetCustomTransport(s.TestSuiteCommon.client.Transport)
+ return client
+ }
+
+ disableClient := createStatusClient(policy.DisableGroupAdminAction)
+ if err := disableClient.SetGroupStatus(ctx, group, madmin.GroupDisabled); err != nil {
+ c.Fatalf("DisableGroup-only client could not disable a group: %v", err)
+ }
+ if err := disableClient.SetGroupStatus(ctx, group, madmin.GroupEnabled); err == nil || madmin.ToErrorResponse(err).Code != "AccessDenied" {
+ c.Fatalf("DisableGroup-only client unexpectedly enabled a group: %v", err)
+ }
+
+ enableClient := createStatusClient(policy.EnableGroupAdminAction)
+ if err := enableClient.SetGroupStatus(ctx, group, madmin.GroupEnabled); err != nil {
+ c.Fatalf("EnableGroup-only client could not enable a group: %v", err)
+ }
+ if err := enableClient.SetGroupStatus(ctx, group, madmin.GroupDisabled); err == nil || madmin.ToErrorResponse(err).Code != "AccessDenied" {
+ c.Fatalf("EnableGroup-only client unexpectedly disabled a group: %v", err)
+ }
+}
+
func (s *TestSuiteIAM) TestUserPolicyEscalationBug(c *check) {
ctx, cancel := context.WithTimeout(context.Background(), testDefaultTimeout)
defer cancel()
diff --git a/cmd/api-response.go b/cmd/api-response.go
index c6ad7b07e..43020a5a3 100644
--- a/cmd/api-response.go
+++ b/cmd/api-response.go
@@ -777,8 +777,7 @@ func generateListObjectsV2Response(ctx context.Context, bucket, prefix, token, n
type metaCheckFn = func(name string, action policy.Action) (s3Err APIErrorCode)
// generates CopyObjectResponse from the committed object information.
-func generateCopyObjectResponse(oi ObjectInfo, h http.Header) CopyObjectResponse {
- cs, _ := oi.decryptChecksums(0, h)
+func generateCopyObjectResponse(oi ObjectInfo, cs map[string]string) CopyObjectResponse {
return CopyObjectResponse{
ETag: "\"" + oi.ETag + "\"",
LastModified: amztime.ISO8601Format(oi.ModTime.UTC()),
diff --git a/cmd/api-router.go b/cmd/api-router.go
index 8de86e9bb..63164a06c 100644
--- a/cmd/api-router.go
+++ b/cmd/api-router.go
@@ -785,19 +785,21 @@ func corsHandler(handler http.Handler) http.Handler {
}
globalCors := cors.New(opts).Handler(handler)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if bucket, _ := request2BucketObjectName(r); bucket != "" && globalBucketMetadataSys != nil {
- cfg, _, err := globalBucketMetadataSys.GetCorsConfig(bucket)
- if err == nil && cfg != nil {
- if applyBucketCors(w, r, cfg) {
+ if r.Header.Get("Origin") != "" {
+ if bucket, _ := request2BucketObjectName(r); bucket != "" && globalBucketMetadataSys != nil {
+ cfg, _, err := globalBucketMetadataSys.GetCorsConfig(bucket)
+ if err == nil && cfg != nil {
+ if applyBucketCors(w, r, cfg) {
+ return
+ }
+ handler.ServeHTTP(w, r)
+ return
+ }
+ if err != nil && !errors.Is(err, errConfigNotFound) {
+ internalLogOnceIf(r.Context(), err, "bucket-cors-metadata")
+ handler.ServeHTTP(w, r)
return
}
- handler.ServeHTTP(w, r)
- return
- }
- if err != nil && !errors.Is(err, errConfigNotFound) && r.Header.Get("Origin") != "" {
- internalLogOnceIf(r.Context(), err, "bucket-cors-metadata")
- handler.ServeHTTP(w, r)
- return
}
}
globalCors.ServeHTTP(w, r)
diff --git a/cmd/bucket-cors-middleware_test.go b/cmd/bucket-cors-middleware_test.go
index be6d1ddda..f168e0f31 100644
--- a/cmd/bucket-cors-middleware_test.go
+++ b/cmd/bucket-cors-middleware_test.go
@@ -18,15 +18,27 @@
package cmd
import (
+ "context"
"net/http"
"net/http/httptest"
"strings"
+ "sync/atomic"
"testing"
"github.com/minio/minio/internal/auth"
"github.com/minio/minio/internal/bucket/cors"
)
+type corsLookupCountingObjectLayer struct {
+ ObjectLayer
+ getObjectNInfoCalls atomic.Int64
+}
+
+func (o *corsLookupCountingObjectLayer) GetObjectNInfo(ctx context.Context, bucket, object string, rs *HTTPRangeSpec, h http.Header, opts ObjectOptions) (*GetObjectReader, error) {
+ o.getObjectNInfoCalls.Add(1)
+ return o.ObjectLayer.GetObjectNInfo(ctx, bucket, object, rs, h, opts)
+}
+
func TestPerBucketCorsPreflight(t *testing.T) {
cfg := &cors.Config{CORSRules: []cors.Rule{{
AllowedOrigins: []string{"http://example.com"},
@@ -245,8 +257,13 @@ func TestPerBucketCorsOriginPatternResponse(t *testing.T) {
func TestBucketCorsMetadataErrorFailsClosed(t *testing.T) {
oldObjectAPI := newObjectLayerFn()
+ oldMetadataSys := globalBucketMetadataSys
setObjectLayer(nil)
- defer setObjectLayer(oldObjectAPI)
+ globalBucketMetadataSys = NewBucketMetadataSys()
+ defer func() {
+ setObjectLayer(oldObjectAPI)
+ globalBucketMetadataSys = oldMetadataSys
+ }()
wrapped := corsHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
@@ -273,6 +290,55 @@ func TestBucketCorsMetadataErrorFailsClosed(t *testing.T) {
}
}
+func TestBucketCorsSkipsMetadataLookupWithoutOrigin(t *testing.T) {
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
+ t: t,
+ objAPITest: testBucketCorsSkipsMetadataLookupWithoutOrigin,
+ endpoints: []string{"GetObject"},
+ })
+}
+
+func testBucketCorsSkipsMetadataLookupWithoutOrigin(obj ObjectLayer, _ string, _ string, _ http.Handler, _ auth.Credentials, t *testing.T) {
+ oldObjectAPI := newObjectLayerFn()
+ counting := &corsLookupCountingObjectLayer{ObjectLayer: obj}
+ setObjectLayer(counting)
+ defer setObjectLayer(oldObjectAPI)
+
+ wrapped := corsHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ rec := httptest.NewRecorder()
+ wrapped.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, getGetObjectURL("", "api", "v1/login"), nil))
+
+ if rec.Code != http.StatusNoContent {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
+ }
+ requireCorsOriginVary(t, rec.Header())
+ if got := counting.getObjectNInfoCalls.Load(); got != 0 {
+ t.Fatalf("request without Origin performed %d bucket metadata reads", got)
+ }
+}
+
+func TestBucketCorsOriginlessPreflightShapeUsesGlobalHandler(t *testing.T) {
+ nextCalled := false
+ wrapped := corsHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ nextCalled = true
+ w.WriteHeader(http.StatusTeapot)
+ }))
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodOptions, getGetObjectURL("", "api", "v1/login"), nil)
+ req.Header.Set("Access-Control-Request-Method", http.MethodGet)
+ wrapped.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusNoContent {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
+ }
+ if nextCalled {
+ t.Fatal("originless preflight-shaped OPTIONS reached the application handler")
+ }
+ requireCorsOriginVary(t, rec.Header())
+}
+
func TestBucketCorsNoConfigUsesGlobalFallback(t *testing.T) {
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
t: t,
@@ -281,6 +347,34 @@ func TestBucketCorsNoConfigUsesGlobalFallback(t *testing.T) {
})
}
+func TestBucketCorsMissingBucketUsesGlobalFallback(t *testing.T) {
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
+ t: t,
+ objAPITest: testBucketCorsMissingBucketUsesGlobalFallback,
+ endpoints: []string{"GetBucketCors"},
+ })
+}
+
+func testBucketCorsMissingBucketUsesGlobalFallback(_ ObjectLayer, _ string, bucket string, _ http.Handler, _ auth.Credentials, t *testing.T) {
+ wrapped := corsHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ }))
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, getGetObjectURL("", bucket+"-missing", "object"), nil)
+ req.Header.Set("Origin", "https://app.example.com")
+ wrapped.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
+ }
+ if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://app.example.com" {
+ t.Fatalf("allow-origin = %q", got)
+ }
+ if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
+ t.Fatalf("allow-credentials = %q", got)
+ }
+}
+
func testBucketCorsNoConfigUsesGlobalFallback(_ ObjectLayer, _ string, bucket string, _ http.Handler, _ auth.Credentials, t *testing.T) {
wrapped := corsHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
diff --git a/cmd/bucket-handlers.go b/cmd/bucket-handlers.go
index 9be23c21d..47096cbf0 100644
--- a/cmd/bucket-handlers.go
+++ b/cmd/bucket-handlers.go
@@ -1844,12 +1844,7 @@ func (api objectAPIHandlers) PutBucketObjectLockConfigHandler(w http.ResponseWri
// We encode the xml bytes as base64 to ensure there are no encoding
// errors.
cfgStr := base64.StdEncoding.EncodeToString(configData)
- replLogIf(ctx, globalSiteReplicationSys.BucketMetaHook(ctx, madmin.SRBucketMeta{
- Type: madmin.SRBucketMetaTypeObjectLockConfig,
- Bucket: bucket,
- ObjectLockConfig: &cfgStr,
- UpdatedAt: updatedAt,
- }))
+ replLogIf(ctx, globalSiteReplicationSys.BucketMetaHook(ctx, newSRBucketObjectLockMeta(bucket, &cfgStr, updatedAt)))
// Write success response.
writeSuccessResponseHeadersOnly(w)
diff --git a/cmd/bucket-metadata.go b/cmd/bucket-metadata.go
index ba4c0eb5b..9c066ba32 100644
--- a/cmd/bucket-metadata.go
+++ b/cmd/bucket-metadata.go
@@ -344,7 +344,10 @@ func (b *BucketMetadata) parseAllConfigs(ctx context.Context, objectAPI ObjectLa
}
if bytes.Equal(b.ObjectLockConfigXML, enabledBucketObjectLockConfig) {
- b.VersioningConfigXML = enabledBucketVersioningConfig
+ config, versioningErr := versioning.ParseConfig(bytes.NewReader(b.VersioningConfigXML))
+ if versioningErr != nil || !config.Enabled() {
+ b.VersioningConfigXML = enabledBucketVersioningConfig
+ }
}
if len(b.ObjectLockConfigXML) != 0 {
diff --git a/cmd/common-main.go b/cmd/common-main.go
index 7f6ead797..e859f881a 100644
--- a/cmd/common-main.go
+++ b/cmd/common-main.go
@@ -37,6 +37,7 @@ import (
"syscall"
"time"
"unicode"
+ "unicode/utf8"
"github.com/dustin/go-humanize"
fcolor "github.com/fatih/color"
@@ -542,21 +543,17 @@ func (e envKV) String() string {
}
func isValidEnvName(name string) bool {
- if name == "" || !isEnvNameStart(name[0]) {
+ if name == "" || !utf8.ValidString(name) {
return false
}
- for i := 1; i < len(name); i++ {
- if !isEnvNameStart(name[i]) && (name[i] < '0' || name[i] > '9') {
+ for _, ch := range name {
+ if ch == '=' || unicode.IsSpace(ch) || !unicode.IsGraphic(ch) {
return false
}
}
return true
}
-func isEnvNameStart(ch byte) bool {
- return ch == '_' || ch >= 'A' && ch <= 'Z' || ch >= 'a' && ch <= 'z'
-}
-
func trimExportPrefix(envEntry string) string {
rest, ok := strings.CutPrefix(envEntry, "export")
if !ok || rest == "" {
diff --git a/cmd/common-main_test.go b/cmd/common-main_test.go
index 341ddba49..8afcaaf2b 100644
--- a/cmd/common-main_test.go
+++ b/cmd/common-main_test.go
@@ -22,8 +22,11 @@ import (
"fmt"
"os"
"reflect"
+ "slices"
"strings"
"testing"
+
+ "github.com/minio/minio/internal/config"
)
func Test_readFromSecret(t *testing.T) {
@@ -240,6 +243,19 @@ func Test_minioEnvironFromFileWhitespaceAndValidation(t *testing.T) {
{Key: "_VALID_2", Value: "value"},
},
},
+ {
+ name: "named target punctuation and unicode",
+ content: "MINIO_NOTIFY_WEBHOOK_ENABLE_my-hook=off\n" +
+ "MINIO_NOTIFY_WEBHOOK_ENABLE_site.eu=off\n" +
+ "MINIO_NOTIFY_WEBHOOK_ENABLE_team:blue=off\n" +
+ "MINIO_NOTIFY_WEBHOOK_ENABLE_目标=off",
+ want: []envKV{
+ {Key: "MINIO_NOTIFY_WEBHOOK_ENABLE_my-hook", Value: "off"},
+ {Key: "MINIO_NOTIFY_WEBHOOK_ENABLE_site.eu", Value: "off"},
+ {Key: "MINIO_NOTIFY_WEBHOOK_ENABLE_team:blue", Value: "off"},
+ {Key: "MINIO_NOTIFY_WEBHOOK_ENABLE_目标", Value: "off"},
+ },
+ },
{
name: "missing separator redacts the line",
content: "MINIO_ROOT_PASSWORD=valid\nsuper-secret-without-equals",
@@ -255,18 +271,12 @@ func Test_minioEnvironFromFileWhitespaceAndValidation(t *testing.T) {
errExcludes: "empty-name-secret",
},
{
- name: "digit leading name",
- content: "1MINIO_ROOT_USER=digit-leading-secret",
- errLine: 1,
- errContains: `invalid environment variable name "1MINIO_ROOT_USER"`,
- errExcludes: "digit-leading-secret",
- },
- {
- name: "hyphenated name",
- content: "MINIO-ROOT-USER=hyphen-secret",
- errLine: 1,
- errContains: `invalid environment variable name "MINIO-ROOT-USER"`,
- errExcludes: "hyphen-secret",
+ name: "os compatible leading digit and punctuation",
+ content: "1MINIO_ROOT_USER=digit-leading-secret\n-MINIO-ROOT-USER=hyphen-secret",
+ want: []envKV{
+ {Key: "1MINIO_ROOT_USER", Value: "digit-leading-secret"},
+ {Key: "-MINIO-ROOT-USER", Value: "hyphen-secret"},
+ },
},
{
name: "whitespace in name",
@@ -282,6 +292,13 @@ func Test_minioEnvironFromFileWhitespaceAndValidation(t *testing.T) {
errContains: "invalid environment variable name",
errExcludes: "nul-name-secret",
},
+ {
+ name: "format character in name",
+ content: "MINIO\u200bROOT=format-secret",
+ errLine: 1,
+ errContains: "invalid environment variable name",
+ errExcludes: "format-secret",
+ },
{
name: "NUL in value",
content: "MINIO_ROOT_USER=before\x00nul-value-secret",
@@ -342,3 +359,16 @@ func Test_minioEnvironFromFileWhitespaceAndValidation(t *testing.T) {
})
}
}
+
+func TestConfigEnvFileNamedTargetDiscovery(t *testing.T) {
+ key := "MINIO_NOTIFY_WEBHOOK_ENABLE_my-hook"
+ t.Setenv(key, "off")
+
+ targets, err := (config.Config{}).GetAvailableTargets(config.NotifyWebhookSubSys)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !slices.Contains(targets, "my-hook") {
+ t.Fatalf("named target %q not discovered from %s: %v", "my-hook", key, targets)
+ }
+}
diff --git a/cmd/encryption-v1.go b/cmd/encryption-v1.go
index c3da051a8..36857851c 100644
--- a/cmd/encryption-v1.go
+++ b/cmd/encryption-v1.go
@@ -355,6 +355,29 @@ func rotateKey(ctx context.Context, oldKey []byte, newKeyID string, newKey []byt
}
}
+// checkSSECCopySourceKey authenticates the SSE-C copy source key against the
+// sealed object key held in metadata. This keeps the diverted rotation safe on
+// its own and remains defense in depth when the read path also authenticates
+// zero-byte objects. Mirrors the errors rotateKey reports.
+func checkSSECCopySourceKey(h http.Header, metadata map[string]string, bucket, object string, newKey []byte) error {
+ oldKey, err := ParseSSECopyCustomerRequest(h, metadata)
+ if err != nil {
+ return err
+ }
+ sealedKey, err := crypto.SSEC.ParseMetadata(metadata)
+ if err != nil {
+ return err
+ }
+ var objectKey crypto.ObjectKey
+ if err := objectKey.Unseal(oldKey, sealedKey, crypto.SSEC.String(), bucket, object); err != nil {
+ if subtle.ConstantTimeCompare(oldKey, newKey) == 1 {
+ return errInvalidSSEParameters
+ }
+ return crypto.ErrInvalidCustomerKey
+ }
+ return nil
+}
+
func newEncryptMetadata(ctx context.Context, kind crypto.Type, keyID string, key []byte, bucket, object string, metadata map[string]string, cryptoCtx kms.Context) (crypto.ObjectKey, error) {
var sealedKey crypto.SealedKey
switch kind {
@@ -551,6 +574,24 @@ func DecryptCopyRequestR(client io.Reader, h http.Header, bucket, object string,
return newDecryptReader(client, key, bucket, object, seqNumber, metadata)
}
+// checkSSECReadKey authenticates a supplied SSE-C read key against the sealed
+// object key when a read has no data from which to build a decryptor.
+func checkSSECReadKey(h http.Header, oi ObjectInfo, opts ObjectOptions) error {
+ if opts.NoDecryption || opts.Transition.RestoreRequest != nil || !crypto.SSEC.IsEncrypted(oi.UserDefined) {
+ return nil
+ }
+ switch {
+ case crypto.SSECopy.IsRequested(h):
+ _, err := crypto.SSECopy.UnsealObjectKey(h, oi.UserDefined, oi.Bucket, oi.Name)
+ return err
+ case crypto.SSEC.IsRequested(h):
+ _, err := crypto.SSEC.UnsealObjectKey(h, oi.UserDefined, oi.Bucket, oi.Name)
+ return err
+ default:
+ return nil
+ }
+}
+
func newDecryptReader(client io.Reader, key []byte, bucket, object string, seqNumber uint32, metadata map[string]string) (io.Reader, error) {
objectEncryptionKey, err := decryptObjectMeta(key, bucket, object, metadata)
if err != nil {
diff --git a/cmd/erasure-multipart-fullobject_test.go b/cmd/erasure-multipart-fullobject_test.go
index 0bcb14c4f..bb196acda 100644
--- a/cmd/erasure-multipart-fullobject_test.go
+++ b/cmd/erasure-multipart-fullobject_test.go
@@ -420,8 +420,74 @@ func testAPICompleteMultipartChecksumTypeMismatch(obj ObjectLayer, instanceType,
t.Fatalf("%s: object was created despite a rejected checksum type", instanceType)
}
})
+
+ t.Run(test.name+"-type-only", func(t *testing.T) {
+ objectName := "type-mismatch/type-only-" + test.name
+ uploadID := newMultipartUploadHTTP(t, apiRouter, credentials, bucketName, objectName,
+ typ.String(), test.createdType)
+ etags := uploadPartsHTTP(t, apiRouter, credentials, bucketName, objectName, uploadID, typ, partData)
+ partCS := []string{mustChecksum(t, typ, partData[0]), mustChecksum(t, typ, partData[1])}
+ rec := completeMultipartUploadHTTP(t, apiRouter, credentials, bucketName, objectName, uploadID, etags, partCS,
+ map[string]string{xhttp.AmzChecksumType: test.providedType})
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("%s: checksum type-only mismatch returned %d, want 400", instanceType, rec.Code)
+ }
+ apiErr := apiError(t, rec)
+ if apiErr.Code != "BadDigest" {
+ t.Fatalf("%s: expected BadDigest, got %q", instanceType, apiErr.Code)
+ }
+ wantMessage := fmt.Sprintf("The checksum type %s does not match the multipart upload checksum type %s.", test.providedType, test.createdType)
+ if apiErr.Message != wantMessage {
+ t.Fatalf("%s: expected message %q, got %q", instanceType, wantMessage, apiErr.Message)
+ }
+ if _, err := obj.GetObjectInfo(t.Context(), bucketName, objectName, ObjectOptions{}); err == nil {
+ t.Fatalf("%s: object was created despite a rejected checksum type-only assertion", instanceType)
+ }
+ })
}
+ for _, test := range []struct {
+ name string
+ providedType string
+ withChecksum bool
+ }{
+ {name: "unknown-type-only", providedType: "NOT_A_TYPE"},
+ {name: "lowercase-type-only", providedType: "full_object"},
+ {name: "unknown-with-checksum", providedType: "NOT_A_TYPE", withChecksum: true},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ objectName := "type-mismatch/invalid-" + test.name
+ uploadID := newMultipartUploadHTTP(t, apiRouter, credentials, bucketName, objectName,
+ typ.String(), xhttp.AmzChecksumTypeComposite)
+ etags := uploadPartsHTTP(t, apiRouter, credentials, bucketName, objectName, uploadID, typ, partData)
+ partCS := []string{mustChecksum(t, typ, partData[0]), mustChecksum(t, typ, partData[1])}
+ headers := map[string]string{xhttp.AmzChecksumType: test.providedType}
+ if test.withChecksum {
+ headers[typ.Key()] = mustChecksum(t, typ, full)
+ }
+ rec := completeMultipartUploadHTTP(t, apiRouter, credentials, bucketName, objectName, uploadID, etags, partCS, headers)
+ if rec.Code != http.StatusBadRequest || apiErrorCode(t, rec) != "InvalidArgument" {
+ t.Fatalf("%s: invalid checksum type returned %d %s", instanceType, rec.Code, rec.Body.String())
+ }
+ if _, err := obj.GetObjectInfo(t.Context(), bucketName, objectName, ObjectOptions{}); err == nil {
+ t.Fatalf("%s: object was created despite an invalid checksum type", instanceType)
+ }
+ })
+ }
+
+ t.Run("matching-type-only", func(t *testing.T) {
+ objectName := "type-mismatch/matching-type-only"
+ uploadID := newMultipartUploadHTTP(t, apiRouter, credentials, bucketName, objectName,
+ typ.String(), xhttp.AmzChecksumTypeComposite)
+ etags := uploadPartsHTTP(t, apiRouter, credentials, bucketName, objectName, uploadID, typ, partData)
+ partCS := []string{mustChecksum(t, typ, partData[0]), mustChecksum(t, typ, partData[1])}
+ rec := completeMultipartUploadHTTP(t, apiRouter, credentials, bucketName, objectName, uploadID, etags, partCS,
+ map[string]string{xhttp.AmzChecksumType: xhttp.AmzChecksumTypeComposite})
+ if rec.Code != http.StatusOK {
+ t.Fatalf("%s: matching checksum type-only assertion returned %d %s", instanceType, rec.Code, rec.Body.String())
+ }
+ })
+
t.Run("omitted-type-is-not-composite", func(t *testing.T) {
objectName := "type-mismatch/omitted-type"
uploadID := newMultipartUploadHTTP(t, apiRouter, credentials, bucketName, objectName,
@@ -450,19 +516,38 @@ func testAPICompleteMultipartChecksumTypeMismatch(obj ObjectLayer, instanceType,
}
})
- t.Run("crc64nvme-composite-remains-canonicalized", func(t *testing.T) {
+ t.Run("crc64nvme-composite-is-rejected", func(t *testing.T) {
crc64Type := hash.ChecksumCRC64NVME
objectName := "type-mismatch/crc64nvme-composite"
- uploadID := newMultipartUploadHTTP(t, apiRouter, credentials, bucketName, objectName,
- crc64Type.String(), xhttp.AmzChecksumTypeComposite)
- etags := uploadPartsHTTP(t, apiRouter, credentials, bucketName, objectName, uploadID, crc64Type, partData)
- rec := completeMultipartUploadHTTP(t, apiRouter, credentials, bucketName, objectName, uploadID, etags, nil,
- map[string]string{
- crc64Type.Key(): mustChecksum(t, crc64Type, full),
+ req, err := newTestSignedRequestV4(http.MethodPost, getNewMultipartURL("", bucketName, objectName),
+ 0, nil, credentials.AccessKey, credentials.SecretKey, map[string]string{
+ xhttp.AmzChecksumAlgo: crc64Type.String(),
xhttp.AmzChecksumType: xhttp.AmzChecksumTypeComposite,
})
- if rec.Code != http.StatusOK {
- t.Fatalf("%s: CRC64NVME canonicalization changed: %d %s", instanceType, rec.Code, rec.Body.String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec := httptest.NewRecorder()
+ apiRouter.ServeHTTP(rec, req)
+ if rec.Code != http.StatusBadRequest || apiErrorCode(t, rec) != "InvalidArgument" {
+ t.Fatalf("%s: CRC64NVME/COMPOSITE returned %d %s", instanceType, rec.Code, rec.Body.String())
+ }
+ })
+
+ t.Run("crc64nvme-composite-completion-is-rejected", func(t *testing.T) {
+ crc64Type := hash.ChecksumCRC64NVME
+ objectName := "type-mismatch/crc64nvme-composite-completion"
+ uploadID := newMultipartUploadHTTP(t, apiRouter, credentials, bucketName, objectName,
+ crc64Type.String(), xhttp.AmzChecksumTypeFullObject)
+ etags := uploadPartsHTTP(t, apiRouter, credentials, bucketName, objectName, uploadID, crc64Type, partData)
+ partCS := []string{mustChecksum(t, crc64Type, partData[0]), mustChecksum(t, crc64Type, partData[1])}
+ rec := completeMultipartUploadHTTP(t, apiRouter, credentials, bucketName, objectName, uploadID, etags, partCS,
+ map[string]string{xhttp.AmzChecksumType: xhttp.AmzChecksumTypeComposite})
+ if rec.Code != http.StatusBadRequest || apiErrorCode(t, rec) != "BadDigest" {
+ t.Fatalf("%s: CRC64NVME composite completion returned %d %s", instanceType, rec.Code, rec.Body.String())
+ }
+ if _, err := obj.GetObjectInfo(t.Context(), bucketName, objectName, ObjectOptions{}); err == nil {
+ t.Fatalf("%s: object was created despite a rejected CRC64NVME checksum type", instanceType)
}
})
}
diff --git a/cmd/erasure-multipart.go b/cmd/erasure-multipart.go
index 222c458b5..be5afb7c6 100644
--- a/cmd/erasure-multipart.go
+++ b/cmd/erasure-multipart.go
@@ -1173,9 +1173,9 @@ func (er erasureObjects) CompleteMultipartUpload(ctx context.Context, bucket str
var checksumType hash.ChecksumType
if cs := fi.Metadata[hash.MinIOMultipartChecksum]; cs != "" {
checksumType = hash.NewChecksumType(cs, fi.Metadata[hash.MinIOMultipartChecksumType])
+ expectedType := checksumType | hash.ChecksumMultipart | hash.ChecksumIncludesMultipart
if opts.WantChecksum != nil {
providedType := opts.WantChecksum.Type | hash.ChecksumMultipart | hash.ChecksumIncludesMultipart
- expectedType := checksumType | hash.ChecksumMultipart | hash.ChecksumIncludesMultipart
if providedType.Base() != expectedType.Base() {
return oi, InvalidArgument{
Bucket: bucket,
@@ -1183,8 +1183,10 @@ func (er erasureObjects) CompleteMultipartUpload(ctx context.Context, bucket str
Err: fmt.Errorf("checksum algorithm mismatch. got %q expected %q", providedType.String(), expectedType.String()),
}
}
- if opts.wantChecksumTypeSet && providedType.ObjType() != expectedType.ObjType() {
- return oi, completeMultipartChecksumTypeMismatch(providedType.ObjType(), expectedType.ObjType())
+ }
+ if opts.wantChecksumType != "" {
+ if opts.wantChecksumType != expectedType.ObjType() {
+ return oi, completeMultipartChecksumTypeMismatch(opts.wantChecksumType, expectedType.ObjType())
}
}
checksumType |= hash.ChecksumMultipart | hash.ChecksumIncludesMultipart
diff --git a/cmd/erasure-object.go b/cmd/erasure-object.go
index 2364c45b9..79d22cc6f 100644
--- a/cmd/erasure-object.go
+++ b/cmd/erasure-object.go
@@ -266,9 +266,19 @@ func (er erasureObjects) GetObjectNInfo(ctx context.Context, bucket, object stri
ObjInfo: objInfo,
}, err
}
-
// Zero byte objects don't even need to further initialize pipes etc.
- return NewGetObjectReaderFromReader(bytes.NewReader(nil), objInfo, opts)
+ gr, err = NewGetObjectReaderFromReader(bytes.NewReader(nil), objInfo, opts)
+ if err != nil {
+ return gr, err
+ }
+ // With no data, the reader above cannot authenticate an SSE-C key the
+ // way NewGetObjectReader does. Check it after the preconditions so zero
+ // and non-zero reads preserve the same error ordering.
+ if err := checkSSECReadKey(h, objInfo, opts); err != nil {
+ gr.Close()
+ return nil, err
+ }
+ return gr, nil
}
if objInfo.IsRemote() {
diff --git a/cmd/erasure-server-pool.go b/cmd/erasure-server-pool.go
index dc65d06ee..af804fb02 100644
--- a/cmd/erasure-server-pool.go
+++ b/cmd/erasure-server-pool.go
@@ -1328,6 +1328,8 @@ func (z *erasureServerPools) CopyObject(ctx context.Context, srcBucket, srcObjec
return objInfo, err
}
+ // CopyObjectHandler predicts the outcome of this decision in
+ // copyRewritesObjectData(); keep the two in sync.
if cpSrcDstSame && srcInfo.metadataOnly {
// Version ID is set for the destination and source == destination version ID.
if dstOpts.VersionID != "" && srcOpts.VersionID == dstOpts.VersionID {
diff --git a/cmd/erasure-sets.go b/cmd/erasure-sets.go
index 95a7ed339..44401e0db 100644
--- a/cmd/erasure-sets.go
+++ b/cmd/erasure-sets.go
@@ -839,6 +839,8 @@ func (s *erasureSets) CopyObject(ctx context.Context, srcBucket, srcObject, dstB
cpSrcDstSame := srcSet == dstSet
// Check if this request is only metadata update.
+ // CopyObjectHandler predicts the outcome of this decision in
+ // copyRewritesObjectData(); keep the two in sync.
if cpSrcDstSame && srcInfo.metadataOnly {
// Version ID is set for the destination and source == destination version ID.
// perform an in-place update.
diff --git a/cmd/object-api-interface.go b/cmd/object-api-interface.go
index 35d353ca6..f8664310d 100644
--- a/cmd/object-api-interface.go
+++ b/cmd/object-api-interface.go
@@ -84,8 +84,8 @@ type ObjectOptions struct {
Expiration ExpirationOptions
LifecycleAuditEvent lcAuditEvent
- WantChecksum *hash.Checksum // x-amz-checksum-XXX checksum sent to PutObject/ CompleteMultipartUpload.
- wantChecksumTypeSet bool // x-amz-checksum-type was explicitly set on CompleteMultipartUpload.
+ WantChecksum *hash.Checksum // x-amz-checksum-XXX checksum sent to PutObject/ CompleteMultipartUpload.
+ wantChecksumType string // explicit x-amz-checksum-type value on CompleteMultipartUpload.
WantServerSideChecksumType hash.ChecksumType // if set, we compute a server-side checksum of this type
diff --git a/cmd/object-api-options.go b/cmd/object-api-options.go
index 828a8ff00..c20a40b8e 100644
--- a/cmd/object-api-options.go
+++ b/cmd/object-api-options.go
@@ -439,6 +439,9 @@ func putOptsFromHeaders(ctx context.Context, hdr http.Header, metadata map[strin
// get ObjectOptions for Copy calls with encryption headers provided on the target side and source side metadata
func copyDstOpts(ctx context.Context, r *http.Request, bucket, object string, metadata map[string]string) (opts ObjectOptions, err error) {
+ if _, err := hash.GetContentChecksum(r.Header); err != nil {
+ return opts, err
+ }
return putOptsFromReq(ctx, r, bucket, object, metadata)
}
@@ -469,11 +472,17 @@ func completeMultipartOpts(ctx context.Context, r *http.Request, bucket, object
}
}
+ opts.wantChecksumType = r.Header.Get(xhttp.AmzChecksumType)
+ switch opts.wantChecksumType {
+ case "", xhttp.AmzChecksumTypeComposite, xhttp.AmzChecksumTypeFullObject:
+ default:
+ return opts, hash.ErrInvalidChecksum
+ }
+
opts.WantChecksum, err = hash.GetContentChecksum(r.Header)
if err != nil {
return opts, err
}
- opts.wantChecksumTypeSet = r.Header.Get(xhttp.AmzChecksumType) != ""
opts.MTime = mtime
opts.UserDefined = make(map[string]string)
// Transfer SSEC key in opts.EncryptFn
diff --git a/cmd/object-attributes-ssec_test.go b/cmd/object-attributes-ssec_test.go
new file mode 100644
index 000000000..77deca059
--- /dev/null
+++ b/cmd/object-attributes-ssec_test.go
@@ -0,0 +1,104 @@
+// Copyright (c) 2015-2026 MinIO, Inc.
+//
+// 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.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package cmd
+
+import (
+ "bytes"
+ "crypto/md5"
+ "encoding/base64"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/minio/minio/internal/auth"
+ xhttp "github.com/minio/minio/internal/http"
+)
+
+func TestAPIGetObjectAttributesAuthenticatesSSECKey(t *testing.T) {
+ defer DetectTestLeak(t)()
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
+ t: t,
+ objAPITest: testAPIGetObjectAttributesAuthenticatesSSECKey,
+ })
+}
+
+func testAPIGetObjectAttributesAuthenticatesSSECKey(_ 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{0x11}, 32)
+ keyMD5 := md5.Sum(key)
+ wrongKey := bytes.Repeat([]byte{0x22}, 32)
+ wrongMD5 := md5.Sum(wrongKey)
+ correctHeaders := map[string]string{
+ xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES,
+ xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(key),
+ xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(keyMD5[:]),
+ }
+ wrongHeaders := map[string]string{
+ xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES,
+ xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(wrongKey),
+ xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(wrongMD5[:]),
+ }
+
+ for _, test := range []struct {
+ name string
+ data []byte
+ }{
+ {name: "zero", data: nil},
+ {name: "nonzero", data: []byte("secret")},
+ } {
+ object := "attributes/ssec-" + test.name
+ putCopyChecksumSource(t, apiRouter, credentials, bucketName, object, test.data, correctHeaders)
+ if rec := objectAttributesSSECRequest(t, apiRouter, credentials, bucketName, object, correctHeaders); rec.Code != http.StatusOK {
+ t.Fatalf("%s/%s: correct key returned %d: %s", instanceType, test.name, rec.Code, rec.Body.String())
+ }
+ if rec := objectAttributesSSECRequest(t, apiRouter, credentials, bucketName, object, wrongHeaders); rec.Code != http.StatusForbidden {
+ t.Fatalf("%s/%s: wrong key returned %d, want %d: %s", instanceType, test.name, rec.Code, http.StatusForbidden, rec.Body.String())
+ }
+ if rec := objectAttributesSSECRequest(t, apiRouter, credentials, bucketName, object, nil); rec.Code != http.StatusBadRequest {
+ t.Fatalf("%s/%s: missing key returned %d, want %d: %s", instanceType, test.name, rec.Code, http.StatusBadRequest, rec.Body.String())
+ }
+ if rec := objectAttributesSSECRequest(t, apiRouter, credentials, bucketName, object, map[string]string{
+ xhttp.MinIOSourceReplicationRequest: "true",
+ }); rec.Code != http.StatusOK {
+ t.Fatalf("%s/%s: replication request returned %d: %s", instanceType, test.name, rec.Code, rec.Body.String())
+ }
+ }
+}
+
+func objectAttributesSSECRequest(t *testing.T, apiRouter http.Handler, credentials auth.Credentials,
+ bucket, object string, encryptionHeaders map[string]string,
+) *httptest.ResponseRecorder {
+ t.Helper()
+ headers := map[string]string{xhttp.AmzObjectAttributes: "ObjectSize,ETag,ObjectParts,Checksum"}
+ for key, value := range encryptionHeaders {
+ headers[key] = value
+ }
+ req, err := newTestSignedRequestV4(http.MethodGet, getGetObjectURL("", bucket, object)+"?attributes",
+ 0, nil, credentials.AccessKey, credentials.SecretKey, headers)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec := httptest.NewRecorder()
+ apiRouter.ServeHTTP(rec, req)
+ return rec
+}
diff --git a/cmd/object-checksum-unsupported_test.go b/cmd/object-checksum-unsupported_test.go
new file mode 100644
index 000000000..be11f8e9c
--- /dev/null
+++ b/cmd/object-checksum-unsupported_test.go
@@ -0,0 +1,137 @@
+// Copyright (c) 2015-2026 MinIO, Inc.
+//
+// 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.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package cmd
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/xml"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/minio/minio/internal/auth"
+ xhttp "github.com/minio/minio/internal/http"
+)
+
+func TestAPIRejectsUnsupportedChecksumHeaders(t *testing.T) {
+ defer DetectTestLeak(t)()
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
+ t: t,
+ objAPITest: testAPIRejectsUnsupportedChecksumHeaders,
+ endpoints: []string{"CopyObject", "NewMultipart", "PutObject", "PutObjectPart"},
+ })
+}
+
+func testAPIRejectsUnsupportedChecksumHeaders(obj ObjectLayer, instanceType, bucketName string,
+ apiRouter http.Handler, credentials auth.Credentials, t *testing.T,
+) {
+ data := []byte("unsupported-checksum")
+ unsupportedValue := base64.StdEncoding.EncodeToString(make([]byte, 64))
+
+ put := func(object string, headers map[string]string) *httptest.ResponseRecorder {
+ t.Helper()
+ req, err := newTestSignedRequestV4(http.MethodPut, getPutObjectURL("", bucketName, object),
+ int64(len(data)), bytes.NewReader(data), credentials.AccessKey, credentials.SecretKey, headers)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec := httptest.NewRecorder()
+ apiRouter.ServeHTTP(rec, req)
+ return rec
+ }
+ assertRejected := func(name string, rec *httptest.ResponseRecorder) {
+ t.Helper()
+ if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "InvalidArgument") {
+ t.Fatalf("%s: %s returned %d, want InvalidArgument: %s", instanceType, name, rec.Code, rec.Body.String())
+ }
+ }
+
+ for _, algorithm := range []string{"md5", "sha512", "xxhash64", "xxhash3", "xxhash128", "future"} {
+ object := "checksums/unsupported-" + algorithm
+ assertRejected(algorithm, put(object, map[string]string{
+ "x-amz-sdk-checksum-algorithm": "SHA512",
+ "x-amz-checksum-" + algorithm: unsupportedValue,
+ }))
+ if _, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{}); !isErrObjectNotFound(err) {
+ t.Fatalf("%s: rejected %s checksum stored an object: %v", instanceType, algorithm, err)
+ }
+ }
+
+ assertRejected("unsupported trailer", put("checksums/unsupported-trailer", map[string]string{
+ xhttp.AmzTrailer: "x-amz-checksum-sha512",
+ }))
+
+ newMultipart := func(name string, headers map[string]string) *httptest.ResponseRecorder {
+ t.Helper()
+ req, err := newTestSignedRequestV4(http.MethodPost, getNewMultipartURL("", bucketName, name),
+ 0, nil, credentials.AccessKey, credentials.SecretKey, headers)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec := httptest.NewRecorder()
+ apiRouter.ServeHTTP(rec, req)
+ return rec
+ }
+ assertRejected("NewMultipartUpload value header", newMultipart("checksums/mp-value", map[string]string{
+ "x-amz-checksum-sha512": unsupportedValue,
+ }))
+ assertRejected("NewMultipartUpload trailer", newMultipart("checksums/mp-trailer", map[string]string{
+ xhttp.AmzTrailer: "x-amz-checksum-sha512",
+ }))
+
+ rec := newMultipart("checksums/mp-part", nil)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("%s: NewMultipartUpload setup returned %d: %s", instanceType, rec.Code, rec.Body.String())
+ }
+ var initiated InitiateMultipartUploadResponse
+ if err := xml.Unmarshal(rec.Body.Bytes(), &initiated); err != nil {
+ t.Fatal(err)
+ }
+ req, err := newTestSignedRequestV4(http.MethodPut,
+ getPutObjectPartURL("", bucketName, "checksums/mp-part", initiated.UploadID, "1"),
+ int64(len(data)), bytes.NewReader(data), credentials.AccessKey, credentials.SecretKey,
+ map[string]string{"x-amz-checksum-sha512": unsupportedValue})
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec = httptest.NewRecorder()
+ apiRouter.ServeHTTP(rec, req)
+ assertRejected("UploadPart", rec)
+ parts, err := obj.ListObjectParts(t.Context(), bucketName, "checksums/mp-part", initiated.UploadID, 0, 1000, ObjectOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(parts.Parts) != 0 {
+ t.Fatalf("%s: rejected UploadPart stored %d parts", instanceType, len(parts.Parts))
+ }
+ if err := obj.AbortMultipartUpload(t.Context(), bucketName, "checksums/mp-part", initiated.UploadID, ObjectOptions{}); err != nil {
+ t.Fatal(err)
+ }
+
+ source := "checksums/source"
+ putCopyChecksumSource(t, apiRouter, credentials, bucketName, source, data, nil)
+ rec = copyChecksumRequest(t, apiRouter, credentials, bucketName, source, "checksums/copy", map[string]string{
+ "x-amz-checksum-sha512": unsupportedValue,
+ })
+ assertRejected("CopyObject", rec)
+ if _, err := obj.GetObjectInfo(t.Context(), bucketName, "checksums/copy", ObjectOptions{}); !isErrObjectNotFound(err) {
+ t.Fatalf("%s: rejected CopyObject stored a destination: %v", instanceType, err)
+ }
+}
diff --git a/cmd/object-copy-checksum_test.go b/cmd/object-copy-checksum_test.go
index ad373f171..2f2c56328 100644
--- a/cmd/object-copy-checksum_test.go
+++ b/cmd/object-copy-checksum_test.go
@@ -387,6 +387,44 @@ func testAPICopyObjectServerSideChecksumEncryption(obj ObjectLayer, instanceType
}
})
}
+
+ oldKey := bytes.Repeat([]byte{0x31}, 32)
+ oldKeyMD5 := md5.Sum(oldKey)
+ newKey := bytes.Repeat([]byte{0x42}, 32)
+ newKeyMD5 := md5.Sum(newKey)
+ encryptedSource := "copy-checksum/sse-c-different-key-source.bin"
+ putCopyChecksumSource(t, apiRouter, credentials, bucketName, encryptedSource, data, map[string]string{
+ xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES,
+ xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(oldKey),
+ xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(oldKeyMD5[:]),
+ })
+
+ destination := "copy-checksum/sse-c-different-key-destination.bin"
+ rec := copyChecksumRequest(t, apiRouter, credentials, bucketName, encryptedSource, destination, map[string]string{
+ xhttp.AmzChecksumAlgo: hash.ChecksumCRC32.String(),
+ xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES,
+ xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(newKey),
+ xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(newKeyMD5[:]),
+ xhttp.AmzServerSideEncryptionCopyCustomerAlgorithm: xhttp.AmzEncryptionAES,
+ xhttp.AmzServerSideEncryptionCopyCustomerKey: base64.StdEncoding.EncodeToString(oldKey),
+ xhttp.AmzServerSideEncryptionCopyCustomerKeyMD5: base64.StdEncoding.EncodeToString(oldKeyMD5[:]),
+ })
+ if rec.Code != http.StatusOK {
+ t.Fatalf("%s: different-key SSE-C CopyObject failed: %d %s", instanceType, rec.Code, rec.Body.String())
+ }
+ assertCopyChecksumResponse(t, rec, hash.ChecksumCRC32, data)
+ if got, want := rec.Header().Get(hash.ChecksumCRC32.Key()), mustChecksum(t, hash.ChecksumCRC32, data); got != want {
+ t.Fatalf("%s: different-key SSE-C response header checksum %q, want %q", instanceType, got, want)
+ }
+ if got := rec.Header().Get(xhttp.AmzChecksumType); got != xhttp.AmzChecksumTypeFullObject {
+ t.Fatalf("%s: different-key SSE-C response checksum type %q, want %q", instanceType, got, xhttp.AmzChecksumTypeFullObject)
+ }
+ newKeyHeaders := http.Header{
+ xhttp.AmzServerSideEncryptionCustomerAlgorithm: []string{xhttp.AmzEncryptionAES},
+ xhttp.AmzServerSideEncryptionCustomerKey: []string{base64.StdEncoding.EncodeToString(newKey)},
+ xhttp.AmzServerSideEncryptionCustomerKeyMD5: []string{base64.StdEncoding.EncodeToString(newKeyMD5[:])},
+ }
+ assertCopyChecksum(t, obj, bucketName, destination, hash.ChecksumCRC32, data, false, newKeyHeaders)
})
}
diff --git a/cmd/object-copy-metadata_test.go b/cmd/object-copy-metadata_test.go
index 43470436d..4c123676e 100644
--- a/cmd/object-copy-metadata_test.go
+++ b/cmd/object-copy-metadata_test.go
@@ -150,6 +150,7 @@ func testAPICopyObjectSSECKeyRotationKeepsCompressionState(obj ObjectLayer, inst
newMD5 := md5.Sum(newKey)
putCopyChecksumSource(t, apiRouter, credentials, bucketName, object, data, map[string]string{
+ xhttp.AmzChecksumCRC32: mustChecksum(t, hash.ChecksumCRC32, data),
xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES,
xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(oldKey),
xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(oldMD5[:]),
@@ -172,6 +173,7 @@ func testAPICopyObjectSSECKeyRotationKeepsCompressionState(obj ObjectLayer, inst
if rec.Code != http.StatusOK {
t.Fatalf("%s: key rotation failed: %d %s", instanceType, rec.Code, rec.Body.String())
}
+ assertCopyChecksumResponse(t, rec, hash.ChecksumCRC32, data)
after, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{})
if err != nil {
t.Fatal(err)
@@ -200,3 +202,408 @@ func testAPICopyObjectSSECKeyRotationKeepsCompressionState(obj ObjectLayer, inst
instanceType, response.Code, response.Body.Len(), len(data), response.Body.String())
}
}
+
+// TestAPICopyObjectMetadataOnlyNullVersion covers the copy whose source is a
+// null version on a bucket that gained versioning after the object was written.
+// The object layer cannot reference such a version, so it rewrites the data and
+// the recorded compression metadata has to describe the rewritten bytes.
+func TestAPICopyObjectMetadataOnlyNullVersion(t *testing.T) {
+ defer DetectTestLeak(t)()
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
+ t: t,
+ objAPITest: testAPICopyObjectMetadataOnlyNullVersion,
+ endpoints: []string{"CopyObject", "PutObject", "GetObject"},
+ })
+}
+
+func testAPICopyObjectMetadataOnlyNullVersion(obj ObjectLayer, instanceType, bucketName string,
+ apiRouter http.Handler, credentials auth.Credentials, t *testing.T,
+) {
+ restoreCompression := setCopyChecksumCompression(true)
+ compressionRestored := false
+ defer func() {
+ if !compressionRestored {
+ restoreCompression()
+ }
+ }()
+
+ data := bytes.Repeat([]byte("null-version-metadata-copy-"), 64*1024)
+ want := mustChecksum(t, hash.ChecksumCRC32, data)
+ object := "copy-metadata/null-version.txt"
+ putCopyChecksumSource(t, apiRouter, credentials, bucketName, object, data,
+ map[string]string{xhttp.AmzChecksumCRC32: want})
+
+ before, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !before.IsCompressed() || before.VersionID != "" {
+ t.Fatalf("%s: invalid null-version precondition: compressed=%v versionID=%q",
+ instanceType, before.IsCompressed(), before.VersionID)
+ }
+
+ // Versioning is enabled after the write, so the object keeps a null version.
+ if _, err := globalBucketMetadataSys.Update(t.Context(), bucketName,
+ bucketVersioningConfig, enabledBucketVersioningConfig); err != nil {
+ t.Fatalf("%s: unable to enable versioning: %v", instanceType, err)
+ }
+ if !globalBucketVersioningSys.PrefixEnabled(bucketName, object) {
+ t.Fatalf("%s: versioning did not become enabled", instanceType)
+ }
+
+ // Without compression the rewritten destination stores plaintext.
+ restoreCompression()
+ compressionRestored = true
+
+ rec := copyChecksumRequest(t, apiRouter, credentials, bucketName, object, object,
+ map[string]string{xhttp.AmzMetadataDirective: "REPLACE"})
+ if rec.Code != http.StatusOK {
+ t.Fatalf("%s: metadata-only CopyObject failed: %d %s", instanceType, rec.Code, rec.Body.String())
+ }
+
+ after := assertCopyChecksum(t, obj, bucketName, object, hash.ChecksumCRC32, data, false, nil)
+ if after.VersionID == "" {
+ t.Fatalf("%s: versioned copy did not create a new version", instanceType)
+ }
+ if got := readCopyChecksumObject(t, obj, bucketName, object, ObjectOptions{}); !bytes.Equal(got, data) {
+ t.Fatalf("%s: copied object body differs: got %d bytes, want %d", instanceType, len(got), len(data))
+ }
+}
+
+func TestAPICopyObjectMetadataOnlyNullVersionCompressesRewrite(t *testing.T) {
+ defer DetectTestLeak(t)()
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
+ t: t,
+ objAPITest: testAPICopyObjectMetadataOnlyNullVersionCompressesRewrite,
+ endpoints: []string{"CopyObject", "PutObject", "GetObject"},
+ })
+}
+
+func testAPICopyObjectMetadataOnlyNullVersionCompressesRewrite(obj ObjectLayer, instanceType, bucketName string,
+ apiRouter http.Handler, credentials auth.Credentials, t *testing.T,
+) {
+ globalCompressConfigMu.Lock()
+ previousCompression := globalCompressConfig
+ globalCompressConfig.Enabled = false
+ globalCompressConfigMu.Unlock()
+ defer func() {
+ globalCompressConfigMu.Lock()
+ globalCompressConfig = previousCompression
+ globalCompressConfigMu.Unlock()
+ }()
+
+ data := bytes.Repeat([]byte("null-version-compress-rewrite-"), 64*1024)
+ want := mustChecksum(t, hash.ChecksumCRC32, data)
+ object := "copy-metadata/null-version-compress.txt"
+ putCopyChecksumSource(t, apiRouter, credentials, bucketName, object, data,
+ map[string]string{xhttp.AmzChecksumCRC32: want})
+
+ before, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if before.IsCompressed() || before.VersionID != "" {
+ t.Fatalf("%s: invalid null-version precondition: compressed=%v versionID=%q",
+ instanceType, before.IsCompressed(), before.VersionID)
+ }
+ if _, err := globalBucketMetadataSys.Update(t.Context(), bucketName,
+ bucketVersioningConfig, enabledBucketVersioningConfig); err != nil {
+ t.Fatalf("%s: unable to enable versioning: %v", instanceType, err)
+ }
+
+ restoreCopyCompression := setCopyChecksumCompression(false)
+ defer restoreCopyCompression()
+ rec := copyChecksumRequest(t, apiRouter, credentials, bucketName, object, object,
+ map[string]string{xhttp.AmzMetadataDirective: "REPLACE"})
+ if rec.Code != http.StatusOK {
+ t.Fatalf("%s: metadata-only CopyObject failed: %d %s", instanceType, rec.Code, rec.Body.String())
+ }
+
+ after := assertCopyChecksum(t, obj, bucketName, object, hash.ChecksumCRC32, data, true, nil)
+ if after.VersionID == "" {
+ t.Fatalf("%s: versioned copy did not create a new version", instanceType)
+ }
+ if got := readCopyChecksumObject(t, obj, bucketName, object, ObjectOptions{}); !bytes.Equal(got, data) {
+ t.Fatalf("%s: copied object body differs: got %d bytes, want %d", instanceType, len(got), len(data))
+ }
+}
+
+func TestCopyRewritesObjectData(t *testing.T) {
+ tests := []struct {
+ name string
+ metadataOnly bool
+ srcOpts ObjectOptions
+ dstOpts ObjectOptions
+ want bool
+ }{
+ {
+ name: "data copy always rewrites",
+ want: true,
+ },
+ // PostRestoreObjectHandler, updateRestoreMetadata and batchKeyRotate all
+ // address the same version on both sides and never set Versioned, so they
+ // only ever reach these two cases.
+ {
+ name: "unversioned in-place metadata update",
+ metadataOnly: true,
+ },
+ {
+ name: "addressed version updated in place",
+ metadataOnly: true,
+ srcOpts: ObjectOptions{VersionID: "v1"},
+ dstOpts: ObjectOptions{VersionID: "v1"},
+ },
+ {
+ name: "versioned self referential version",
+ metadataOnly: true,
+ srcOpts: ObjectOptions{VersionID: "v1"},
+ dstOpts: ObjectOptions{Versioned: true},
+ },
+ {
+ name: "versioned null source version cannot be referenced",
+ metadataOnly: true,
+ dstOpts: ObjectOptions{Versioned: true},
+ want: true,
+ },
+ {
+ name: "suspended destination with an addressed source version",
+ metadataOnly: true,
+ srcOpts: ObjectOptions{VersionID: "v1"},
+ dstOpts: ObjectOptions{VersionSuspended: true, VersionID: nullVersionID},
+ want: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := copyRewritesObjectData(tt.metadataOnly, tt.srcOpts, tt.dstOpts); got != tt.want {
+ t.Fatalf("copyRewritesObjectData() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+// TestAPICopyObjectSSECKeyRotationNullVersion covers an SSE-C key rotation whose
+// source is a null version on a bucket that gained versioning after the object
+// was written. A rotation only rewraps the object key held in metadata, so it
+// may not take the metadata-only path when the object layer stores new object
+// data; the rotation has to re-encrypt instead.
+func TestAPICopyObjectSSECKeyRotationNullVersion(t *testing.T) {
+ defer DetectTestLeak(t)()
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
+ t: t,
+ objAPITest: testAPICopyObjectSSECKeyRotationNullVersion,
+ endpoints: []string{"CopyObject", "PutObject", "GetObject"},
+ })
+}
+
+func testAPICopyObjectSSECKeyRotationNullVersion(obj ObjectLayer, instanceType, bucketName string,
+ apiRouter http.Handler, credentials auth.Credentials, t *testing.T,
+) {
+ testAPICopyObjectSSECKeyRotationNullVersionWithCompression(obj, instanceType, bucketName,
+ apiRouter, credentials, false, t)
+}
+
+func TestAPICopyObjectSSECKeyRotationNullVersionCompressesRewrite(t *testing.T) {
+ defer DetectTestLeak(t)()
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
+ t: t,
+ objAPITest: testAPICopyObjectSSECKeyRotationNullVersionCompressesRewrite,
+ endpoints: []string{"CopyObject", "PutObject", "GetObject"},
+ })
+}
+
+func testAPICopyObjectSSECKeyRotationNullVersionCompressesRewrite(obj ObjectLayer, instanceType, bucketName string,
+ apiRouter http.Handler, credentials auth.Credentials, t *testing.T,
+) {
+ testAPICopyObjectSSECKeyRotationNullVersionWithCompression(obj, instanceType, bucketName,
+ apiRouter, credentials, true, t)
+}
+
+func testAPICopyObjectSSECKeyRotationNullVersionWithCompression(obj ObjectLayer, instanceType, bucketName string,
+ apiRouter http.Handler, credentials auth.Credentials, compressAtCopy bool, t *testing.T,
+) {
+ previousTLS := globalIsTLS
+ globalIsTLS = true
+ defer func() { globalIsTLS = previousTLS }()
+
+ data := bytes.Repeat([]byte("key-rotation-null-version-"), 64*1024)
+ object := "copy-metadata/key-rotation-null.txt"
+ oldKey := bytes.Repeat([]byte{0x11}, 32)
+ oldMD5 := md5.Sum(oldKey)
+ newKey := bytes.Repeat([]byte{0x22}, 32)
+ newMD5 := md5.Sum(newKey)
+
+ putCopyChecksumSource(t, apiRouter, credentials, bucketName, object, data, map[string]string{
+ xhttp.AmzChecksumCRC32: mustChecksum(t, hash.ChecksumCRC32, data),
+ xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES,
+ xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(oldKey),
+ xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(oldMD5[:]),
+ })
+ before, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if before.VersionID != "" {
+ t.Fatalf("%s: invalid null-version precondition: versionID=%q", instanceType, before.VersionID)
+ }
+
+ // Versioning is enabled after the write, so the object keeps a null version.
+ if _, err := globalBucketMetadataSys.Update(t.Context(), bucketName,
+ bucketVersioningConfig, enabledBucketVersioningConfig); err != nil {
+ t.Fatalf("%s: unable to enable versioning: %v", instanceType, err)
+ }
+ if !globalBucketVersioningSys.PrefixEnabled(bucketName, object) {
+ t.Fatalf("%s: versioning did not become enabled", instanceType)
+ }
+ if compressAtCopy {
+ restoreCompression := setCopyChecksumCompression(true)
+ defer restoreCompression()
+ }
+
+ rec := copyChecksumRequest(t, apiRouter, credentials, bucketName, object, object, map[string]string{
+ xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES,
+ xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(newKey),
+ xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(newMD5[:]),
+ xhttp.AmzServerSideEncryptionCopyCustomerAlgorithm: xhttp.AmzEncryptionAES,
+ xhttp.AmzServerSideEncryptionCopyCustomerKey: base64.StdEncoding.EncodeToString(oldKey),
+ xhttp.AmzServerSideEncryptionCopyCustomerKeyMD5: base64.StdEncoding.EncodeToString(oldMD5[:]),
+ })
+ if rec.Code != http.StatusOK {
+ t.Fatalf("%s: key rotation failed: %d %s", instanceType, rec.Code, rec.Body.String())
+ }
+
+ assertCopyChecksumResponse(t, rec, hash.ChecksumCRC32, data)
+
+ getHeaders := map[string]string{
+ xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES,
+ xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(newKey),
+ xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(newMD5[:]),
+ }
+ req, err := newTestSignedRequestV4(http.MethodGet, getGetObjectURL("", bucketName, object),
+ 0, nil, credentials.AccessKey, credentials.SecretKey, getHeaders)
+ if err != nil {
+ t.Fatalf("failed to build GetObject request: %v", err)
+ }
+ response := httptest.NewRecorder()
+ apiRouter.ServeHTTP(response, req)
+ if response.Code != http.StatusOK || !bytes.Equal(response.Body.Bytes(), data) {
+ t.Fatalf("%s: post-rotation GetObject returned %d with %d bytes, want 200 with %d bytes: %s",
+ instanceType, response.Code, response.Body.Len(), len(data), response.Body.String())
+ }
+
+ decryptHeaders := http.Header{}
+ for key, value := range getHeaders {
+ decryptHeaders.Set(key, value)
+ }
+ after := assertCopyChecksum(t, obj, bucketName, object, hash.ChecksumCRC32, data, compressAtCopy, decryptHeaders)
+ if after.VersionID == "" {
+ t.Fatalf("%s: rotation into a versioned bucket did not create a new version", instanceType)
+ }
+ // The rotation could not be applied in place, so the object was re-encrypted
+ // under a fresh object key. That regenerates the encrypted ETag, unlike an
+ // in-place rotation which leaves the stored bytes and the ETag alone.
+ if after.ETag == before.ETag {
+ t.Fatalf("%s: re-encrypting rotation kept the source ETag %q", instanceType, after.ETag)
+ }
+}
+
+// TestAPICopyObjectSSECKeyRotationNullVersionWrongKey pins source-key
+// authentication in both the standalone rotation fix and the later zero-byte
+// read hardening.
+func TestAPICopyObjectSSECKeyRotationNullVersionWrongKey(t *testing.T) {
+ defer DetectTestLeak(t)()
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
+ t: t,
+ objAPITest: testAPICopyObjectSSECKeyRotationNullVersionWrongKey,
+ endpoints: []string{"CopyObject", "PutObject", "GetObject"},
+ })
+}
+
+func testAPICopyObjectSSECKeyRotationNullVersionWrongKey(obj ObjectLayer, instanceType, bucketName string,
+ apiRouter http.Handler, credentials auth.Credentials, t *testing.T,
+) {
+ previousTLS := globalIsTLS
+ globalIsTLS = true
+ defer func() { globalIsTLS = previousTLS }()
+
+ object := "copy-metadata/key-rotation-null-empty.txt"
+ oldKey := bytes.Repeat([]byte{0x11}, 32)
+ oldMD5 := md5.Sum(oldKey)
+ wrongKey := bytes.Repeat([]byte{0x33}, 32)
+ wrongMD5 := md5.Sum(wrongKey)
+ newKey := bytes.Repeat([]byte{0x22}, 32)
+ newMD5 := md5.Sum(newKey)
+
+ putCopyChecksumSource(t, apiRouter, credentials, bucketName, object, nil, map[string]string{
+ xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES,
+ xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(oldKey),
+ xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(oldMD5[:]),
+ })
+ before, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if before.Size != 0 || before.VersionID != "" || len(before.Checksum) != 0 {
+ t.Fatalf("%s: invalid empty null-version precondition: size=%d versionID=%q checksum=%d",
+ instanceType, before.Size, before.VersionID, len(before.Checksum))
+ }
+
+ if _, err := globalBucketMetadataSys.Update(t.Context(), bucketName,
+ bucketVersioningConfig, enabledBucketVersioningConfig); err != nil {
+ t.Fatalf("%s: unable to enable versioning: %v", instanceType, err)
+ }
+
+ rec := copyChecksumRequest(t, apiRouter, credentials, bucketName, object, object, map[string]string{
+ xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES,
+ xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(newKey),
+ xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(newMD5[:]),
+ xhttp.AmzServerSideEncryptionCopyCustomerAlgorithm: xhttp.AmzEncryptionAES,
+ xhttp.AmzServerSideEncryptionCopyCustomerKey: base64.StdEncoding.EncodeToString(wrongKey),
+ xhttp.AmzServerSideEncryptionCopyCustomerKeyMD5: base64.StdEncoding.EncodeToString(wrongMD5[:]),
+ })
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("%s: rotation with an incorrect source key returned %d, want %d: %s",
+ instanceType, rec.Code, http.StatusForbidden, rec.Body.String())
+ }
+
+ rec = copyChecksumRequest(t, apiRouter, credentials, bucketName, object, object, map[string]string{
+ xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES,
+ xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(newKey),
+ xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(newMD5[:]),
+ xhttp.AmzServerSideEncryptionCopyCustomerAlgorithm: xhttp.AmzEncryptionAES,
+ xhttp.AmzServerSideEncryptionCopyCustomerKey: base64.StdEncoding.EncodeToString(newKey),
+ xhttp.AmzServerSideEncryptionCopyCustomerKeyMD5: base64.StdEncoding.EncodeToString(newMD5[:]),
+ })
+ // The zero-byte read path authenticates the source key before the
+ // rotation-specific equal-key distinction, matching non-empty reads.
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("%s: rotation with equal invalid keys returned %d, want %d: %s",
+ instanceType, rec.Code, http.StatusForbidden, rec.Body.String())
+ }
+
+ after, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if after.VersionID != "" {
+ t.Fatalf("%s: rejected rotation still created version %q", instanceType, after.VersionID)
+ }
+
+ // The object stays readable with the key it was written under.
+ req, err := newTestSignedRequestV4(http.MethodGet, getGetObjectURL("", bucketName, object),
+ 0, nil, credentials.AccessKey, credentials.SecretKey, map[string]string{
+ xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES,
+ xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(oldKey),
+ xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(oldMD5[:]),
+ })
+ if err != nil {
+ t.Fatalf("failed to build GetObject request: %v", err)
+ }
+ response := httptest.NewRecorder()
+ apiRouter.ServeHTTP(response, req)
+ if response.Code != http.StatusOK || response.Body.Len() != 0 {
+ t.Fatalf("%s: original object no longer readable: %d with %d bytes: %s",
+ instanceType, response.Code, response.Body.Len(), response.Body.String())
+ }
+}
diff --git a/cmd/object-crc64-composite_test.go b/cmd/object-crc64-composite_test.go
new file mode 100644
index 000000000..70a80370d
--- /dev/null
+++ b/cmd/object-crc64-composite_test.go
@@ -0,0 +1,79 @@
+// Copyright (c) 2015-2026 MinIO, Inc.
+//
+// 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.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package cmd
+
+import (
+ "bytes"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/minio/minio/internal/auth"
+ "github.com/minio/minio/internal/hash"
+ xhttp "github.com/minio/minio/internal/http"
+)
+
+func TestAPIPutObjectRejectsCRC64Composite(t *testing.T) {
+ defer DetectTestLeak(t)()
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
+ t: t,
+ objAPITest: testAPIPutObjectRejectsCRC64Composite,
+ endpoints: []string{"PutObject"},
+ })
+}
+
+func testAPIPutObjectRejectsCRC64Composite(obj ObjectLayer, instanceType, bucketName string,
+ apiRouter http.Handler, credentials auth.Credentials, t *testing.T,
+) {
+ data := []byte("crc64-composite")
+ object := "checksums/crc64-composite"
+ req, err := newTestSignedRequestV4(http.MethodPut, getPutObjectURL("", bucketName, object),
+ int64(len(data)), bytes.NewReader(data), credentials.AccessKey, credentials.SecretKey, map[string]string{
+ xhttp.AmzChecksumCRC64NVME: mustChecksum(t, hash.ChecksumCRC64NVME, data),
+ xhttp.AmzChecksumType: xhttp.AmzChecksumTypeComposite,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec := httptest.NewRecorder()
+ apiRouter.ServeHTTP(rec, req)
+ if rec.Code != http.StatusBadRequest || apiErrorCode(t, rec) != "InvalidArgument" {
+ t.Fatalf("%s: CRC64NVME/COMPOSITE PutObject returned %d %s", instanceType, rec.Code, rec.Body.String())
+ }
+ if _, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{}); !isErrObjectNotFound(err) {
+ t.Fatalf("%s: rejected PutObject stored an object: %v", instanceType, err)
+ }
+
+ trailerObject := "checksums/crc64-composite-trailer"
+ req, err = newTestSignedRequestV4(http.MethodPut, getPutObjectURL("", bucketName, trailerObject),
+ int64(len(data)), bytes.NewReader(data), credentials.AccessKey, credentials.SecretKey, map[string]string{
+ xhttp.AmzTrailer: xhttp.AmzChecksumCRC64NVME,
+ xhttp.AmzChecksumType: xhttp.AmzChecksumTypeComposite,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec = httptest.NewRecorder()
+ apiRouter.ServeHTTP(rec, req)
+ if rec.Code != http.StatusBadRequest || apiErrorCode(t, rec) != "InvalidArgument" {
+ t.Fatalf("%s: trailing CRC64NVME/COMPOSITE PutObject returned %d %s", instanceType, rec.Code, rec.Body.String())
+ }
+ if _, err := obj.GetObjectInfo(t.Context(), bucketName, trailerObject, ObjectOptions{}); !isErrObjectNotFound(err) {
+ t.Fatalf("%s: rejected trailing PutObject stored an object: %v", instanceType, err)
+ }
+}
diff --git a/cmd/object-handlers-common.go b/cmd/object-handlers-common.go
index a6febc122..abb4c49f8 100644
--- a/cmd/object-handlers-common.go
+++ b/cmd/object-handlers-common.go
@@ -353,6 +353,11 @@ func isETagEqual(left, right string) bool {
// upon a success Put/Copy/CompleteMultipart/Delete requests
// to activate delete only headers set delete as true
func setPutObjHeaders(w http.ResponseWriter, objInfo ObjectInfo, del bool, h http.Header) {
+ cs, _ := objInfo.decryptChecksums(0, h)
+ setPutObjHeadersWithChecksum(w, objInfo, del, cs)
+}
+
+func setPutObjHeadersWithChecksum(w http.ResponseWriter, objInfo ObjectInfo, del bool, cs map[string]string) {
// We must not use the http.Header().Set method here because some (broken)
// clients expect the ETag header key to be literally "ETag" - not "Etag" (case-sensitive).
// Therefore, we have to set the ETag directly as map entry.
@@ -374,7 +379,6 @@ func setPutObjHeaders(w http.ResponseWriter, objInfo ObjectInfo, del bool, h htt
lc.SetPredictionHeaders(w, objInfo.ToLifecycleOpts())
}
}
- cs, _ := objInfo.decryptChecksums(0, h)
hash.AddChecksumHeader(w, cs)
}
diff --git a/cmd/object-handlers.go b/cmd/object-handlers.go
index d5e868925..bf2c9b5e3 100644
--- a/cmd/object-handlers.go
+++ b/cmd/object-handlers.go
@@ -619,6 +619,12 @@ func (api objectAPIHandlers) getObjectAttributesHandler(ctx context.Context, obj
if checkPreconditions(ctx, w, r, objInfo, opts) {
return
}
+ if crypto.SSEC.IsEncrypted(objInfo.UserDefined) && r.Header.Get(xhttp.MinIOSourceReplicationRequest) != "true" {
+ if _, err = crypto.SSEC.UnsealObjectKey(r.Header, objInfo.UserDefined, bucket, object); err != nil {
+ writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
+ return
+ }
+ }
OA := new(getObjectAttributesResponse)
@@ -1107,6 +1113,14 @@ func cloneRequestWithoutCopyReplicationHeaders(r *http.Request) *http.Request {
return clone
}
+func copyDestinationSSEHeaders(h http.Header) http.Header {
+ dst := h.Clone()
+ dst.Del(xhttp.AmzServerSideEncryptionCopyCustomerAlgorithm)
+ dst.Del(xhttp.AmzServerSideEncryptionCopyCustomerKey)
+ dst.Del(xhttp.AmzServerSideEncryptionCopyCustomerKeyMD5)
+ return dst
+}
+
// getRemoteInstanceTransport contains a roundtripper for external (not peers) servers
var remoteInstanceTransport atomic.Value
@@ -1171,6 +1185,31 @@ func isRemoteCallRequired(ctx context.Context, bucket string, objAPI ObjectLayer
return false
}
+// copyRewritesObjectData reports whether the object layer stores new object data
+// for this copy instead of updating metadata in place or adding a
+// self-referential version. It mirrors the metadata-only decision taken by
+// erasureServerPools.CopyObject and erasureSets.CopyObject. CopyObjectHandler
+// has to predict that decision because the compression metadata it records must
+// describe whichever bytes are finally stored. metadataOnly already excludes
+// legacy sources, which the object layer always rewrites.
+func copyRewritesObjectData(metadataOnly bool, srcOpts, dstOpts ObjectOptions) bool {
+ if !metadataOnly {
+ return true
+ }
+ switch {
+ case dstOpts.VersionID != "" && srcOpts.VersionID == dstOpts.VersionID:
+ // In-place update of the addressed version.
+ return false
+ case !dstOpts.Versioned && srcOpts.VersionID == "":
+ // In-place update of an unversioned object.
+ return false
+ case dstOpts.Versioned && srcOpts.VersionID != dstOpts.VersionID:
+ // A new version referencing the existing data.
+ return false
+ }
+ return true
+}
+
// CopyObjectHandler - Copy Object
// ----------
// This implementation of the PUT operation adds an object to a bucket
@@ -1455,12 +1494,39 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
}
}
+ // Name the source version explicitly so a metadata-only copy into a
+ // versioned bucket adds a self-referential version instead of rewriting the
+ // object data. A null source version cannot be referenced this way.
+ copySrcOpts := srcOpts
+ if dstOpts.Versioned && copySrcOpts.VersionID == "" {
+ copySrcOpts.VersionID = srcInfo.VersionID
+ }
+
+ // A key rotation rewraps the object key held in metadata; it never
+ // re-encrypts the stored bytes. When the object layer stores new object
+ // data instead, the rotation has to go through the regular re-encrypting
+ // copy, or the destination ends up holding plaintext under metadata that
+ // claims the object is encrypted.
+ canRotateKeyInPlace := !srcInfo.Legacy &&
+ !copyRewritesObjectData(srcInfo.metadataOnly, copySrcOpts, dstOpts)
+
+ // The rotation shortcut authenticates the source key by unsealing it. The
+ // re-encrypting fallback authenticates it only through the source decryptor,
+ // which GetObjectNInfo skips for a zero byte object, so check it here before
+ // the destination is written under the new key.
+ if cpSrcDstSame && sseCopyC && sseC && !chStorageClass && !canRotateKeyInPlace {
+ if err := checkSSECCopySourceKey(r.Header, srcInfo.UserDefined, srcBucket, srcObject, newKey); err != nil {
+ writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
+ return
+ }
+ }
+
// If src == dst and either
// - the object is encrypted using SSE-C and two different SSE-C keys are present
// - the object is encrypted using SSE-S3 and the SSE-S3 header is present
// - the object storage class is not changing
// then execute a key rotation.
- if cpSrcDstSame && (sseCopyC && sseC) && !chStorageClass {
+ if cpSrcDstSame && (sseCopyC && sseC) && !chStorageClass && canRotateKeyInPlace {
oldKey, err = ParseSSECopyCustomerRequest(r.Header, srcInfo.UserDefined)
if err != nil {
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
@@ -1700,8 +1766,12 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
srcInfo.UserDefined[ReservedMetadataPrefixLower+ReplicationStatus] = dsc.PendingStatus()
srcInfo.UserDefined[ReservedMetadataPrefixLower+ReplicationTimestamp] = UTCNow().Format(time.RFC3339Nano)
}
- // Compression metadata must describe data that is actually rewritten.
- if !srcInfo.metadataOnly || srcInfo.Legacy || dstOpts.WantServerSideChecksumType.IsSet() {
+ // srcInfo.metadataOnly is still cleared below for legacy sources and for
+ // server-side checksum recomputation; both of those rewrite the object data.
+ metadataOnly := srcInfo.metadataOnly && !srcInfo.Legacy && !dstOpts.WantServerSideChecksumType.IsSet()
+
+ // Compression metadata must describe the bytes that are actually stored.
+ if copyRewritesObjectData(metadataOnly, copySrcOpts, dstOpts) {
if isDstCompressed {
maps.Copy(srcInfo.UserDefined, compressMetadata)
} else {
@@ -1800,11 +1870,6 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
copyObjectFn := objectAPI.CopyObject
- copySrcOpts := srcOpts
- if srcInfo.metadataOnly && dstOpts.Versioned && copySrcOpts.VersionID == "" {
- copySrcOpts.VersionID = srcInfo.VersionID
- }
-
// Copy source object to destination, if source and destination
// object is same then only metadata is updated.
objInfo, err = copyObjectFn(ctx, srcBucket, srcObject, dstBucket, dstObject, srcInfo, copySrcOpts, dstOpts)
@@ -1816,14 +1881,16 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
origETag := objInfo.ETag
objInfo.ETag = getDecryptedETag(r.Header, objInfo, false)
- response := generateCopyObjectResponse(objInfo, r.Header)
+ dstHeaders := copyDestinationSSEHeaders(r.Header)
+ checksums, _ := objInfo.decryptChecksums(0, dstHeaders)
+ response := generateCopyObjectResponse(objInfo, checksums)
encodedSuccessResponse := encodeResponse(response)
if dsc := mustReplicate(ctx, dstBucket, dstObject, objInfo.getMustReplicateOptions(replication.ObjectReplicationType, dstOpts)); dsc.ReplicateAny() {
scheduleReplication(ctx, objInfo, objectAPI, dsc, replication.ObjectReplicationType)
}
- setPutObjHeaders(w, objInfo, false, r.Header)
+ setPutObjHeadersWithChecksum(w, objInfo, false, checksums)
// We must not use the http.Header().Set method here because some (broken)
// clients expect the x-amz-copy-source-version-id header key to be literally
// "x-amz-copy-source-version-id"- not in canonicalized form, preserve it.
diff --git a/cmd/object-multipart-federation-checksum_test.go b/cmd/object-multipart-federation-checksum_test.go
index f7b8f5017..85269c7ee 100644
--- a/cmd/object-multipart-federation-checksum_test.go
+++ b/cmd/object-multipart-federation-checksum_test.go
@@ -40,7 +40,7 @@ import (
xhttp "github.com/minio/minio/internal/http"
)
-const federatedTestUserAgent = "MinIO (linux; amd64) minio-go/v7.0.99 minio-federated/RELEASE.TEST"
+const federatedTestUserAgent = "MinIO (linux; amd64) minio-go/v7.3.1 minio-federated/RELEASE.TEST"
func TestAPIFederatedUploadPartChecksumResponse(t *testing.T) {
defer DetectTestLeak(t)()
diff --git a/cmd/object-multipart-handlers.go b/cmd/object-multipart-handlers.go
index 2f6a68a94..37ff9f880 100644
--- a/cmd/object-multipart-handlers.go
+++ b/cmd/object-multipart-handlers.go
@@ -309,6 +309,10 @@ func (api objectAPIHandlers) NewMultipartUploadHandler(w http.ResponseWriter, r
}
}
+ if _, err := hash.GetContentChecksum(r.Header); err != nil {
+ writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrInvalidChecksum), r.URL)
+ return
+ }
checksumType := hash.NewChecksumHeader(r.Header)
if checksumType.Is(hash.ChecksumInvalid) {
writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrInvalidChecksum), r.URL)
diff --git a/cmd/object-ssec-zero-byte_test.go b/cmd/object-ssec-zero-byte_test.go
new file mode 100644
index 000000000..787c11893
--- /dev/null
+++ b/cmd/object-ssec-zero-byte_test.go
@@ -0,0 +1,226 @@
+// Copyright (c) 2015-2026 MinIO, Inc.
+//
+// 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.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package cmd
+
+import (
+ "bytes"
+ "crypto/md5"
+ "encoding/base64"
+ "encoding/xml"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/minio/minio/internal/auth"
+ xhttp "github.com/minio/minio/internal/http"
+)
+
+func TestAPIZeroByteSSECAuthenticatesKey(t *testing.T) {
+ defer DetectTestLeak(t)()
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
+ t: t,
+ objAPITest: testAPIZeroByteSSECAuthenticatesKey,
+ endpoints: []string{"CopyObject", "CopyObjectPart", "PutObject", "GetObject", "HeadObject", "NewMultipart"},
+ })
+}
+
+func testAPIZeroByteSSECAuthenticatesKey(obj ObjectLayer, instanceType, bucketName string,
+ apiRouter http.Handler, credentials auth.Credentials, t *testing.T,
+) {
+ previousTLS := globalIsTLS
+ globalIsTLS = true
+ defer func() { globalIsTLS = previousTLS }()
+
+ object := "ssec/zero-byte"
+ oldKey := bytes.Repeat([]byte{0x11}, 32)
+ oldMD5 := md5.Sum(oldKey)
+ wrongKey := bytes.Repeat([]byte{0x22}, 32)
+ wrongMD5 := md5.Sum(wrongKey)
+ putCopyChecksumSource(t, apiRouter, credentials, bucketName, object, nil, map[string]string{
+ xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES,
+ xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(oldKey),
+ xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(oldMD5[:]),
+ })
+
+ correctHeaders := map[string]string{
+ xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES,
+ xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(oldKey),
+ xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(oldMD5[:]),
+ }
+ wrongHeaders := map[string]string{
+ xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES,
+ xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(wrongKey),
+ xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(wrongMD5[:]),
+ }
+
+ if rec := ssecZeroByteRequest(t, apiRouter, credentials, http.MethodGet, bucketName, object, correctHeaders); rec.Code != http.StatusOK || rec.Body.Len() != 0 {
+ t.Fatalf("%s: correct-key GET returned %d with %d bytes: %s", instanceType, rec.Code, rec.Body.Len(), rec.Body.String())
+ }
+ headRec := ssecZeroByteRequest(t, apiRouter, credentials, http.MethodHead, bucketName, object, correctHeaders)
+ if headRec.Code != http.StatusOK {
+ t.Fatalf("%s: correct-key HEAD returned %d", instanceType, headRec.Code)
+ }
+ if rec := ssecZeroByteRequest(t, apiRouter, credentials, http.MethodGet, bucketName, object, wrongHeaders); rec.Code != http.StatusForbidden {
+ t.Fatalf("%s: wrong-key GET returned %d, want %d: %s", instanceType, rec.Code, http.StatusForbidden, rec.Body.String())
+ }
+ if rec := ssecZeroByteRequest(t, apiRouter, credentials, http.MethodHead, bucketName, object, wrongHeaders); rec.Code != http.StatusForbidden {
+ t.Fatalf("%s: wrong-key HEAD returned %d, want %d", instanceType, rec.Code, http.StatusForbidden)
+ }
+ conditionalHeaders := make(map[string]string, len(wrongHeaders)+1)
+ for key, value := range wrongHeaders {
+ conditionalHeaders[key] = value
+ }
+ conditionalInfo, err := obj.GetObjectInfo(t.Context(), bucketName, object, ObjectOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ conditionalRequest := httptest.NewRequest(http.MethodGet, getGetObjectURL("", bucketName, object), nil)
+ for key, value := range wrongHeaders {
+ conditionalRequest.Header.Set(key, value)
+ }
+ if _, err := DecryptObjectInfo(&conditionalInfo, conditionalRequest); err != nil {
+ t.Fatal(err)
+ }
+ conditionalHeaders[xhttp.IfNoneMatch] = conditionalInfo.ETag
+ if rec := ssecZeroByteRequest(t, apiRouter, credentials, http.MethodGet, bucketName, object, conditionalHeaders); rec.Code != http.StatusNotModified {
+ t.Fatalf("%s: conditional wrong-key GET returned %d, want %d: %s", instanceType, rec.Code, http.StatusNotModified, rec.Body.String())
+ }
+ if rec := ssecZeroByteRequest(t, apiRouter, credentials, http.MethodGet, bucketName, object, nil); rec.Code != http.StatusBadRequest {
+ t.Fatalf("%s: missing-key GET returned %d, want %d: %s", instanceType, rec.Code, http.StatusBadRequest, rec.Body.String())
+ }
+
+ nonEmptyObject := "ssec/one-byte"
+ putCopyChecksumSource(t, apiRouter, credentials, bucketName, nonEmptyObject, []byte{1}, correctHeaders)
+ if rec := ssecZeroByteRequest(t, apiRouter, credentials, http.MethodGet, bucketName, nonEmptyObject, wrongHeaders); rec.Code != http.StatusForbidden {
+ t.Fatalf("%s: one-byte wrong-key GET returned %d, want %d: %s", instanceType, rec.Code, http.StatusForbidden, rec.Body.String())
+ }
+
+ plainObject := "ssec/plain-zero-byte"
+ putCopyChecksumSource(t, apiRouter, credentials, bucketName, plainObject, nil, nil)
+ if rec := ssecZeroByteRequest(t, apiRouter, credentials, http.MethodGet, bucketName, plainObject, wrongHeaders); rec.Code != http.StatusBadRequest {
+ t.Fatalf("%s: unencrypted wrong-key GET returned %d, want %d: %s", instanceType, rec.Code, http.StatusBadRequest, rec.Body.String())
+ }
+
+ destination := "ssec/zero-byte-copy"
+ rec := copyChecksumRequest(t, apiRouter, credentials, bucketName, object, destination, map[string]string{
+ xhttp.AmzServerSideEncryptionCopyCustomerAlgorithm: xhttp.AmzEncryptionAES,
+ xhttp.AmzServerSideEncryptionCopyCustomerKey: base64.StdEncoding.EncodeToString(wrongKey),
+ xhttp.AmzServerSideEncryptionCopyCustomerKeyMD5: base64.StdEncoding.EncodeToString(wrongMD5[:]),
+ })
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("%s: wrong-key CopyObject returned %d, want %d: %s", instanceType, rec.Code, http.StatusForbidden, rec.Body.String())
+ }
+ if _, err := obj.GetObjectInfo(t.Context(), bucketName, destination, ObjectOptions{}); !isErrObjectNotFound(err) {
+ t.Fatalf("%s: rejected CopyObject created the destination: %v", instanceType, err)
+ }
+
+ rec = copyChecksumRequest(t, apiRouter, credentials, bucketName, object, object, map[string]string{
+ xhttp.AmzStorageClass: "REDUCED_REDUNDANCY",
+ xhttp.AmzServerSideEncryptionCopyCustomerAlgorithm: xhttp.AmzEncryptionAES,
+ xhttp.AmzServerSideEncryptionCopyCustomerKey: base64.StdEncoding.EncodeToString(wrongKey),
+ xhttp.AmzServerSideEncryptionCopyCustomerKeyMD5: base64.StdEncoding.EncodeToString(wrongMD5[:]),
+ })
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("%s: wrong-key storage-class CopyObject returned %d, want %d: %s", instanceType, rec.Code, http.StatusForbidden, rec.Body.String())
+ }
+
+ multipartObject := "ssec/zero-byte-multipart-copy"
+ req, err := newTestSignedRequestV4(http.MethodPost, getNewMultipartURL("", bucketName, multipartObject),
+ 0, nil, credentials.AccessKey, credentials.SecretKey, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec = httptest.NewRecorder()
+ apiRouter.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("%s: NewMultipartUpload returned %d: %s", instanceType, rec.Code, rec.Body.String())
+ }
+ var initiated InitiateMultipartUploadResponse
+ if err := xml.Unmarshal(rec.Body.Bytes(), &initiated); err != nil {
+ t.Fatal(err)
+ }
+ req, err = newTestSignedRequestV4(http.MethodPut,
+ getCopyObjectPartURL("", bucketName, multipartObject, initiated.UploadID, "1"),
+ 0, nil, credentials.AccessKey, credentials.SecretKey, map[string]string{
+ xhttp.AmzServerSideEncryptionCopyCustomerAlgorithm: xhttp.AmzEncryptionAES,
+ xhttp.AmzServerSideEncryptionCopyCustomerKey: base64.StdEncoding.EncodeToString(wrongKey),
+ xhttp.AmzServerSideEncryptionCopyCustomerKeyMD5: base64.StdEncoding.EncodeToString(wrongMD5[:]),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set(xhttp.AmzCopySource, SlashSeparator+pathJoin(bucketName, object))
+ rec = httptest.NewRecorder()
+ apiRouter.ServeHTTP(rec, req)
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("%s: wrong-key UploadPartCopy returned %d, want %d: %s", instanceType, rec.Code, http.StatusForbidden, rec.Body.String())
+ }
+ parts, err := obj.ListObjectParts(t.Context(), bucketName, multipartObject, initiated.UploadID, 0, 1000, ObjectOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(parts.Parts) != 0 {
+ t.Fatalf("%s: rejected UploadPartCopy stored %d parts", instanceType, len(parts.Parts))
+ }
+ if err := obj.AbortMultipartUpload(t.Context(), bucketName, multipartObject, initiated.UploadID, ObjectOptions{}); err != nil {
+ t.Fatal(err)
+ }
+
+ wrongHeader := http.Header{}
+ for key, value := range wrongHeaders {
+ wrongHeader.Set(key, value)
+ }
+ for _, test := range []struct {
+ header http.Header
+ opts ObjectOptions
+ }{
+ {header: nil, opts: ObjectOptions{}},
+ {header: wrongHeader, opts: ObjectOptions{NoDecryption: true}},
+ {header: wrongHeader, opts: ObjectOptions{ReplicationRequest: true}},
+ {header: wrongHeader, opts: ObjectOptions{Transition: TransitionOptions{RestoreRequest: &RestoreObjectRequest{}}}},
+ } {
+ gr, err := obj.GetObjectNInfo(t.Context(), bucketName, object, nil, test.header, test.opts)
+ if err != nil {
+ t.Fatalf("%s: internal zero-byte read with opts %+v failed: %v", instanceType, test.opts, err)
+ }
+ gr.Close()
+ }
+
+ rangeHeaders := make(map[string]string, len(wrongHeaders)+1)
+ for key, value := range wrongHeaders {
+ rangeHeaders[key] = value
+ }
+ rangeHeaders[xhttp.Range] = "bytes=0-0"
+ if rec := ssecZeroByteRequest(t, apiRouter, credentials, http.MethodGet, bucketName, object, rangeHeaders); rec.Code != http.StatusRequestedRangeNotSatisfiable {
+ t.Fatalf("%s: ranged wrong-key GET returned %d, want %d: %s", instanceType, rec.Code, http.StatusRequestedRangeNotSatisfiable, rec.Body.String())
+ }
+}
+
+func ssecZeroByteRequest(t *testing.T, apiRouter http.Handler, credentials auth.Credentials,
+ method, bucket, object string, headers map[string]string,
+) *httptest.ResponseRecorder {
+ t.Helper()
+ req, err := newTestSignedRequestV4(method, getGetObjectURL("", bucket, object),
+ 0, nil, credentials.AccessKey, credentials.SecretKey, headers)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec := httptest.NewRecorder()
+ apiRouter.ServeHTTP(rec, req)
+ return rec
+}
diff --git a/cmd/site-replication-bucket-adoption_test.go b/cmd/site-replication-bucket-adoption_test.go
new file mode 100644
index 000000000..2a5d98b3d
--- /dev/null
+++ b/cmd/site-replication-bucket-adoption_test.go
@@ -0,0 +1,190 @@
+// Copyright (c) 2015-2026 MinIO, Inc.
+//
+// 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.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package cmd
+
+import (
+ "bytes"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/minio/minio/internal/auth"
+)
+
+func TestPeerBucketAdoptionPreservesLockAndVersioningConfigs(t *testing.T) {
+ defer DetectTestLeak(t)()
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
+ t: t,
+ objAPITest: testPeerBucketAdoptionPreservesLockAndVersioningConfigs,
+ makeBucketOptions: MakeBucketOptions{LockEnabled: true},
+ })
+}
+
+func testPeerBucketAdoptionPreservesLockAndVersioningConfigs(_ ObjectLayer, instanceType, bucketName string,
+ _ http.Handler, _ auth.Credentials, t *testing.T,
+) {
+ objectLockXML := []byte(`EnabledGOVERNANCE30`)
+ versioningXML := []byte(`Enabledtruetemporary/`)
+ if _, err := globalBucketMetadataSys.Update(t.Context(), bucketName, objectLockConfig, objectLockXML); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := globalBucketMetadataSys.Update(t.Context(), bucketName, bucketVersioningConfig, versioningXML); err != nil {
+ t.Fatal(err)
+ }
+ before, err := globalBucketMetadataSys.Get(bucketName)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := globalSiteReplicationSys.PeerBucketMakeWithVersioningHandler(t.Context(), bucketName, MakeBucketOptions{
+ CreatedAt: before.Created.Add(-time.Hour),
+ LockEnabled: true,
+ }); err != nil {
+ t.Fatalf("%s: adopting existing bucket failed: %v", instanceType, err)
+ }
+ after, err := globalBucketMetadataSys.Get(bucketName)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(after.ObjectLockConfigXML, before.ObjectLockConfigXML) || !after.ObjectLockConfigUpdatedAt.Equal(before.ObjectLockConfigUpdatedAt) {
+ t.Fatalf("%s: Object Lock config changed during adoption", instanceType)
+ }
+ if !bytes.Equal(after.VersioningConfigXML, before.VersioningConfigXML) || !after.VersioningConfigUpdatedAt.Equal(before.VersioningConfigUpdatedAt) {
+ t.Fatalf("%s: versioning config changed during adoption", instanceType)
+ }
+}
+
+func TestPeerBucketAdoptionBootstrapsMissingConfigs(t *testing.T) {
+ defer DetectTestLeak(t)()
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
+ t: t,
+ objAPITest: testPeerBucketAdoptionBootstrapsMissingConfigs,
+ })
+}
+
+func TestPeerBucketAdoptionPreservesCustomVersioningWhenEnablingLock(t *testing.T) {
+ defer DetectTestLeak(t)()
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
+ t: t,
+ objAPITest: testPeerBucketAdoptionPreservesCustomVersioningWhenEnablingLock,
+ })
+}
+
+func testPeerBucketAdoptionPreservesCustomVersioningWhenEnablingLock(_ ObjectLayer, instanceType, bucketName string,
+ _ http.Handler, _ auth.Credentials, t *testing.T,
+) {
+ versioningXML := []byte(`Enabledtruetemporary/`)
+ if _, err := globalBucketMetadataSys.Update(t.Context(), bucketName, bucketVersioningConfig, versioningXML); err != nil {
+ t.Fatal(err)
+ }
+ before, err := globalBucketMetadataSys.Get(bucketName)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := globalSiteReplicationSys.PeerBucketMakeWithVersioningHandler(t.Context(), bucketName, MakeBucketOptions{
+ CreatedAt: before.Created,
+ LockEnabled: true,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ after, err := globalBucketMetadataSys.Get(bucketName)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(after.VersioningConfigXML, before.VersioningConfigXML) || !after.VersioningConfigUpdatedAt.Equal(before.VersioningConfigUpdatedAt) {
+ t.Fatalf("%s: custom versioning changed while enabling Object Lock", instanceType)
+ }
+ if !bytes.Equal(after.ObjectLockConfigXML, enabledBucketObjectLockConfig) {
+ t.Fatalf("%s: Object Lock was not bootstrapped", instanceType)
+ }
+}
+
+func TestPeerBucketAdoptionEnablesSuspendedVersioning(t *testing.T) {
+ defer DetectTestLeak(t)()
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
+ t: t,
+ objAPITest: testPeerBucketAdoptionEnablesSuspendedVersioning,
+ })
+}
+
+func testPeerBucketAdoptionEnablesSuspendedVersioning(_ ObjectLayer, instanceType, bucketName string,
+ _ http.Handler, _ auth.Credentials, t *testing.T,
+) {
+ suspended := []byte(`Suspended`)
+ if _, err := globalBucketMetadataSys.Update(t.Context(), bucketName, bucketVersioningConfig, suspended); err != nil {
+ t.Fatal(err)
+ }
+ before, err := globalBucketMetadataSys.Get(bucketName)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := globalSiteReplicationSys.PeerBucketMakeWithVersioningHandler(t.Context(), bucketName, MakeBucketOptions{CreatedAt: before.Created}); err != nil {
+ t.Fatal(err)
+ }
+ after, err := globalBucketMetadataSys.Get(bucketName)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if after.versioningConfig == nil || !after.versioningConfig.Enabled() {
+ t.Fatalf("%s: versioning remained disabled: %q", instanceType, after.VersioningConfigXML)
+ }
+ if !after.VersioningConfigUpdatedAt.After(before.VersioningConfigUpdatedAt) {
+ t.Fatalf("%s: versioning update time = %v, want after %v", instanceType, after.VersioningConfigUpdatedAt, before.VersioningConfigUpdatedAt)
+ }
+}
+
+func TestEnablePeerBucketVersioningRepairsInvalidConfig(t *testing.T) {
+ meta := newBucketMetadata("bucket")
+ meta.Created = time.Date(2026, time.August, 29, 8, 0, 0, 0, time.UTC)
+ meta.VersioningConfigXML = []byte(``)
+ if err := enablePeerBucketVersioning(&meta); err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(meta.VersioningConfigXML, enabledBucketVersioningConfig) || meta.VersioningConfigUpdatedAt.IsZero() {
+ t.Fatalf("invalid versioning was not repaired: xml=%q updatedAt=%v", meta.VersioningConfigXML, meta.VersioningConfigUpdatedAt)
+ }
+}
+
+func testPeerBucketAdoptionBootstrapsMissingConfigs(_ ObjectLayer, instanceType, bucketName string,
+ _ http.Handler, _ auth.Credentials, t *testing.T,
+) {
+ before, err := globalBucketMetadataSys.Get(bucketName)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(before.ObjectLockConfigXML) != 0 || len(before.VersioningConfigXML) != 0 {
+ t.Fatalf("%s: invalid bootstrap precondition", instanceType)
+ }
+ if err := globalSiteReplicationSys.PeerBucketMakeWithVersioningHandler(t.Context(), bucketName, MakeBucketOptions{
+ CreatedAt: before.Created,
+ LockEnabled: true,
+ }); err != nil {
+ t.Fatalf("%s: adopting existing bucket failed: %v", instanceType, err)
+ }
+ after, err := globalBucketMetadataSys.Get(bucketName)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(after.ObjectLockConfigXML, enabledBucketObjectLockConfig) || !bytes.Equal(after.VersioningConfigXML, enabledBucketVersioningConfig) {
+ t.Fatalf("%s: missing bootstrap configs: objectLock=%q versioning=%q", instanceType, after.ObjectLockConfigXML, after.VersioningConfigXML)
+ }
+ if !after.ObjectLockConfigUpdatedAt.Equal(before.Created) || !after.VersioningConfigUpdatedAt.Equal(before.Created) {
+ t.Fatalf("%s: bootstrap timestamps = (%v, %v), want %v", instanceType,
+ after.ObjectLockConfigUpdatedAt, after.VersioningConfigUpdatedAt, before.Created)
+ }
+}
diff --git a/cmd/site-replication-object-lock_test.go b/cmd/site-replication-object-lock_test.go
new file mode 100644
index 000000000..48d1d35f5
--- /dev/null
+++ b/cmd/site-replication-object-lock_test.go
@@ -0,0 +1,266 @@
+// Copyright (c) 2015-2026 MinIO, Inc.
+//
+// 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.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package cmd
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/minio/madmin-go/v3"
+ "github.com/minio/minio/internal/auth"
+ "github.com/minio/mux"
+)
+
+func TestSRBucketObjectLockMetadata(t *testing.T) {
+ updatedAt := time.Date(2026, time.August, 29, 8, 0, 0, 0, time.UTC)
+ current := "current"
+ legacy := "legacy"
+
+ event := newSRBucketObjectLockMeta("bucket", ¤t, updatedAt)
+ if event.Type != madmin.SRBucketMetaTypeObjectLockConfig || event.Bucket != "bucket" ||
+ event.ObjectLockConfig == nil || *event.ObjectLockConfig != current || event.Tags != nil || !event.UpdatedAt.Equal(updatedAt) {
+ t.Fatalf("unexpected Object Lock event: %#v", event)
+ }
+
+ encoded, err := json.Marshal(event)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var roundTrip madmin.SRBucketMeta
+ if err := json.Unmarshal(encoded, &roundTrip); err != nil {
+ t.Fatal(err)
+ }
+ if roundTrip.ObjectLockConfig == nil || *roundTrip.ObjectLockConfig != current || roundTrip.Tags != nil {
+ t.Fatalf("unexpected JSON round trip: %#v", roundTrip)
+ }
+
+ for _, test := range []struct {
+ name string
+ item madmin.SRBucketMeta
+ want *string
+ }{
+ {name: "current", item: madmin.SRBucketMeta{ObjectLockConfig: ¤t}, want: ¤t},
+ {name: "legacy", item: madmin.SRBucketMeta{Tags: &legacy}, want: &legacy},
+ {name: "current wins", item: madmin.SRBucketMeta{ObjectLockConfig: ¤t, Tags: &legacy}, want: ¤t},
+ {name: "missing", item: madmin.SRBucketMeta{}},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ got := srObjectLockPayload(test.item)
+ if test.want == nil {
+ if got != nil {
+ t.Fatalf("payload = %q, want nil", *got)
+ }
+ return
+ }
+ if got == nil || *got != *test.want {
+ t.Fatalf("payload = %v, want %q", got, *test.want)
+ }
+ })
+ }
+}
+
+func TestPeerBucketObjectLockMetadataCurrentAndLegacyPayloads(t *testing.T) {
+ defer DetectTestLeak(t)()
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
+ t: t,
+ objAPITest: testPeerBucketObjectLockMetadataCurrentAndLegacyPayloads,
+ makeBucketOptions: MakeBucketOptions{LockEnabled: true},
+ })
+}
+
+func applySRBucketMetaViaAdmin(t *testing.T, credentials auth.Credentials, item madmin.SRBucketMeta) *httptest.ResponseRecorder {
+ t.Helper()
+ body, err := json.Marshal(item)
+ if err != nil {
+ t.Fatal(err)
+ }
+ adminRouter := mux.NewRouter()
+ registerAdminRouter(adminRouter, true)
+ path := adminPathPrefix + adminAPIVersionPrefix + "/site-replication/peer/bucket-meta"
+ req, err := newTestSignedRequestV4(http.MethodPut, path, int64(len(body)), bytes.NewReader(body),
+ credentials.AccessKey, credentials.SecretKey, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec := httptest.NewRecorder()
+ adminRouter.ServeHTTP(rec, req)
+ return rec
+}
+
+func testPeerBucketObjectLockMetadataCurrentAndLegacyPayloads(_ ObjectLayer, instanceType, bucketName string,
+ _ http.Handler, credentials auth.Credentials, t *testing.T,
+) {
+ apply := func(item madmin.SRBucketMeta, wantDays uint64) {
+ t.Helper()
+ rec := applySRBucketMetaViaAdmin(t, credentials, item)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("%s: admin Object Lock apply returned %d: %s", instanceType, rec.Code, rec.Body.String())
+ }
+ config, _, err := globalBucketMetadataSys.GetObjectLockConfig(bucketName)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if config.Rule == nil || config.Rule.DefaultRetention.Mode != "GOVERNANCE" ||
+ config.Rule.DefaultRetention.Days == nil || *config.Rule.DefaultRetention.Days != wantDays {
+ t.Fatalf("%s: persisted Object Lock config = %s, want GOVERNANCE/%d days", instanceType, config, wantDays)
+ }
+ }
+
+ config30 := base64.StdEncoding.EncodeToString([]byte(`EnabledGOVERNANCE30`))
+ apply(newSRBucketObjectLockMeta(bucketName, &config30, UTCNow().Add(time.Hour)), 30)
+
+ config45 := base64.StdEncoding.EncodeToString([]byte(`EnabledGOVERNANCE45`))
+ apply(madmin.SRBucketMeta{
+ Type: madmin.SRBucketMetaTypeObjectLockConfig,
+ Bucket: bucketName,
+ Tags: &config45,
+ UpdatedAt: UTCNow().Add(2 * time.Hour),
+ }, 45)
+}
+
+func TestPeerBucketObjectLockMetadataWithoutLockEnabled(t *testing.T) {
+ defer DetectTestLeak(t)()
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
+ t: t,
+ objAPITest: testPeerBucketObjectLockMetadataWithoutLockEnabled,
+ })
+}
+
+func testPeerBucketObjectLockMetadataWithoutLockEnabled(_ ObjectLayer, instanceType, bucketName string,
+ _ http.Handler, credentials auth.Credentials, t *testing.T,
+) {
+ config := base64.StdEncoding.EncodeToString([]byte(`EnabledGOVERNANCE30`))
+ item := newSRBucketObjectLockMeta(bucketName, &config, UTCNow().Add(time.Hour))
+ rec := applySRBucketMetaViaAdmin(t, credentials, item)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("%s: admin Object Lock apply returned %d: %s", instanceType, rec.Code, rec.Body.String())
+ }
+ meta, err := globalBucketMetadataSys.Get(bucketName)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if meta.objectLockConfig == nil || len(meta.VersioningConfigXML) != 0 {
+ t.Fatalf("%s: unlocked bucket metadata = objectLock:%v versioning:%q", instanceType, meta.objectLockConfig, meta.VersioningConfigXML)
+ }
+}
+
+func TestHealObjectLockMetadataUsesObjectLockField(t *testing.T) {
+ defer DetectTestLeak(t)()
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
+ t: t,
+ objAPITest: testHealObjectLockMetadataUsesObjectLockField,
+ })
+}
+
+func testHealObjectLockMetadataUsesObjectLockField(obj ObjectLayer, instanceType, bucketName string,
+ _ http.Handler, credentials auth.Credentials, t *testing.T,
+) {
+ ctx := t.Context()
+ localID := globalDeploymentID()
+ remoteID := "remote-object-lock-heal"
+ updatedAt := UTCNow().Add(time.Hour)
+ createdAt := updatedAt.Add(-time.Hour)
+ config := base64.StdEncoding.EncodeToString([]byte(`EnabledGOVERNANCE30`))
+
+ remoteApplies := make(chan madmin.SRBucketMeta, 1)
+ remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var applied madmin.SRBucketMeta
+ if err := json.NewDecoder(r.Body).Decode(&applied); err != nil {
+ t.Errorf("%s: decode remote apply: %v", instanceType, err)
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ remoteApplies <- applied
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer remote.Close()
+
+ serviceCred, err := auth.CreateCredentials("object-lock-heal-svc", "object-lock-heal-service-secret")
+ if err != nil {
+ t.Fatal(err)
+ }
+ serviceCred.ParentUser = credentials.AccessKey
+ if _, err = globalIAMSys.store.AddServiceAccount(ctx, serviceCred); err != nil {
+ t.Fatal(err)
+ }
+ defer globalIAMSys.DeleteServiceAccount(ctx, serviceCred.AccessKey, false)
+
+ globalSiteReplicationSys.Lock()
+ oldEnabled := globalSiteReplicationSys.enabled
+ oldState := globalSiteReplicationSys.state
+ globalSiteReplicationSys.enabled = true
+ globalSiteReplicationSys.state = srState{
+ Name: "object-lock-heal-test",
+ ServiceAccountAccessKey: serviceCred.AccessKey,
+ Peers: map[string]madmin.PeerInfo{
+ localID: {Name: "local", DeploymentID: localID},
+ remoteID: {Name: "remote", DeploymentID: remoteID, Endpoint: remote.URL},
+ },
+ }
+ globalSiteReplicationSys.Unlock()
+ defer func() {
+ globalSiteReplicationSys.Lock()
+ globalSiteReplicationSys.enabled = oldEnabled
+ globalSiteReplicationSys.state = oldState
+ globalSiteReplicationSys.Unlock()
+ }()
+
+ status := srStatusInfo{
+ Sites: map[string]madmin.PeerInfo{
+ localID: {Name: "local", DeploymentID: localID},
+ remoteID: {Name: "remote", DeploymentID: remoteID, Endpoint: remote.URL},
+ },
+ BucketStats: map[string]map[string]srBucketStatsSummary{
+ bucketName: {
+ localID: {
+ SRBucketStatsSummary: madmin.SRBucketStatsSummary{OLockConfigMismatch: true},
+ meta: srBucketMetaInfo{SRBucketInfo: madmin.SRBucketInfo{
+ Bucket: bucketName,
+ CreatedAt: createdAt,
+ ObjectLockConfig: &config,
+ ObjectLockConfigUpdatedAt: updatedAt,
+ }, DeploymentID: localID},
+ },
+ remoteID: {
+ SRBucketStatsSummary: madmin.SRBucketStatsSummary{OLockConfigMismatch: true},
+ meta: srBucketMetaInfo{SRBucketInfo: madmin.SRBucketInfo{
+ Bucket: bucketName,
+ CreatedAt: createdAt,
+ }, DeploymentID: remoteID},
+ },
+ },
+ },
+ }
+ if err := globalSiteReplicationSys.healOLockConfigMetadata(ctx, obj, bucketName, status); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case applied := <-remoteApplies:
+ if applied.Type != madmin.SRBucketMetaTypeObjectLockConfig || applied.Bucket != bucketName ||
+ applied.ObjectLockConfig == nil || *applied.ObjectLockConfig != config || applied.Tags != nil || !applied.UpdatedAt.Equal(updatedAt) {
+ t.Fatalf("%s: remote heal apply = %#v", instanceType, applied)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatalf("%s: remote heal did not dispatch Object Lock metadata", instanceType)
+ }
+}
diff --git a/cmd/site-replication-status-accounting_test.go b/cmd/site-replication-status-accounting_test.go
new file mode 100644
index 000000000..ff4deeef1
--- /dev/null
+++ b/cmd/site-replication-status-accounting_test.go
@@ -0,0 +1,191 @@
+// Copyright (c) 2015-2026 MinIO, Inc.
+//
+// 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.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package cmd
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/minio/madmin-go/v3"
+ "github.com/minio/minio/internal/auth"
+)
+
+func TestSiteReplicationStatusAccountsPerSiteAndSurvivesMalformedConfig(t *testing.T) {
+ defer DetectTestLeak(t)()
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
+ t: t,
+ objAPITest: testSiteReplicationStatusAccountsPerSiteAndSurvivesMalformedConfig,
+ })
+}
+
+func testSiteReplicationStatusAccountsPerSiteAndSurvivesMalformedConfig(obj ObjectLayer, instanceType, localBucket string,
+ _ http.Handler, credentials auth.Credentials, t *testing.T,
+) {
+ ctx := t.Context()
+ remoteBucket := getRandomBucketName()
+ if err := obj.MakeBucket(ctx, remoteBucket, MakeBucketOptions{}); err != nil {
+ t.Fatal(err)
+ }
+ remoteBucketMeta, err := loadBucketMetadata(ctx, obj, remoteBucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ globalBucketMetadataSys.Set(remoteBucket, remoteBucketMeta)
+ globalNotificationSys.LoadBucketMetadata(ctx, remoteBucket)
+
+ tagXML := []byte(`keyvalue`)
+ versioningXML := []byte(`Enabled`)
+ objectLockXML := []byte(`EnabledGOVERNANCE30`)
+ sseXML := []byte(`AES256`)
+ quotaJSON, err := json.Marshal(madmin.BucketQuota{Type: madmin.HardQuota, Quota: 1024})
+ if err != nil {
+ t.Fatal(err)
+ }
+ policyJSON := []byte(fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":["*"]},"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::%s/*"]}]}`, localBucket))
+
+ for configFile, data := range map[string][]byte{
+ bucketTaggingConfig: tagXML,
+ bucketVersioningConfig: versioningXML,
+ objectLockConfig: objectLockXML,
+ bucketSSEConfig: sseXML,
+ bucketQuotaConfigFile: quotaJSON,
+ bucketPolicyConfig: policyJSON,
+ bucketCorsConfig: []byte(testSiteReplicationCORSDoc),
+ } {
+ if _, err := globalBucketMetadataSys.Update(ctx, localBucket, configFile, data); err != nil {
+ t.Fatalf("%s: update %s: %v", instanceType, configFile, err)
+ }
+ }
+
+ encode := func(data []byte) *string {
+ encoded := base64.StdEncoding.EncodeToString(data)
+ return &encoded
+ }
+ remotePolicy := []byte(fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":["*"]},"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::%s/*"]}]}`, remoteBucket))
+ localMeta, err := globalBucketMetadataSys.Get(localBucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ remoteInfo := madmin.SRInfo{
+ DeploymentID: "remote-status-accounting",
+ Buckets: map[string]madmin.SRBucketInfo{
+ localBucket: {
+ Bucket: localBucket,
+ 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)),
+ },
+ },
+ }
+ remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(remoteInfo); err != nil {
+ t.Errorf("%s: encode remote metadata: %v", instanceType, err)
+ }
+ }))
+ defer remote.Close()
+
+ serviceCred, err := auth.CreateCredentials("status-accounting-svc", "status-accounting-service-secret")
+ if err != nil {
+ t.Fatal(err)
+ }
+ serviceCred.ParentUser = credentials.AccessKey
+ if _, err = globalIAMSys.store.AddServiceAccount(ctx, serviceCred); err != nil {
+ t.Fatal(err)
+ }
+ defer globalIAMSys.DeleteServiceAccount(ctx, serviceCred.AccessKey, false)
+
+ localID := globalDeploymentID()
+ remoteID := remoteInfo.DeploymentID
+ globalSiteReplicationSys.Lock()
+ oldEnabled := globalSiteReplicationSys.enabled
+ oldState := globalSiteReplicationSys.state
+ globalSiteReplicationSys.enabled = true
+ globalSiteReplicationSys.state = srState{
+ Name: "status-accounting-test",
+ ServiceAccountAccessKey: serviceCred.AccessKey,
+ Peers: map[string]madmin.PeerInfo{
+ localID: {Name: "local", DeploymentID: localID},
+ remoteID: {Name: "remote", DeploymentID: remoteID, Endpoint: remote.URL},
+ },
+ }
+ globalSiteReplicationSys.Unlock()
+ defer func() {
+ globalSiteReplicationSys.Lock()
+ globalSiteReplicationSys.enabled = oldEnabled
+ globalSiteReplicationSys.state = oldState
+ globalSiteReplicationSys.Unlock()
+ }()
+
+ check := func(name string, wantRemoteTags, wantRemoteQuota int) {
+ t.Helper()
+ status, err := globalSiteReplicationSys.siteReplicationStatus(ctx, obj, madmin.SRStatusOptions{Buckets: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ local := status.StatsSummary[localID]
+ remote := status.StatsSummary[remoteID]
+ if local.TotalBucketsCount != 2 || remote.TotalBucketsCount != 2 {
+ t.Fatalf("%s: bucket totals = local:%d remote:%d", name, local.TotalBucketsCount, remote.TotalBucketsCount)
+ }
+ if local.TotalTagsCount != 1 || remote.TotalTagsCount != wantRemoteTags ||
+ local.TotalLockConfigCount != 1 || remote.TotalLockConfigCount != 1 ||
+ local.TotalSSEConfigCount != 1 || remote.TotalSSEConfigCount != 1 ||
+ local.TotalVersioningConfigCount != 1 || remote.TotalVersioningConfigCount != 1 ||
+ local.TotalBucketPoliciesCount != 1 || remote.TotalBucketPoliciesCount != 1 ||
+ local.TotalQuotaConfigCount != 1 || remote.TotalQuotaConfigCount != wantRemoteQuota ||
+ local.TotalCorsConfigCount != 1 || remote.TotalCorsConfigCount != 1 {
+ t.Fatalf("%s: site totals = local:%+v remote:%+v", name, local, remote)
+ }
+ if local.ReplicatedTags != 0 || remote.ReplicatedTags != 0 ||
+ local.ReplicatedBucketPolicies != 0 || remote.ReplicatedBucketPolicies != 0 ||
+ local.ReplicatedQuotaConfig != 0 || remote.ReplicatedQuotaConfig != 0 {
+ t.Fatalf("%s: asymmetric configs counted as replicated: local:%+v remote:%+v", name, local, remote)
+ }
+ remoteBucketStatus := status.BucketStats[remoteBucket][remoteID]
+ if remoteBucketStatus.HasTagsSet != (wantRemoteTags != 0) || remoteBucketStatus.HasQuotaCfgSet != (wantRemoteQuota != 0) {
+ t.Fatalf("%s: remote bucket presence = tags:%v quota:%v", name, remoteBucketStatus.HasTagsSet, remoteBucketStatus.HasQuotaCfgSet)
+ }
+ }
+
+ check("valid asymmetric configs", 1, 1)
+ invalidTags := "not-base64"
+ info := remoteInfo.Buckets[remoteBucket]
+ info.Tags = &invalidTags
+ remoteInfo.Buckets[remoteBucket] = info
+ check("malformed tags do not drop site", 0, 1)
+
+ emptyQuota := base64.StdEncoding.EncodeToString([]byte(`{}`))
+ info = remoteInfo.Buckets[remoteBucket]
+ info.QuotaConfig = &emptyQuota
+ remoteInfo.Buckets[remoteBucket] = info
+ check("empty quota is absent", 0, 0)
+}
diff --git a/cmd/site-replication.go b/cmd/site-replication.go
index 41f0eb512..31232fc4d 100644
--- a/cmd/site-replication.go
+++ b/cmd/site-replication.go
@@ -46,6 +46,7 @@ import (
"github.com/minio/minio/internal/bucket/cors"
"github.com/minio/minio/internal/bucket/lifecycle"
sreplication "github.com/minio/minio/internal/bucket/replication"
+ "github.com/minio/minio/internal/bucket/versioning"
"github.com/minio/minio/internal/logger"
xldap "github.com/minio/pkg/v3/ldap"
"github.com/minio/pkg/v3/policy"
@@ -888,6 +889,32 @@ func (c *SiteReplicationSys) DeleteBucketHook(ctx context.Context, bucket string
return errors.Unwrap(cerr)
}
+func enablePeerBucketVersioning(meta *BucketMetadata) error {
+ if len(meta.VersioningConfigXML) == 0 {
+ meta.VersioningConfigXML = enabledBucketVersioningConfig
+ if meta.VersioningConfigUpdatedAt.IsZero() {
+ meta.VersioningConfigUpdatedAt = meta.Created
+ }
+ return nil
+ }
+ config, err := versioning.ParseConfig(bytes.NewReader(meta.VersioningConfigXML))
+ if err != nil {
+ meta.VersioningConfigXML = enabledBucketVersioningConfig
+ meta.VersioningConfigUpdatedAt = UTCNow()
+ return nil
+ }
+ if config.Enabled() {
+ return nil
+ }
+ config.Status = versioning.Enabled
+ meta.VersioningConfigXML, err = xml.Marshal(config)
+ if err != nil {
+ return err
+ }
+ meta.VersioningConfigUpdatedAt = UTCNow()
+ return nil
+}
+
// PeerBucketMakeWithVersioningHandler - creates bucket and enables versioning.
func (c *SiteReplicationSys) PeerBucketMakeWithVersioningHandler(ctx context.Context, bucket string, opts MakeBucketOptions) error {
objAPI := newObjectLayerFn()
@@ -916,9 +943,14 @@ func (c *SiteReplicationSys) PeerBucketMakeWithVersioningHandler(ctx context.Con
meta.SetCreatedAt(opts.CreatedAt)
- meta.VersioningConfigXML = enabledBucketVersioningConfig
- if opts.LockEnabled {
+ if err := enablePeerBucketVersioning(&meta); err != nil {
+ return wrapSRErr(err)
+ }
+ if opts.LockEnabled && len(meta.ObjectLockConfigXML) == 0 {
meta.ObjectLockConfigXML = enabledBucketObjectLockConfig
+ if meta.ObjectLockConfigUpdatedAt.IsZero() {
+ meta.ObjectLockConfigUpdatedAt = meta.Created
+ }
}
if err := meta.Save(context.Background(), objAPI); err != nil {
@@ -1727,6 +1759,26 @@ func (c *SiteReplicationSys) PeerBucketTaggingHandler(ctx context.Context, bucke
return nil
}
+func newSRBucketObjectLockMeta(bucket string, config *string, updatedAt time.Time) madmin.SRBucketMeta {
+ return madmin.SRBucketMeta{
+ Type: madmin.SRBucketMetaTypeObjectLockConfig,
+ Bucket: bucket,
+ ObjectLockConfig: config,
+ UpdatedAt: updatedAt,
+ }
+}
+
+func srObjectLockPayload(item madmin.SRBucketMeta) *string {
+ if item.ObjectLockConfig != nil {
+ return item.ObjectLockConfig
+ }
+ return item.Tags
+}
+
+func (c *SiteReplicationSys) peerBucketObjectLockConfigItem(ctx context.Context, item madmin.SRBucketMeta) error {
+ return c.PeerBucketObjectLockConfigHandler(ctx, item.Bucket, srObjectLockPayload(item), item.UpdatedAt)
+}
+
// PeerBucketObjectLockConfigHandler - sets object lock on local bucket.
func (c *SiteReplicationSys) PeerBucketObjectLockConfigHandler(ctx context.Context, bucket string, objectLockData *string, updatedAt time.Time) error {
if objectLockData != nil {
@@ -2176,12 +2228,7 @@ func (c *SiteReplicationSys) syncToAllPeers(ctx context.Context, addOpts madmin.
objLockCfgData, tm := meta.ObjectLockConfigXML, meta.ObjectLockConfigUpdatedAt
if len(objLockCfgData) > 0 {
objLockStr := base64.StdEncoding.EncodeToString(objLockCfgData)
- err = c.BucketMetaHook(ctx, madmin.SRBucketMeta{
- Type: madmin.SRBucketMetaTypeObjectLockConfig,
- Bucket: bucket,
- Tags: &objLockStr,
- UpdatedAt: tm,
- })
+ err = c.BucketMetaHook(ctx, newSRBucketObjectLockMeta(bucket, &objLockStr, tm))
if err != nil {
return errSRBucketMetaError(err)
}
@@ -3406,80 +3453,116 @@ func (c *SiteReplicationSys) siteReplicationStatus(ctx context.Context, objAPI O
quotaCfgs := make([]*madmin.BucketQuota, numSites)
sseCfgSet := set.NewStringSet()
versionCfgSet := set.NewStringSet()
- var tagCount, olockCfgCount, sseCfgCount, corsCfgCount, versionCfgCount int
+ validReplCfg := make([]bool, numSites)
+ validVersionCfg := make([]bool, numSites)
+ validQuotaCfg := make([]bool, numSites)
+ validTags := make([]bool, numSites)
+ validPolicies := make([]bool, numSites)
+ validObjectLockCfg := make([]bool, numSites)
+ validSSECfg := make([]bool, numSites)
+ validCorsCfg := make([]bool, numSites)
+ var tagCount, olockCfgCount, policyCount, quotaCfgCount, sseCfgCount, corsCfgCount, versionCfgCount int
for i, s := range slc {
+ logInvalid := func(configType string, err error) {
+ replLogOnceIf(ctx,
+ fmt.Errorf("unable to parse %s metadata for bucket %s from site %s: %w", configType, b, s.DeploymentID, err),
+ "site-replication-status-"+configType+"-"+b+"-"+s.DeploymentID)
+ }
if s.ReplicationConfig != nil {
cfgBytes, err := base64.StdEncoding.DecodeString(*s.ReplicationConfig)
- if err != nil {
- continue
+ if err == nil {
+ cfg, err := sreplication.ParseConfig(bytes.NewReader(cfgBytes))
+ if err == nil {
+ replCfgs[i] = cfg
+ validReplCfg[i] = true
+ } else {
+ logInvalid("replication", err)
+ }
+ } else {
+ logInvalid("replication", err)
}
- cfg, err := sreplication.ParseConfig(bytes.NewReader(cfgBytes))
- if err != nil {
- continue
- }
- replCfgs[i] = cfg
}
if s.Versioning != nil {
configData, err := base64.StdEncoding.DecodeString(*s.Versioning)
- if err != nil {
- continue
- }
- versionCfgCount++
- if !versionCfgSet.Contains(string(configData)) {
- versionCfgSet.Add(string(configData))
+ if err == nil {
+ validVersionCfg[i] = true
+ versionCfgCount++
+ if !versionCfgSet.Contains(string(configData)) {
+ versionCfgSet.Add(string(configData))
+ }
+ } else {
+ logInvalid("versioning", err)
}
}
if s.QuotaConfig != nil {
cfgBytes, err := base64.StdEncoding.DecodeString(*s.QuotaConfig)
- if err != nil {
- continue
+ if err == nil {
+ cfg, err := parseBucketQuota(b, cfgBytes)
+ if err == nil {
+ if cfg != nil && *cfg != (madmin.BucketQuota{}) {
+ quotaCfgs[i] = cfg
+ validQuotaCfg[i] = true
+ quotaCfgCount++
+ }
+ } else {
+ logInvalid("quota", err)
+ }
+ } else {
+ logInvalid("quota", err)
}
- cfg, err := parseBucketQuota(b, cfgBytes)
- if err != nil {
- continue
- }
- quotaCfgs[i] = cfg
}
if s.Tags != nil {
tagBytes, err := base64.StdEncoding.DecodeString(*s.Tags)
- if err != nil {
- continue
- }
- tagCount++
- if !tagSet.Contains(string(tagBytes)) {
- tagSet.Add(string(tagBytes))
+ if err == nil {
+ validTags[i] = true
+ tagCount++
+ if !tagSet.Contains(string(tagBytes)) {
+ tagSet.Add(string(tagBytes))
+ }
+ } else {
+ logInvalid("tags", err)
}
}
if len(s.Policy) > 0 {
plcy, err := policy.ParseBucketPolicyConfig(bytes.NewReader(s.Policy), b)
- if err != nil {
- continue
+ if err == nil {
+ policies[i] = plcy
+ validPolicies[i] = true
+ policyCount++
+ } else {
+ logInvalid("policy", err)
}
- policies[i] = plcy
}
if s.ObjectLockConfig != nil {
configData, err := base64.StdEncoding.DecodeString(*s.ObjectLockConfig)
- if err != nil {
- continue
- }
- olockCfgCount++
- if !olockConfigSet.Contains(string(configData)) {
- olockConfigSet.Add(string(configData))
+ if err == nil {
+ validObjectLockCfg[i] = true
+ olockCfgCount++
+ if !olockConfigSet.Contains(string(configData)) {
+ olockConfigSet.Add(string(configData))
+ }
+ } else {
+ logInvalid("object-lock", err)
}
}
if s.SSEConfig != nil {
configData, err := base64.StdEncoding.DecodeString(*s.SSEConfig)
- if err != nil {
- continue
- }
- sseCfgCount++
- if !sseCfgSet.Contains(string(configData)) {
- sseCfgSet.Add(string(configData))
+ if err == nil {
+ validSSECfg[i] = true
+ sseCfgCount++
+ if !sseCfgSet.Contains(string(configData)) {
+ sseCfgSet.Add(string(configData))
+ }
+ } else {
+ logInvalid("sse", err)
}
}
if s.CorsConfig != nil {
if _, err := decodeCORSReplicationPayload(s.CorsConfig); err == nil {
+ validCorsCfg[i] = true
corsCfgCount++
+ } else {
+ logInvalid("cors", err)
}
}
ss, ok := info.StatsSummary[s.DeploymentID]
@@ -3491,24 +3574,27 @@ func (c *SiteReplicationSys) siteReplicationStatus(ctx context.Context, objAPI O
ss.ReplicatedBuckets++
}
ss.TotalBucketsCount++
- if tagCount > 0 {
+ if validTags[i] {
ss.TotalTagsCount++
}
- if olockCfgCount > 0 {
+ if validObjectLockCfg[i] {
ss.TotalLockConfigCount++
}
- if sseCfgCount > 0 {
+ if validSSECfg[i] {
ss.TotalSSEConfigCount++
}
- if s.CorsConfig != nil {
+ if validCorsCfg[i] {
ss.TotalCorsConfigCount++
}
- if versionCfgCount > 0 {
+ if validVersionCfg[i] {
ss.TotalVersioningConfigCount++
}
- if len(policies) > 0 {
+ if validPolicies[i] {
ss.TotalBucketPoliciesCount++
}
+ if validQuotaCfg[i] {
+ ss.TotalQuotaConfigCount++
+ }
info.StatsSummary[s.DeploymentID] = ss
}
tagMismatch := !isReplicated(tagCount, numSites, tagSet)
@@ -3542,13 +3628,13 @@ func (c *SiteReplicationSys) siteReplicationStatus(ctx context.Context, objAPI O
PolicyMismatch: policyMismatch,
ReplicationCfgMismatch: replCfgMismatch,
QuotaCfgMismatch: quotaCfgMismatch,
- HasReplicationCfg: s.ReplicationConfig != nil,
- HasTagsSet: s.Tags != nil,
- HasOLockConfigSet: s.ObjectLockConfig != nil,
- HasPolicySet: s.Policy != nil,
+ HasReplicationCfg: validReplCfg[i],
+ HasTagsSet: validTags[i],
+ HasOLockConfigSet: validObjectLockCfg[i],
+ HasPolicySet: validPolicies[i],
HasQuotaCfgSet: quotaCfgSet,
- HasSSECfgSet: s.SSEConfig != nil,
- HasCorsCfgSet: s.CorsConfig != nil,
+ HasSSECfgSet: validSSECfg[i],
+ HasCorsCfgSet: validCorsCfg[i],
}
var m srBucketMetaInfo
if len(bucketStats[s.Bucket]) > dIdx {
@@ -3574,12 +3660,15 @@ func (c *SiteReplicationSys) siteReplicationStatus(ctx context.Context, objAPI O
if !corsCfgMismatch && corsCfgCount == numSites {
sum.ReplicatedCorsConfig++
}
- if !policyMismatch && len(policies) == numSites {
+ if !policyMismatch && policyCount == numSites {
sum.ReplicatedBucketPolicies++
}
if !tagMismatch && tagCount == numSites {
sum.ReplicatedTags++
}
+ if !quotaCfgMismatch && quotaCfgCount == numSites {
+ sum.ReplicatedQuotaConfig++
+ }
info.StatsSummary[s.DeploymentID] = sum
}
}
@@ -5322,12 +5411,7 @@ func (c *SiteReplicationSys) healOLockConfigMetadata(ctx context.Context, objAPI
return wrapSRErr(err)
}
peerName := info.Sites[dID].Name
- err = admClient.SRPeerReplicateBucketMeta(ctx, madmin.SRBucketMeta{
- Type: madmin.SRBucketMetaTypeObjectLockConfig,
- Bucket: bucket,
- Tags: latestObjLockConfig,
- UpdatedAt: lastUpdate,
- })
+ err = admClient.SRPeerReplicateBucketMeta(ctx, newSRBucketObjectLockMeta(bucket, latestObjLockConfig, lastUpdate))
if err != nil {
replLogIf(ctx, c.annotatePeerErr(peerName, replicateBucketMetadata,
fmt.Errorf("Unable to heal object lock config metadata for peer %s from peer %s : %w",
diff --git a/docs/security/advisories.md b/docs/security/advisories.md
index 6aa2df3f9..7c7523ca8 100644
--- a/docs/security/advisories.md
+++ b/docs/security/advisories.md
@@ -4,6 +4,14 @@ This document summarizes fork-specific security fixes and closely related upgrad
Entries carry a CVE identifier where one exists. Where none does, they carry a fork-local `SN--` identifier so that a finding without a CVE can still be referenced stably from release notes, commits and issues. An `SN-` identifier is **not** a CVE and is not registered in any vulnerability database; it is deliberately not written in CVE form so that scanners do not mistake it for one. Upstream `minio/minio` is archived, so for findings in inherited code there is no upstream maintainer to coordinate a CVE assignment with. `SN-2026-001` is the streaming-flush regression in `trackingResponseWriter`, which is a reliability defect rather than a security one and is tracked in the release notes rather than here.
+## Inherited upstream advisory baseline
+
+The first Silo community release was cut from upstream history that already contained the following security fix. Upstream and Silo links are both recorded even when the fork preserves the same commit object and SHA; that identity is the inheritance evidence, not a claim that Silo independently reimplemented the patch.
+
+| ID | Upstream remediation | Silo inheritance | Regression evidence | Release / operator note |
+| :-- | :-- | :-- | :-- | :-- |
+| [CVE-2025-62506](https://github.com/advisories/GHSA-jjjj-jwhf-8rgr) | [minio/minio#21642](https://github.com/minio/minio/pull/21642), merged as [`c1a49490`](https://github.com/minio/minio/commit/c1a49490c78e9c3ebcad86ba0662319138ace190) | The same commit object is present as [`pgsty/silo@c1a49490`](https://github.com/pgsty/silo/commit/c1a49490c78e9c3ebcad86ba0662319138ace190) | The inherited [service-account](https://github.com/pgsty/silo/blob/c1a49490c78e9c3ebcad86ba0662319138ace190/cmd/admin-handlers-users_test.go#L211-L212) and [STS](https://github.com/pgsty/silo/blob/c1a49490c78e9c3ebcad86ba0662319138ace190/cmd/sts-handlers_test.go#L45-L46) regression groups run for root and non-root parents through `go test ./cmd` | Resets `DenyOnly` while evaluating a restricted session policy so service or STS accounts cannot mint an unrestricted child service account. Upstream first fixed this in [`RELEASE.2025-10-15T17-29-55Z`](https://github.com/minio/minio/releases/tag/RELEASE.2025-10-15T17-29-55Z); every Silo community release, beginning with [`RELEASE.2025-12-03T12-00-00Z`](https://github.com/pgsty/silo/releases/tag/RELEASE.2025-12-03T12-00-00Z), contains it. Operators migrating from an older upstream build should upgrade and audit service accounts created by restricted service or STS identities. |
+
## Advisories since `RELEASE.2026-03-21T00-00-00Z`
| ID | Fixed by | Affected area | Remote exploitability | Summary | Upgrade / workaround notes |
diff --git a/internal/hash/checksum.go b/internal/hash/checksum.go
index 5131087b8..e4ced2fba 100644
--- a/internal/hash/checksum.go
+++ b/internal/hash/checksum.go
@@ -157,7 +157,6 @@ func ChecksumStringToType(alg string) ChecksumType {
case "SHA256":
return ChecksumSHA256
case "CRC64NVME":
- // AWS seems to ignore full value, and just assume it.
return ChecksumCRC64NVME
case "":
return ChecksumNone
@@ -192,7 +191,9 @@ func NewChecksumType(alg, objType string) ChecksumType {
}
return ChecksumSHA256
case "CRC64NVME":
- // AWS seems to ignore full value, and just assume it.
+ if objType == xhttp.AmzChecksumTypeComposite {
+ return ChecksumInvalid
+ }
return ChecksumCRC64NVME
case "":
if full != 0 {
@@ -657,22 +658,55 @@ func AddChecksumHeader(w http.ResponseWriter, c map[string]string) {
}
}
+func isSupportedChecksumHeader(name string) bool {
+ switch {
+ case strings.EqualFold(name, xhttp.AmzChecksumAlgo),
+ strings.EqualFold(name, xhttp.AmzChecksumType),
+ strings.EqualFold(name, xhttp.AmzChecksumMode):
+ return true
+ }
+ for _, checksumType := range BaseChecksumTypes {
+ if strings.EqualFold(name, checksumType.Key()) {
+ return true
+ }
+ }
+ return false
+}
+
+func hasUnsupportedChecksumHeader(h http.Header) bool {
+ for name := range h {
+ if strings.HasPrefix(strings.ToLower(name), "x-amz-checksum-") && !isSupportedChecksumHeader(name) {
+ return true
+ }
+ }
+ return false
+}
+
// GetContentChecksum returns content checksum.
// Returns ErrInvalidChecksum if so.
// Returns nil, nil if no checksum.
func GetContentChecksum(h http.Header) (*Checksum, error) {
+ if hasUnsupportedChecksumHeader(h) {
+ return nil, ErrInvalidChecksum
+ }
if trailing := h.Values(xhttp.AmzTrailer); len(trailing) > 0 {
var res *Checksum
- for _, header := range trailing {
- var duplicates bool
- for _, t := range BaseChecksumTypes {
- if strings.EqualFold(t.Key(), header) {
- duplicates = res != nil
- res = NewChecksumWithType(t|ChecksumTrailing, "")
+ for _, headers := range trailing {
+ for header := range strings.SplitSeq(headers, ",") {
+ header = strings.TrimSpace(header)
+ var duplicates bool
+ for _, t := range BaseChecksumTypes {
+ if strings.EqualFold(t.Key(), header) {
+ duplicates = res != nil
+ res = NewChecksumWithType(t|ChecksumTrailing, "")
+ }
+ }
+ if strings.HasPrefix(strings.ToLower(header), "x-amz-checksum-") && !isSupportedChecksumHeader(header) {
+ return nil, ErrInvalidChecksum
+ }
+ if duplicates {
+ return nil, ErrInvalidChecksum
}
- }
- if duplicates {
- return nil, ErrInvalidChecksum
}
}
if res != nil {
@@ -682,7 +716,11 @@ func GetContentChecksum(h http.Header) (*Checksum, error) {
return nil, ErrInvalidChecksum
}
res.Type |= ChecksumFullObject
- case xhttp.AmzChecksumTypeComposite, "":
+ case xhttp.AmzChecksumTypeComposite:
+ if res.Type.Base().Is(ChecksumCRC64NVME) {
+ return nil, ErrInvalidChecksum
+ }
+ case "":
default:
return nil, ErrInvalidChecksum
}
@@ -748,5 +786,8 @@ func getContentChecksum(h http.Header) (t ChecksumType, s string) {
for _, t := range BaseChecksumTypes {
checkType(t)
}
+ if t.Base().Is(ChecksumCRC64NVME) && h.Get(xhttp.AmzChecksumType) == xhttp.AmzChecksumTypeComposite {
+ return ChecksumInvalid, ""
+ }
return t, s
}
diff --git a/internal/hash/checksum_test.go b/internal/hash/checksum_test.go
index 504803795..595302818 100644
--- a/internal/hash/checksum_test.go
+++ b/internal/hash/checksum_test.go
@@ -18,14 +18,58 @@
package hash
import (
+ "errors"
+ "net/http"
"net/http/httptest"
"testing"
xhttp "github.com/minio/minio/internal/http"
)
+func TestGetContentChecksumRejectsUnsupportedHeaders(t *testing.T) {
+ unsupported := []string{
+ "x-amz-checksum-md5",
+ "x-amz-checksum-sha512",
+ "x-amz-checksum-xxhash64",
+ "x-amz-checksum-xxhash3",
+ "x-amz-checksum-xxhash128",
+ "x-amz-checksum-future",
+ }
+ for _, header := range unsupported {
+ t.Run("header/"+header, func(t *testing.T) {
+ h := http.Header{header: {"AA=="}}
+ if _, err := GetContentChecksum(h); !errors.Is(err, ErrInvalidChecksum) {
+ t.Fatalf("GetContentChecksum(%s) error = %v, want ErrInvalidChecksum", header, err)
+ }
+ })
+ t.Run("trailer/"+header, func(t *testing.T) {
+ h := http.Header{xhttp.AmzTrailer: {header}}
+ if _, err := GetContentChecksum(h); !errors.Is(err, ErrInvalidChecksum) {
+ t.Fatalf("GetContentChecksum(trailer %s) error = %v, want ErrInvalidChecksum", header, err)
+ }
+ })
+ }
+
+ for header, value := range map[string]string{
+ xhttp.AmzChecksumAlgo: "CRC32",
+ xhttp.AmzChecksumType: xhttp.AmzChecksumTypeComposite,
+ xhttp.AmzChecksumMode: "ENABLED",
+ "x-amz-sdk-checksum-algorithm": "SHA512",
+ } {
+ t.Run("control/"+header, func(t *testing.T) {
+ h := http.Header{header: {value}}
+ if _, err := GetContentChecksum(h); errors.Is(err, ErrInvalidChecksum) {
+ t.Fatalf("control header %s was rejected", header)
+ }
+ })
+ }
+}
+
// TestChecksumAddToHeader tests that adding and retrieving a checksum on a header works
func TestChecksumAddToHeader(t *testing.T) {
+ if got := NewChecksumType("CRC64NVME", xhttp.AmzChecksumTypeComposite); !got.Is(ChecksumInvalid) {
+ t.Fatalf("CRC64NVME/COMPOSITE = %s, want invalid", got.StringFull())
+ }
tests := []struct {
name string
checksum ChecksumType
@@ -106,6 +150,16 @@ func TestChecksumAddToHeader(t *testing.T) {
}
}
+func TestCRC64NVMECompositeTrailerIsInvalid(t *testing.T) {
+ h := http.Header{}
+ h.Set(xhttp.AmzTrailer, ChecksumCRC64NVME.Key())
+ h.Set(xhttp.AmzChecksumType, xhttp.AmzChecksumTypeComposite)
+ _, err := GetContentChecksum(h)
+ if !errors.Is(err, ErrInvalidChecksum) {
+ t.Fatalf("CRC64NVME/COMPOSITE trailer error = %v, want ErrInvalidChecksum", err)
+ }
+}
+
// TestChecksumSerializeDeserialize checks AppendTo can be reversed by ChecksumFromBytes
func TestChecksumSerializeDeserialize(t *testing.T) {
myData := []byte("this-is-a-checksum-data-test")