CVE-2026-44428
Description
The MCP Registry provides MCP clients with a list of MCP servers, like an app store for MCP servers. Prior to 1.7.6, the client-side and server-side GitHub OIDC flow is bound only to a global audience string, not to the specific registry instance being targeted. On the client side, the publisher always appends audience=mcp-registry when requesting the GitHub Actions ID token, regardless of the selected --registry URL. On the server side, the exchange endpoint validates only that same fixed audience and then derives publish permissions directly from repository_owner. As a result, a token legitimately obtained while interacting with one registry deployment remains acceptable to any other deployment that shares the same code and audience string. This vulnerability is fixed in 1.7.6.
Affected products
1- Range: < 1.7.6
Patches
13f89fc2b1fb3auth: bind GitHub OIDC token exchange to a per-deployment audience (#1229)
6 files changed · +166 −8
cmd/publisher/auth/github-oidc.go+27 −4 modified@@ -7,7 +7,9 @@ import ( "fmt" "io" "net/http" + "net/url" "os" + "strings" ) type GitHubOIDCProvider struct { @@ -23,8 +25,13 @@ func NewGitHubOIDCProvider(registryURL string) Provider { // GetToken retrieves the registry JWT token using GitHub Actions OIDC token func (o *GitHubOIDCProvider) GetToken(ctx context.Context) (string, error) { + audience, err := audienceFromRegistryURL(o.registryURL) + if err != nil { + return "", fmt.Errorf("invalid --registry URL: %w", err) + } + // Get OIDC token from GitHub Actions endpoint - oidcToken, err := o.getOIDCTokenFromGitHub(ctx) + oidcToken, err := o.getOIDCTokenFromGitHub(ctx, audience) if err != nil { return "", fmt.Errorf("failed to get OIDC token from GitHub: %w", err) } @@ -99,8 +106,8 @@ func (o *GitHubOIDCProvider) exchangeOIDCTokenForRegistry(ctx context.Context, o return tokenResp.RegistryToken, nil } -// getOIDCTokenFromGitHub fetches the OIDC token from GitHub Actions endpoint -func (o *GitHubOIDCProvider) getOIDCTokenFromGitHub(ctx context.Context) (string, error) { +// getOIDCTokenFromGitHub fetches the OIDC token from GitHub Actions endpoint. +func (o *GitHubOIDCProvider) getOIDCTokenFromGitHub(ctx context.Context, audience string) (string, error) { // Check for required environment variables requestToken := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") if requestToken == "" { @@ -113,7 +120,7 @@ func (o *GitHubOIDCProvider) getOIDCTokenFromGitHub(ctx context.Context) (string } // Build the full URL with audience parameter - fullURL := requestURL + "&audience=mcp-registry" + fullURL := requestURL + "&audience=" + url.QueryEscape(audience) // Create the request req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) //nolint:gosec // G704: URL is from GitHub Actions ACTIONS_ID_TOKEN_REQUEST_URL env var @@ -158,3 +165,19 @@ func (o *GitHubOIDCProvider) getOIDCTokenFromGitHub(ctx context.Context) (string return tokenResp.Value, nil } + +// audienceFromRegistryURL returns scheme + lowercased host for the given URL. +func audienceFromRegistryURL(registryURL string) (string, error) { + registryURL = strings.TrimSpace(registryURL) + if registryURL == "" { + return "", fmt.Errorf("registry URL is empty") + } + u, err := url.Parse(registryURL) + if err != nil { + return "", fmt.Errorf("parse %q: %w", registryURL, err) + } + if u.Scheme == "" || u.Host == "" { + return "", fmt.Errorf("registry URL must include scheme and host: %q", registryURL) + } + return u.Scheme + "://" + strings.ToLower(u.Host), nil +}
cmd/publisher/auth/github-oidc_internal_test.go+48 −0 added@@ -0,0 +1,48 @@ +package auth + +import "testing" + +func TestAudienceFromRegistryURL(t *testing.T) { + tests := []struct { + input string + want string + wantErr bool + }{ + // Canonical forms + {"https://registry.modelcontextprotocol.io", "https://registry.modelcontextprotocol.io", false}, + {"https://staging.registry.modelcontextprotocol.io", "https://staging.registry.modelcontextprotocol.io", false}, + + // Trailing slash and path are stripped + {"https://registry.modelcontextprotocol.io/", "https://registry.modelcontextprotocol.io", false}, + {"https://registry.modelcontextprotocol.io/api/v0", "https://registry.modelcontextprotocol.io", false}, + + // Host is lowercased + {"https://Registry.Example.COM", "https://registry.example.com", false}, + + // Whitespace is tolerated + {" https://registry.example ", "https://registry.example", false}, + + // Invalid inputs + {"", "", true}, + {"registry.example", "", true}, // missing scheme + {"https://", "", true}, // missing host + {"://nothing", "", true}, // missing scheme + } + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + got, err := audienceFromRegistryURL(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error for %q, got %q", tc.input, got) + } + return + } + if err != nil { + t.Fatalf("unexpected error for %q: %v", tc.input, err) + } + if got != tc.want { + t.Errorf("audienceFromRegistryURL(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +}
deploy/pkg/k8s/registry.go+9 −0 modified@@ -185,6 +185,15 @@ func DeployMCPRegistry(ctx *pulumi.Context, cluster *providers.ProviderInfo, env Name: pulumi.String("MCP_REGISTRY_OIDC_PUBLISH_PERMISSIONS"), Value: pulumi.String("*"), }, + &corev1.EnvVarArgs{ + Name: pulumi.String("MCP_REGISTRY_GITHUB_OIDC_AUDIENCE"), + Value: pulumi.String(func() string { + if environment == "prod" { + return "https://registry.modelcontextprotocol.io" + } + return "https://" + environment + ".registry.modelcontextprotocol.io" + }()), + }, }, // StartupProbe protects the DB-retry budget in cmd/registry/main.go: // 30 × 5s = 150s allowed before liveness/readiness take over, which
internal/api/handlers/v0/auth/github_oidc.go+4 −2 modified@@ -252,8 +252,10 @@ func RegisterGitHubOIDCEndpoint(api huma.API, pathPrefix string, cfg *config.Con // ExchangeToken exchanges a GitHub OIDC token for a Registry JWT token func (h *GitHubOIDCHandler) ExchangeToken(ctx context.Context, oidcToken string) (*auth.TokenResponse, error) { - // Validate OIDC token with audience "mcp-registry" - claims, err := h.validator.ValidateToken(ctx, oidcToken, "mcp-registry") + if h.config.GitHubOIDCAudience == "" { + return nil, fmt.Errorf("GitHub OIDC exchange disabled: this deployment has no audience configured") + } + claims, err := h.validator.ValidateToken(ctx, oidcToken, h.config.GitHubOIDCAudience) if err != nil { return nil, fmt.Errorf("failed to validate OIDC token: %w", err) }
internal/api/handlers/v0/auth/github_oidc_test.go+76 −2 modified@@ -26,7 +26,8 @@ func (m *MockOIDCValidator) ValidateToken(ctx context.Context, token string, aud func TestGitHubOIDCHandler_ExchangeToken(t *testing.T) { cfg := &config.Config{ - JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", // 32 bytes hex + JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", // 32 bytes hex + GitHubOIDCAudience: "https://registry.test", } handler := auth.NewGitHubOIDCHandler(cfg) @@ -118,9 +119,82 @@ func TestGitHubOIDCHandler_ExchangeToken(t *testing.T) { } } +func TestGitHubOIDCHandler_AudienceBinding(t *testing.T) { + t.Run("empty audience config fails closed", func(t *testing.T) { + cfg := &config.Config{ + JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + GitHubOIDCAudience: "", + } + handler := auth.NewGitHubOIDCHandler(cfg) + // Validator should never be called — the audience precondition rejects first. + handler.SetValidator(&MockOIDCValidator{ + validateFunc: func(_ context.Context, _ string, _ string) (*auth.GitHubOIDCClaims, error) { + t.Fatal("validator must not be called when audience is unconfigured") + return nil, fmt.Errorf("unreachable") + }, + }) + + _, err := handler.ExchangeToken(context.Background(), "any-token") + require.Error(t, err) + assert.Contains(t, err.Error(), "no audience configured") + }) + + t.Run("configured audience is what the validator sees", func(t *testing.T) { + cfg := &config.Config{ + JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + GitHubOIDCAudience: "https://registry.example.com", + } + handler := auth.NewGitHubOIDCHandler(cfg) + + var seenAudience string + handler.SetValidator(&MockOIDCValidator{ + validateFunc: func(_ context.Context, _ string, audience string) (*auth.GitHubOIDCClaims, error) { + seenAudience = audience + return &auth.GitHubOIDCClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Subject: "repo:octo-org/octo-repo:environment:prod", + ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)), + Audience: jwt.ClaimStrings{audience}, + }, + RepositoryOwner: "octo-org", + }, nil + }, + }) + + _, err := handler.ExchangeToken(context.Background(), "any-token") + require.NoError(t, err) + assert.Equal(t, "https://registry.example.com", seenAudience) + }) + + t.Run("token issued for another deployment is rejected", func(t *testing.T) { + cfg := &config.Config{ + JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + GitHubOIDCAudience: "https://registry.modelcontextprotocol.io", + } + handler := auth.NewGitHubOIDCHandler(cfg) + + // The mock validator does what the real one does: reject if the + // expected audience is not present in the token's aud claim. + handler.SetValidator(&MockOIDCValidator{ + validateFunc: func(_ context.Context, _ string, expected string) (*auth.GitHubOIDCClaims, error) { + tokenAudience := "https://attacker-registry.example" //nolint:gosec // G101 false positive: URL string, not a credential + if expected != tokenAudience { + return nil, fmt.Errorf("invalid audience: expected %s, got [%s]", expected, tokenAudience) + } + return &auth.GitHubOIDCClaims{RepositoryOwner: "octo-org"}, nil + }, + }) + + _, err := handler.ExchangeToken(context.Background(), "captured-token") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid audience") + }) +} + func TestBuildPermissionsFromOIDC(t *testing.T) { cfg := &config.Config{ - JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + JWTPrivateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + GitHubOIDCAudience: "https://registry.test", } handler := auth.NewGitHubOIDCHandler(cfg)
internal/config/config.go+2 −0 modified@@ -17,6 +17,8 @@ type Config struct { EnableAnonymousAuth bool `env:"ENABLE_ANONYMOUS_AUTH" envDefault:"false"` EnableRegistryValidation bool `env:"ENABLE_REGISTRY_VALIDATION" envDefault:"true"` + GitHubOIDCAudience string `env:"GITHUB_OIDC_AUDIENCE" envDefault:""` + // OIDC Configuration OIDCEnabled bool `env:"OIDC_ENABLED" envDefault:"false"` OIDCIssuer string `env:"OIDC_ISSUER" envDefault:""`
Vulnerability mechanics
AI mechanics synthesis has not run for this CVE yet.
References
6- github.com/advisories/GHSA-95c3-6vvw-4mrqghsaADVISORY
- github.com/modelcontextprotocol/registry/security/advisories/GHSA-95c3-6vvw-4mrqnvdMitigationVendor Advisory
- github.com/modelcontextprotocol/registry/commit/3f89fc2b1fb34fd49f3c0e1b39e964a5c67b613fghsa
- github.com/modelcontextprotocol/registry/pull/1229ghsa
- github.com/modelcontextprotocol/registry/releases/tag/v1.7.6ghsa
- nvd.nist.gov/vuln/detail/CVE-2026-44428ghsa
News mentions
0No linked articles in our index yet.