From 8d969851317ffeb13c15e8133bc388dcb2aaa46e Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Tue, 10 Nov 2026 21:18:59 -0800 Subject: [PATCH] security: scoped filer tokens are not admin credentials (#12551) * security: do not accept a path- or method-scoped filer token as admin Co-Authored-By: Claude Opus 5.5 * security: test that scoped filer tokens fail the admin token check Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- weed/security/grpc_peer.go | 5 +++-- weed/security/grpc_peer_test.go | 15 +++++++++++++++ weed/security/jwt.go | 3 +++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/weed/security/grpc_peer.go b/weed/security/grpc_peer.go index 268d09edcf..4d6a64c928 100644 --- a/weed/security/grpc_peer.go +++ b/weed/security/grpc_peer.go @@ -63,7 +63,8 @@ func CheckGrpcAdminToken(ctx context.Context, signingKey SigningKey) error { } // IsFilerAdminToken reports whether token is an admin token signed by -// signingKey. An STS session token signed with the same key does not count. +// signingKey. An STS session token, or a filer token scoped to paths or +// methods, signed with the same key does not count. func IsFilerAdminToken(signingKey SigningKey, token EncodedJwt) bool { if len(signingKey) == 0 || token == "" { return false @@ -73,7 +74,7 @@ func IsFilerAdminToken(signingKey SigningKey, token EncodedJwt) bool { return false } claims, ok := parsed.Claims.(*SeaweedFilerAdminClaims) - return ok && claims.SessionId == "" + return ok && claims.SessionId == "" && len(claims.AllowedPrefixes) == 0 && len(claims.AllowedMethods) == 0 } // GrpcAdminPeerCredentials returns outgoing metadata carrying a Bearer token diff --git a/weed/security/grpc_peer_test.go b/weed/security/grpc_peer_test.go index c67383c478..7b7a682b63 100644 --- a/weed/security/grpc_peer_test.go +++ b/weed/security/grpc_peer_test.go @@ -87,6 +87,17 @@ func TestCheckGrpcAdminToken(t *testing.T) { if signErr != nil { t.Fatalf("sign session token: %v", signErr) } + scoped := func(claims SeaweedFilerClaims) string { + claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Hour)) + signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(key)) + if err != nil { + t.Fatalf("sign scoped token: %v", err) + } + return "Bearer " + signed + } + pathScoped := scoped(SeaweedFilerClaims{AllowedPrefixes: []string{"/tenants/t1"}}) + methodScoped := scoped(SeaweedFilerClaims{AllowedMethods: []string{"GET"}}) + unscoped := scoped(SeaweedFilerClaims{}) for name, tc := range map[string]struct { values []string @@ -98,6 +109,10 @@ func TestCheckGrpcAdminToken(t *testing.T) { "only expired": {[]string{"Bearer " + expired}, "invalid admin token"}, "only stale": {[]string{stale}, "invalid admin token"}, "same-key session": {[]string{"Bearer " + session}, "invalid admin token"}, + "path-scoped": {[]string{pathScoped}, "invalid admin token"}, + "method-scoped": {[]string{methodScoped}, "invalid admin token"}, + "scoped then fresh": {[]string{pathScoped, fresh}, ""}, + "unscoped filer": {[]string{unscoped}, ""}, "basic then stale": {[]string{"Basic dXNlcjpwYXNz", stale}, "invalid admin token"}, "no bearer value": {[]string{"Basic dXNlcjpwYXNz", "garbage"}, "authorization header must use Bearer scheme"}, "lowercase scheme ok": {[]string{"bearer " + fresh[len("Bearer "):]}, ""}, diff --git a/weed/security/jwt.go b/weed/security/jwt.go index 30b02db3e1..97fb9ac9fc 100644 --- a/weed/security/jwt.go +++ b/weed/security/jwt.go @@ -52,6 +52,9 @@ type SeaweedFilerAdminClaims struct { // SessionId is present only on STS session tokens; a token carrying it is // an S3 session credential, not a filer admin credential. SessionId string `json:"sid,omitempty"` + // A filer token scoped to paths or methods is not an admin credential. + AllowedPrefixes []string `json:"allowed_prefixes,omitempty"` + AllowedMethods []string `json:"allowed_methods,omitempty"` jwt.RegisteredClaims }