From 9d04671ee322fe394038f93f8f2e36e92a1bc0da Mon Sep 17 00:00:00 2001 From: adam jones Date: Wed, 27 Aug 2025 23:46:35 +0100 Subject: [PATCH] feat: add namespace blocking functionality for JWT authentication (#313) ## Summary Implements a denylist mechanism to prevent blocked namespaces from publishing packages: - Added `BlockedNamespaces` field to `JWTManager` struct for maintaining the denylist - Added validation logic in `GenerateTokenResponse` that checks user permissions against blocked namespaces - Admins with global permissions (`*`) bypass the blocking logic - Added comprehensive test coverage for all blocking scenarios Fixes #98 --- internal/auth/blocks.go | 9 +++ internal/auth/jwt.go | 18 ++++++ internal/auth/jwt_test.go | 115 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+) create mode 100644 internal/auth/blocks.go diff --git a/internal/auth/blocks.go b/internal/auth/blocks.go new file mode 100644 index 0000000..1598355 --- /dev/null +++ b/internal/auth/blocks.go @@ -0,0 +1,9 @@ +package auth + +// BlockedNamespaces contains a list of namespaces that are not allowed to publish packages. +// This is used as a denylist mechanism to prevent abuse. +var BlockedNamespaces = []string{ + // Add blocked namespaces here, e.g.: + // "io.github.spammer", + // "com.evil-domain", +} \ No newline at end of file diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 114c658..19c97e8 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -72,6 +72,24 @@ func NewJWTManager(cfg *config.Config) *JWTManager { // GenerateToken generates a new Registry JWT token func (j *JWTManager) GenerateTokenResponse(_ context.Context, claims JWTClaims) (*TokenResponse, error) { + // Check whether they have global permissions (used by admins) + hasGlobalPermissions := false + for _, perm := range claims.Permissions { + if perm.ResourcePattern == "*" { + hasGlobalPermissions = true + break + } + } + + // Check permissions against denylist, provided they are not an admin + if !hasGlobalPermissions { + for _, blockedNamespace := range BlockedNamespaces { + if j.HasPermission(blockedNamespace+"/test", PermissionActionPublish, claims.Permissions) { + return nil, fmt.Errorf("your namespace is blocked. raise an issue at https://github.com/modelcontextprotocol/registry/ if you think this is a mistake") + } + } + } + if claims.IssuedAt == nil { claims.IssuedAt = jwt.NewNumericDate(time.Now()) } diff --git a/internal/auth/jwt_test.go b/internal/auth/jwt_test.go index 8aaf0ee..42615da 100644 --- a/internal/auth/jwt_test.go +++ b/internal/auth/jwt_test.go @@ -283,3 +283,118 @@ func TestNewJWTManager_InvalidKeySize(t *testing.T) { auth.NewJWTManager(cfg) }) } + +func TestJWTManager_BlockedNamespaces(t *testing.T) { + // Generate a proper Ed25519 seed for testing + testSeed := make([]byte, ed25519.SeedSize) + _, err := rand.Read(testSeed) + require.NoError(t, err) + + cfg := &config.Config{ + JWTPrivateKey: hex.EncodeToString(testSeed), + } + + ctx := context.Background() + + t.Run("blocked namespace should deny token", func(t *testing.T) { + // Temporarily override blocked namespaces for testing + originalBlocked := auth.BlockedNamespaces + auth.BlockedNamespaces = []string{"io.github.spammer"} + defer func() { auth.BlockedNamespaces = originalBlocked }() + + jwtManager := auth.NewJWTManager(cfg) + + claims := auth.JWTClaims{ + AuthMethod: model.AuthMethodGitHubAT, + AuthMethodSubject: "spammer", + Permissions: []auth.Permission{ + { + Action: auth.PermissionActionPublish, + ResourcePattern: "io.github.spammer/*", + }, + }, + } + + tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims) + assert.Error(t, err) + assert.Contains(t, err.Error(), "your namespace is blocked") + assert.Nil(t, tokenResponse) + }) + + t.Run("non-blocked namespace should allow token", func(t *testing.T) { + // Temporarily override blocked namespaces for testing + originalBlocked := auth.BlockedNamespaces + auth.BlockedNamespaces = []string{"io.github.spammer"} + defer func() { auth.BlockedNamespaces = originalBlocked }() + + jwtManager := auth.NewJWTManager(cfg) + + claims := auth.JWTClaims{ + AuthMethod: model.AuthMethodGitHubAT, + AuthMethodSubject: "gooduser", + Permissions: []auth.Permission{ + { + Action: auth.PermissionActionPublish, + ResourcePattern: "io.github.gooduser/*", + }, + }, + } + + tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims) + require.NoError(t, err) + assert.NotEmpty(t, tokenResponse.RegistryToken) + }) + + t.Run("multiple permissions with one blocked should deny token", func(t *testing.T) { + // Temporarily override blocked namespaces for testing + originalBlocked := auth.BlockedNamespaces + auth.BlockedNamespaces = []string{"io.github.badorg"} + defer func() { auth.BlockedNamespaces = originalBlocked }() + + jwtManager := auth.NewJWTManager(cfg) + + claims := auth.JWTClaims{ + AuthMethod: model.AuthMethodGitHubAT, + AuthMethodSubject: "user", + Permissions: []auth.Permission{ + { + Action: auth.PermissionActionPublish, + ResourcePattern: "io.github.user/*", // allowed + }, + { + Action: auth.PermissionActionPublish, + ResourcePattern: "io.github.badorg/*", // blocked + }, + }, + } + + tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims) + assert.Error(t, err) + assert.Contains(t, err.Error(), "your namespace is blocked") + assert.Nil(t, tokenResponse) + }) + + t.Run("global admin permissions should bypass denylist", func(t *testing.T) { + // Temporarily override blocked namespaces for testing + originalBlocked := auth.BlockedNamespaces + auth.BlockedNamespaces = []string{"io.github.spammer"} + defer func() { auth.BlockedNamespaces = originalBlocked }() + + jwtManager := auth.NewJWTManager(cfg) + + claims := auth.JWTClaims{ + AuthMethod: model.AuthMethodNone, + AuthMethodSubject: "admin", + Permissions: []auth.Permission{ + { + Action: auth.PermissionActionPublish, + ResourcePattern: "*", // global permission should bypass blocking + }, + }, + } + + tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims) + require.NoError(t, err) + assert.NotEmpty(t, tokenResponse.RegistryToken) + }) +}