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
This commit is contained in:
adam jones 2025-08-27 23:46:35 +01:00 • committed by GitHub
commit 9d04671ee3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 142 additions and 0 deletions

9
internal/auth/blocks.go Normal file
View file

@ -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",
}

View file

@ -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())
}

View file

@ -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)
})
}