diff --git a/cmd/object-attributes-ssec-authz_test.go b/cmd/object-attributes-ssec-authz_test.go new file mode 100644 index 000000000..b18f28305 --- /dev/null +++ b/cmd/object-attributes-ssec-authz_test.go @@ -0,0 +1,139 @@ +// Copyright (c) 2015-2025 MinIO, Inc. +// Copyright (c) 2025-2026 PGSTY +// +// This file is part of MinIO Object Storage stack +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package cmd + +import ( + "bytes" + "crypto/md5" + "encoding/base64" + "net/http" + "strings" + "testing" + + "github.com/minio/madmin-go/v3" + "github.com/minio/minio/internal/auth" + xhttp "github.com/minio/minio/internal/http" + "github.com/minio/pkg/v3/policy" +) + +// GetObjectAttributes lets a replication peer read SSE-C attributes without +// presenting the customer key. The X-Minio-Source-Replication-Request header +// that marks such a request is client controlled, so the carve-out has to be +// gated on the caller actually holding s3:ReplicateObject. Root credentials +// hold every action, so a root-only test cannot tell the gate apart from an +// ungated header check; these cases drive it with least-privilege users. +func TestAPIGetObjectAttributesSSECReplicationAuthz(t *testing.T) { + defer DetectTestLeak(t)() + ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{ + t: t, + objAPITest: testAPIGetObjectAttributesSSECReplicationAuthz, + }) +} + +func testAPIGetObjectAttributesSSECReplicationAuthz(_ 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) + sseHeaders := map[string]string{ + xhttp.AmzServerSideEncryptionCustomerAlgorithm: xhttp.AmzEncryptionAES, + xhttp.AmzServerSideEncryptionCustomerKey: base64.StdEncoding.EncodeToString(key), + xhttp.AmzServerSideEncryptionCustomerKeyMD5: base64.StdEncoding.EncodeToString(keyMD5[:]), + } + replicationHeader := map[string]string{xhttp.MinIOSourceReplicationRequest: "true"} + + object := "attributes/ssec-replication-authz" + putCopyChecksumSource(t, apiRouter, credentials, bucketName, object, + bytes.Repeat([]byte("attributes-authz-"), 512), sseHeaders) + + // s3:GetObjectAttributes and s3:GetObject reach the handler; only the + // second policy adds the replication action the carve-out requires. + readerOnly := newObjectAttributesAuthzUser(t, instanceType, bucketName, + `"s3:GetObject","s3:GetObjectAttributes"`) + replicator := newObjectAttributesAuthzUser(t, instanceType, bucketName, + `"s3:GetObject","s3:GetObjectAttributes","s3:ReplicateObject"`) + + for _, test := range []struct { + name string + creds auth.Credentials + headers map[string]string + want int + }{ + // The header alone must not stand in for s3:ReplicateObject. + {name: "reader/replication-header-only", creds: readerOnly, headers: replicationHeader, want: http.StatusBadRequest}, + // A caller that may replicate this object keeps the carve-out. + {name: "replicator/replication-header-only", creds: replicator, headers: replicationHeader, want: http.StatusOK}, + // Root holds s3:ReplicateObject, so its carve-out is unchanged. + {name: "root/replication-header-only", creds: credentials, headers: replicationHeader, want: http.StatusOK}, + // The ordinary key-bearing path is unaffected for every caller. + {name: "reader/correct-key", creds: readerOnly, headers: sseHeaders, want: http.StatusOK}, + {name: "reader/no-key", creds: readerOnly, want: http.StatusBadRequest}, + } { + t.Run(test.name, func(t *testing.T) { + rec := objectAttributesSSECRequest(t, apiRouter, test.creds, bucketName, object, test.headers) + if rec.Code != test.want { + t.Fatalf("%s: status %d, want %d: %s", instanceType, rec.Code, test.want, rec.Body.String()) + } + }) + } +} + +// newObjectAttributesAuthzUser installs a least-privilege user whose policy +// grants exactly the listed actions on bucketName's objects. +func newObjectAttributesAuthzUser(t *testing.T, instanceType, bucketName, actions string) auth.Credentials { + t.Helper() + ctx := t.Context() + + accessKey, secretKey, err := auth.GenerateCredentials() + if err != nil { + t.Fatalf("%s: generate credentials: %v", instanceType, err) + } + creds := auth.Credentials{AccessKey: accessKey, SecretKey: secretKey} + if _, err = globalIAMSys.CreateUser(ctx, creds.AccessKey, madmin.AddOrUpdateUserReq{ + SecretKey: creds.SecretKey, + Status: madmin.AccountEnabled, + }); err != nil { + t.Fatalf("%s: create attributes user: %v", instanceType, err) + } + + policyJSON := `{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": [` + actions + `], + "Resource": ["arn:aws:s3:::` + bucketName + `/*"] + }] +}` + parsed, err := policy.ParseConfig(strings.NewReader(policyJSON)) + if err != nil { + t.Fatalf("%s: parse attributes policy: %v", instanceType, err) + } + policyName := "attributes-authz-" + mustGetUUID() + if _, err = globalIAMSys.SetPolicy(ctx, policyName, *parsed); err != nil { + t.Fatalf("%s: install attributes policy: %v", instanceType, err) + } + if _, err = globalIAMSys.PolicyDBSet(ctx, creds.AccessKey, policyName, regUser, false); err != nil { + t.Fatalf("%s: attach attributes policy: %v", instanceType, err) + } + return creds +} diff --git a/cmd/object-handlers.go b/cmd/object-handlers.go index bf2c9b5e3..cd4917dd5 100644 --- a/cmd/object-handlers.go +++ b/cmd/object-handlers.go @@ -619,7 +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" { + // Only a caller authorized to replicate this object may read SSE-C + // attributes without presenting the customer key. The header alone is + // client controlled, so it cannot stand in for that authorization. + trustedReplicationRequest := r.Header.Get(xhttp.MinIOSourceReplicationRequest) == "true" && + checkRequestAuthType(ctx, r, policy.ReplicateObjectAction, bucket, object) == ErrNone + if crypto.SSEC.IsEncrypted(objInfo.UserDefined) && !trustedReplicationRequest { if _, err = crypto.SSEC.UnsealObjectKey(r.Header, objInfo.UserDefined, bucket, object); err != nil { writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) return