Skip to content

Commit 9d04671

Browse files
authored
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
1 parent 6431f23 commit 9d04671

File tree

3 files changed

+142
-0
lines changed

3 files changed

+142
-0
lines changed

internal/auth/blocks.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package auth
2+
3+
// BlockedNamespaces contains a list of namespaces that are not allowed to publish packages.
4+
// This is used as a denylist mechanism to prevent abuse.
5+
var BlockedNamespaces = []string{
6+
// Add blocked namespaces here, e.g.:
7+
// "io.github.spammer",
8+
// "com.evil-domain",
9+
}

internal/auth/jwt.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,24 @@ func NewJWTManager(cfg *config.Config) *JWTManager {
7272

7373
// GenerateToken generates a new Registry JWT token
7474
func (j *JWTManager) GenerateTokenResponse(_ context.Context, claims JWTClaims) (*TokenResponse, error) {
75+
// Check whether they have global permissions (used by admins)
76+
hasGlobalPermissions := false
77+
for _, perm := range claims.Permissions {
78+
if perm.ResourcePattern == "*" {
79+
hasGlobalPermissions = true
80+
break
81+
}
82+
}
83+
84+
// Check permissions against denylist, provided they are not an admin
85+
if !hasGlobalPermissions {
86+
for _, blockedNamespace := range BlockedNamespaces {
87+
if j.HasPermission(blockedNamespace+"/test", PermissionActionPublish, claims.Permissions) {
88+
return nil, fmt.Errorf("your namespace is blocked. raise an issue at https://github.com/modelcontextprotocol/registry/ if you think this is a mistake")
89+
}
90+
}
91+
}
92+
7593
if claims.IssuedAt == nil {
7694
claims.IssuedAt = jwt.NewNumericDate(time.Now())
7795
}

internal/auth/jwt_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,3 +283,118 @@ func TestNewJWTManager_InvalidKeySize(t *testing.T) {
283283
auth.NewJWTManager(cfg)
284284
})
285285
}
286+
287+
func TestJWTManager_BlockedNamespaces(t *testing.T) {
288+
// Generate a proper Ed25519 seed for testing
289+
testSeed := make([]byte, ed25519.SeedSize)
290+
_, err := rand.Read(testSeed)
291+
require.NoError(t, err)
292+
293+
cfg := &config.Config{
294+
JWTPrivateKey: hex.EncodeToString(testSeed),
295+
}
296+
297+
ctx := context.Background()
298+
299+
t.Run("blocked namespace should deny token", func(t *testing.T) {
300+
// Temporarily override blocked namespaces for testing
301+
originalBlocked := auth.BlockedNamespaces
302+
auth.BlockedNamespaces = []string{"io.github.spammer"}
303+
defer func() { auth.BlockedNamespaces = originalBlocked }()
304+
305+
jwtManager := auth.NewJWTManager(cfg)
306+
307+
claims := auth.JWTClaims{
308+
AuthMethod: model.AuthMethodGitHubAT,
309+
AuthMethodSubject: "spammer",
310+
Permissions: []auth.Permission{
311+
{
312+
Action: auth.PermissionActionPublish,
313+
ResourcePattern: "io.github.spammer/*",
314+
},
315+
},
316+
}
317+
318+
tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims)
319+
assert.Error(t, err)
320+
assert.Contains(t, err.Error(), "your namespace is blocked")
321+
assert.Nil(t, tokenResponse)
322+
})
323+
324+
t.Run("non-blocked namespace should allow token", func(t *testing.T) {
325+
// Temporarily override blocked namespaces for testing
326+
originalBlocked := auth.BlockedNamespaces
327+
auth.BlockedNamespaces = []string{"io.github.spammer"}
328+
defer func() { auth.BlockedNamespaces = originalBlocked }()
329+
330+
jwtManager := auth.NewJWTManager(cfg)
331+
332+
claims := auth.JWTClaims{
333+
AuthMethod: model.AuthMethodGitHubAT,
334+
AuthMethodSubject: "gooduser",
335+
Permissions: []auth.Permission{
336+
{
337+
Action: auth.PermissionActionPublish,
338+
ResourcePattern: "io.github.gooduser/*",
339+
},
340+
},
341+
}
342+
343+
tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims)
344+
require.NoError(t, err)
345+
assert.NotEmpty(t, tokenResponse.RegistryToken)
346+
})
347+
348+
t.Run("multiple permissions with one blocked should deny token", func(t *testing.T) {
349+
// Temporarily override blocked namespaces for testing
350+
originalBlocked := auth.BlockedNamespaces
351+
auth.BlockedNamespaces = []string{"io.github.badorg"}
352+
defer func() { auth.BlockedNamespaces = originalBlocked }()
353+
354+
jwtManager := auth.NewJWTManager(cfg)
355+
356+
claims := auth.JWTClaims{
357+
AuthMethod: model.AuthMethodGitHubAT,
358+
AuthMethodSubject: "user",
359+
Permissions: []auth.Permission{
360+
{
361+
Action: auth.PermissionActionPublish,
362+
ResourcePattern: "io.github.user/*", // allowed
363+
},
364+
{
365+
Action: auth.PermissionActionPublish,
366+
ResourcePattern: "io.github.badorg/*", // blocked
367+
},
368+
},
369+
}
370+
371+
tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims)
372+
assert.Error(t, err)
373+
assert.Contains(t, err.Error(), "your namespace is blocked")
374+
assert.Nil(t, tokenResponse)
375+
})
376+
377+
t.Run("global admin permissions should bypass denylist", func(t *testing.T) {
378+
// Temporarily override blocked namespaces for testing
379+
originalBlocked := auth.BlockedNamespaces
380+
auth.BlockedNamespaces = []string{"io.github.spammer"}
381+
defer func() { auth.BlockedNamespaces = originalBlocked }()
382+
383+
jwtManager := auth.NewJWTManager(cfg)
384+
385+
claims := auth.JWTClaims{
386+
AuthMethod: model.AuthMethodNone,
387+
AuthMethodSubject: "admin",
388+
Permissions: []auth.Permission{
389+
{
390+
Action: auth.PermissionActionPublish,
391+
ResourcePattern: "*", // global permission should bypass blocking
392+
},
393+
},
394+
}
395+
396+
tokenResponse, err := jwtManager.GenerateTokenResponse(ctx, claims)
397+
require.NoError(t, err)
398+
assert.NotEmpty(t, tokenResponse.RegistryToken)
399+
})
400+
}

0 commit comments

Comments
 (0)