From 47cd7807d37e9bf0006ff3da5d6679cd0f208a25 Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Thu, 27 Aug 2026 10:23:33 +0800 Subject: [PATCH 1/6] test: align federated client version Signed-off-by: Feng Ruohang --- cmd/object-multipart-federation-checksum_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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)() From 38ed9d1e1fb23b0db05b9b507b893c26809a9cee Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Thu, 27 Aug 2026 10:23:51 +0800 Subject: [PATCH 2/6] docs(security): record inherited upstream advisory Signed-off-by: Feng Ruohang --- SECURITY.md | 19 +++++++++++++++++++ docs/security/advisories.md | 8 ++++++++ 2 files changed, 27 insertions(+) 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/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 | From 229fe2b3c3d38345927992c6ab2d81bc5201f0a8 Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Fri, 28 Aug 2026 08:56:58 +0800 Subject: [PATCH 3/6] fix: authorize group status changes by target status Signed-off-by: Feng Ruohang --- cmd/admin-handlers-users.go | 17 +++-- cmd/admin-handlers-users_test.go | 121 +++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 5 deletions(-) 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() From 7e079ff05c842afabb73c4572885cfb74604df17 Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Fri, 28 Aug 2026 08:57:06 +0800 Subject: [PATCH 4/6] fix: validate explicit multipart checksum type Signed-off-by: Feng Ruohang --- cmd/erasure-multipart-fullobject_test.go | 66 ++++++++++++++++++++++++ cmd/erasure-multipart.go | 14 +++-- cmd/object-api-interface.go | 4 +- cmd/object-api-options.go | 8 ++- 4 files changed, 86 insertions(+), 6 deletions(-) diff --git a/cmd/erasure-multipart-fullobject_test.go b/cmd/erasure-multipart-fullobject_test.go index 0bcb14c4f..b9d71aa21 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, diff --git a/cmd/erasure-multipart.go b/cmd/erasure-multipart.go index 222c458b5..d99492368 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,16 @@ 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 != "" { + providedObjectType := opts.wantChecksumType + // CRC64NVME is always canonicalized to FULL_OBJECT. Preserve this + // behavior until its exact AWS wire semantics have been probed. + if checksumType.Base().Is(hash.ChecksumCRC64NVME) { + providedObjectType = xhttp.AmzChecksumTypeFullObject + } + if providedObjectType != expectedType.ObjType() { + return oi, completeMultipartChecksumTypeMismatch(opts.wantChecksumType, expectedType.ObjType()) } } checksumType |= hash.ChecksumMultipart | hash.ChecksumIncludesMultipart 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..0d496e811 100644 --- a/cmd/object-api-options.go +++ b/cmd/object-api-options.go @@ -469,11 +469,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 From e73436c99d65e9b40cde2149111f62e0b0907aae Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Fri, 28 Aug 2026 08:57:15 +0800 Subject: [PATCH 5/6] fix: decrypt CopyObject checksums with destination key Signed-off-by: Feng Ruohang --- cmd/api-response.go | 3 +-- cmd/object-copy-checksum_test.go | 38 ++++++++++++++++++++++++++++++++ cmd/object-copy-metadata_test.go | 2 ++ cmd/object-handlers-common.go | 6 ++++- cmd/object-handlers.go | 14 ++++++++++-- 5 files changed, 58 insertions(+), 5 deletions(-) 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/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..399f192ee 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) 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..649136227 100644 --- a/cmd/object-handlers.go +++ b/cmd/object-handlers.go @@ -1107,6 +1107,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 @@ -1816,14 +1824,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. From 2aea7fe9c48008e4ba438b9e6e6bb29de9b3de40 Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Fri, 28 Aug 2026 08:57:22 +0800 Subject: [PATCH 6/6] fix: preserve named targets in config environment files Signed-off-by: Feng Ruohang --- cmd/common-main.go | 11 +++------ cmd/common-main_test.go | 54 ++++++++++++++++++++++++++++++++--------- 2 files changed, 46 insertions(+), 19 deletions(-) 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) + } +}