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 <noreply@anthropic.com>

* security: test that scoped filer tokens fail the admin token check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chris LuandClaude Opus 5.5 authored and GitHub committed 2026-11-10 21:18:59 -08:00
1 parent 2143761319
commit 8d96985131
3 files changed
+21 -2

No files matched your search

+3 -2
View File
@@ -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
+15
View File
@@ -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 "):]}, ""},
+3
View File
@@ -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
}