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
|
// GenerateToken generates a new Registry JWT token
|
||||||
func (j *JWTManager) GenerateTokenResponse(_ context.Context, claims JWTClaims) (*TokenResponse, error) {
|
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 {
|
if claims.IssuedAt == nil {
|
||||||
claims.IssuedAt = jwt.NewNumericDate(time.Now())
|
claims.IssuedAt = jwt.NewNumericDate(time.Now())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -283,3 +283,118 @@ func TestNewJWTManager_InvalidKeySize(t *testing.T) {
|
||||||
auth.NewJWTManager(cfg)
|
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