mirror of
https://github.com/pgsty/minio.git
synced 2026-09-05 18:16:16 +03:00
Merge remote-tracking branch 'origin/main' into codex/issue-50-after-checksum-contract
# Conflicts: # cmd/erasure-multipart-fullobject_test.go
This commit is contained in:
+19
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
+1
-2
@@ -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()),
|
||||
|
||||
+4
-7
@@ -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 == "" {
|
||||
|
||||
+41
-11
@@ -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: "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: "hyphenated name",
|
||||
content: "MINIO-ROOT-USER=hyphen-secret",
|
||||
errLine: 1,
|
||||
errContains: `invalid environment variable name "MINIO-ROOT-USER"`,
|
||||
errExcludes: "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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -420,7 +420,73 @@ 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"
|
||||
@@ -467,6 +533,23 @@ func testAPICompleteMultipartChecksumTypeMismatch(obj ObjectLayer, instanceType,
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestAPICompleteMultipartFullObjectVariants pins down the surrounding
|
||||
|
||||
@@ -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
|
||||
|
||||
+12
-2
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -85,7 +85,7 @@ type ObjectOptions struct {
|
||||
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.
|
||||
wantChecksumType string // explicit x-amz-checksum-type value on CompleteMultipartUpload.
|
||||
|
||||
WantServerSideChecksumType hash.ChecksumType // if set, we compute a server-side checksum of this type
|
||||
|
||||
|
||||
@@ -472,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
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+77
-10
@@ -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.
|
||||
|
||||
@@ -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)()
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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-<year>-<sequence>` 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 |
|
||||
|
||||
Reference in New Issue
Block a user