fix(s3): allow anonymous ListBuckets with prefix-scoped List action (#9073)

* fix(s3): allow anonymous ListBuckets with prefix-scoped List action

An anonymous identity holding a prefix-scoped action such as
"List:prefix-*" was denied at the auth middleware before ListBucketsHandler
could apply the per-bucket visibility check. The middleware called
CanDo with an empty bucket, which never matches a scoped action, so
every anonymous ListBuckets request returned 403 even though matching
buckets should have been visible.

Defer ListBuckets authorization to the handler for the anonymous
identity when it actually carries a List action, mirroring the
existing behavior for authenticated users. Anonymous identities with
no List action continue to be rejected at the global layer, preserving
the secure-by-default posture.

Fixes #9072

* refactor(s3): make hasListAction a method on Identity

Addresses PR review — consistent with existing CanDo/isAdmin methods and
also treats Admin identities as implicitly having List permission.
This commit is contained in:
Chris Lu
2026-04-14 10:52:00 -07:00
committed by GitHub
parent eaf561e86c
commit 40c1797f8e
2 changed files with 58 additions and 2 deletions
+31 -2
View File
@@ -1413,8 +1413,14 @@ func (iam *IdentityAccessManagement) authRequestWithAuthType(r *http.Request, ac
// through buckets and checking permissions for each. Skip the global check here.
policyAllows := false
if action == s3_constants.ACTION_LIST && bucket == "" && identity.Name != s3_constants.AccountAnonymousId {
// ListBuckets operation for authenticated users - authorization handled per-bucket in the handler
if action == s3_constants.ACTION_LIST && bucket == "" &&
(identity.Name != s3_constants.AccountAnonymousId || identity.hasListAction()) {
// ListBuckets operation - authorization handled per-bucket in the handler.
// For authenticated users this is always deferred to the handler. For the
// anonymous identity we only defer when it actually carries a List action
// (e.g. "List:prefix-*"); otherwise fall through to the global check so
// an anonymous caller with no permissions is rejected outright rather
// than receiving an empty ListAllMyBuckets result.
} else {
// First check bucket policy if one exists
// Bucket policies can grant or deny access to specific users/principals
@@ -1590,6 +1596,29 @@ func (identity *Identity) CanDo(action Action, bucket string, objectKey string)
return false
}
// hasListAction reports whether the identity carries any List-scoped legacy
// action (e.g. "List", "List:*", "List:prefix-*") or has administrative
// privileges. Used to decide whether a ListBuckets request from an anonymous
// identity should be deferred to the per-bucket check in the handler or
// denied at the global auth layer.
func (identity *Identity) hasListAction() bool {
if identity == nil {
return false
}
if identity.isAdmin() {
return true
}
listPrefix := string(s3_constants.ACTION_LIST)
listPrefixWithColon := listPrefix + ":"
for _, a := range identity.Actions {
act := string(a)
if act == listPrefix || strings.HasPrefix(act, listPrefixWithColon) {
return true
}
}
return false
}
func (identity *Identity) isAdmin() bool {
if identity == nil {
return false
+27
View File
@@ -132,6 +132,33 @@ func TestCheckaAnonymousRequestAuthType(t *testing.T) {
}
// TestAnonymousListBucketsWithPrefixAction verifies that an anonymous identity
// holding a prefix-scoped List action (e.g. "List:prefix-*") is not denied at
// the global auth layer when issuing ListBuckets. The per-bucket permission
// check happens inside ListBucketsHandler, so the global auth step must let
// the request through.
//
// Regression test for https://github.com/seaweedfs/seaweedfs/issues/9072
func TestAnonymousListBucketsWithPrefixAction(t *testing.T) {
iam := &IdentityAccessManagement{
hashes: make(map[string]*sync.Pool),
hashCounters: make(map[string]*int32),
}
_ = iam.loadS3ApiConfiguration(&iam_pb.S3ApiConfiguration{
Identities: []*iam_pb.Identity{
{
Name: s3_constants.AccountAnonymousId,
Actions: []string{"Read:prefix-*", "List:prefix-*"},
},
},
})
req := mustNewRequest(http.MethodGet, "http://127.0.0.1:9000/", 0, nil, t)
if _, s3Error := iam.authRequest(req, s3_constants.ACTION_LIST); s3Error != s3err.ErrNone {
t.Errorf("anonymous ListBuckets with prefix-scoped List action: want ErrNone, got %d", s3Error)
}
}
func TestCheckAdminRequestAuthType(t *testing.T) {
iam := &IdentityAccessManagement{
hashes: make(map[string]*sync.Pool),