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:
parent
6431f23107
commit
9d04671ee3
3 changed files with 142 additions and 0 deletions
9
internal/auth/blocks.go
Normal file
9
internal/auth/blocks.go
Normal 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",
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue