None severity5.7GHSA Advisory· Published May 14, 2026· Updated May 15, 2026
CVE-2026-44427
CVE-2026-44427
Description
The MCP Registry provides MCP clients with a list of MCP servers, like an app store for MCP servers. From 1.1.0 to 1.7.4, the TrailingSlashMiddleware in internal/api/server.go is vulnerable to an open redirect attack. An attacker can craft a URL with a protocol-relative path (e.g., //evil.com/) that, after trailing slash removal, results in a Location header of //evil.com — which browsers interpret as an absolute URL to an external domain. This vulnerability is fixed in 1.7.5.
Affected products
1- Range: >= 1.1.0, < 1.7.5
Patches
11201cbd82b2csecurity: fix open redirect and add small hardening (#1227)
22 files changed · +330 −48
docs/reference/api/openapi.yaml+1 −0 modified@@ -688,6 +688,7 @@ components: description: "Package version. Must be a specific version. Version ranges are rejected (e.g., '^1.2.3', '~1.2.3', '>=1.2.3', '1.x', '1.*')." example: "1.0.2" minLength: 1 + maxLength: 255 not: const: "latest" fileSha256:
docs/reference/server-json/draft/server.schema.json+1 −0 modified@@ -282,6 +282,7 @@ "version": { "description": "Package version. Must be a specific version. Version ranges are rejected (e.g., '^1.2.3', '~1.2.3', '\u003e=1.2.3', '1.x', '1.*').", "example": "1.0.2", + "maxLength": 255, "minLength": 1, "not": { "const": "latest"
internal/api/handlers/v0/auth/common.go+13 −0 modified@@ -11,6 +11,7 @@ import ( "errors" "fmt" "math/big" + "net" "regexp" "strings" "time" @@ -376,6 +377,18 @@ func IsValidDomain(domain string) bool { return false } + // Reject IP literals — this auth method proves domain ownership, not IP + // ownership, and IP literals are an SSRF vector into internal networks. + if net.ParseIP(domain) != nil { + return false + } + + // Require at least one dot — rejects single-label names like "localhost" + // or "kubernetes" that resolve only inside private networks. + if !strings.Contains(domain, ".") { + return false + } + // Check for valid characters and structure domainPattern := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$`) return domainPattern.MatchString(domain)
internal/api/handlers/v0/auth/common_test.go+49 −0 added@@ -0,0 +1,49 @@ +package auth_test + +import ( + "testing" + + "github.com/modelcontextprotocol/registry/internal/api/handlers/v0/auth" +) + +func TestIsValidDomain(t *testing.T) { + tests := []struct { + domain string + want bool + }{ + // Valid + {"example.com", true}, + {"sub.example.com", true}, + {"a.b.c.d.example.com", true}, + {"foo-bar.example.com", true}, + {"123.example.com", true}, + + // Invalid — empty / oversize + {"", false}, + + // Invalid — IP literals (SSRF vector) + {"127.0.0.1", false}, + {"10.0.0.1", false}, + {"169.254.169.254", false}, + {"::1", false}, + {"fe80::1", false}, + + // Invalid — single-label internal names (SSRF vector) + {"localhost", false}, + {"kubernetes", false}, + {"internal", false}, + + // Invalid — bad characters / structure + {"-example.com", false}, + {"example.com-", false}, + {"exa mple.com", false}, + {"example..com", false}, + } + for _, tc := range tests { + t.Run(tc.domain, func(t *testing.T) { + if got := auth.IsValidDomain(tc.domain); got != tc.want { + t.Errorf("IsValidDomain(%q) = %v, want %v", tc.domain, got, tc.want) + } + }) + } +}
internal/api/handlers/v0/auth/dns_test.go+0 −11 modified@@ -280,17 +280,6 @@ func TestDNSAuthHandler_Permissions(t *testing.T) { "v1.api.example.com/*", // should be reversed }, }, - { - name: "single part domain", - domain: "localhost", - expectedPatterns: []string{ - "localhost/*", // exact pattern (no reversal needed) - "localhost.*", // subdomain pattern - }, - unexpectedPatterns: []string{ - "*.localhost", // wrong wildcard position - }, - }, { name: "hyphenated domain", domain: "my-app.example-site.com",
internal/api/handlers/v0/auth/http.go+86 −0 modified@@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "net" "net/http" "strings" "time" @@ -34,6 +35,9 @@ type DefaultHTTPKeyFetcher struct { // NewDefaultHTTPKeyFetcher creates a new HTTP key fetcher with timeout func NewDefaultHTTPKeyFetcher() *DefaultHTTPKeyFetcher { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.DialContext = safeDialContext + return &DefaultHTTPKeyFetcher{ client: &http.Client{ Timeout: 10 * time.Second, @@ -42,10 +46,92 @@ func NewDefaultHTTPKeyFetcher() *DefaultHTTPKeyFetcher { CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, + Transport: transport, }, } } +// safeDialContext resolves the target hostname and refuses to dial loopback, +// private (RFC1918, ULA), link-local, or unspecified addresses. Combined with +// IsValidDomain rejecting IP literals, this neutralises SSRF abuse of the +// well-known fetcher: an attacker cannot reach internal HTTPS services +// (Kubernetes API server, internal admin panels, internal DNS-resolved hosts) +// even if they control DNS for an attacker domain. +// +// The hostname is resolved once here; we then dial the resolved IP directly, +// which pins the connection against DNS rebinding (a TOCTOU where the resolver +// returns a public IP to a pre-flight check and an internal IP to the actual +// dial). TLS SNI and the Host header continue to use the original hostname +// since they are set by http.Transport from the request URL, not the dial +// address. +func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + + var resolver net.Resolver + ips, err := resolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + + // Try each non-blocked address in order, falling through on dial failure. + // Without this, a stale public AAAA record that no longer routes (or any + // individually-unreachable IP) breaks auth where the default transport + // would have recovered by trying the next answer. + // + // Each attempt is bounded by perIPDialTimeout so that a single hanging + // address can't consume the whole http.Client budget. This is a + // simpler substitute for Happy Eyeballs (parallel A/AAAA racing) — we + // fail fast and try the next answer instead of racing them. + const perIPDialTimeout = 3 * time.Second + + var lastErr error + allBlocked := true + for _, ip := range ips { + if isBlockedIP(ip.IP) { + continue + } + allBlocked = false + dialCtx, cancel := context.WithTimeout(ctx, perIPDialTimeout) + var d net.Dialer + conn, dialErr := d.DialContext(dialCtx, network, net.JoinHostPort(ip.IP.String(), port)) + cancel() + if dialErr == nil { + return conn, nil + } + lastErr = dialErr + } + if allBlocked { + return nil, fmt.Errorf("dial %s: refusing to connect to private or loopback address", host) + } + return nil, fmt.Errorf("dial %s: all resolved public addresses failed: %w", host, lastErr) +} + +// cgnatRange covers RFC 6598 Carrier-Grade NAT (100.64.0.0/10), which the +// stdlib does not classify via any Is* helper but is reachable on some +// cloud / mobile networks where it shadows internal infrastructure. +var cgnatRange = func() *net.IPNet { + _, n, _ := net.ParseCIDR("100.64.0.0/10") + return n +}() + +// isBlockedIP reports whether an IP must not be dialled by the namespace +// verification fetcher. Covers loopback (127/8, ::1), RFC1918 + ULA via +// IsPrivate, link-local (169.254/16, fe80::/10 — includes cloud metadata +// 169.254.169.254), unspecified (0.0.0.0, ::), all multicast (admin-scoped +// 239/8 and ff00::/8 in addition to link-local-multicast), and CGNAT. +func isBlockedIP(ip net.IP) bool { + if ip == nil { + return true + } + return ip.IsLoopback() || ip.IsPrivate() || + ip.IsLinkLocalUnicast() || ip.IsMulticast() || + ip.IsUnspecified() || + cgnatRange.Contains(ip) +} + // NewDefaultHTTPKeyFetcherWithClient creates a new HTTP key fetcher with a custom HTTP client. // This is primarily useful in tests to inject transports or TLS settings. func NewDefaultHTTPKeyFetcherWithClient(client *http.Client) *DefaultHTTPKeyFetcher {
internal/api/handlers/v0/auth/http_internal_test.go+52 −0 added@@ -0,0 +1,52 @@ +package auth + +import ( + "net" + "testing" +) + +func TestIsBlockedIP(t *testing.T) { + tests := []struct { + ip string + blocked bool + }{ + // Blocked — loopback + {"127.0.0.1", true}, + {"::1", true}, + // Blocked — RFC1918 / ULA (IsPrivate) + {"10.0.0.1", true}, + {"172.16.0.1", true}, + {"192.168.1.1", true}, + {"fc00::1", true}, + // Blocked — link-local (includes cloud metadata 169.254.169.254) + {"169.254.169.254", true}, + {"fe80::1", true}, + // Blocked — unspecified + {"0.0.0.0", true}, + {"::", true}, + // Blocked — admin-scoped and broader multicast + {"239.0.0.1", true}, + {"ff00::1", true}, + // Blocked — Carrier-Grade NAT (RFC 6598) + {"100.64.0.1", true}, + {"100.127.255.254", true}, + // Allowed — public + {"1.1.1.1", false}, + {"8.8.8.8", false}, + {"2606:4700:4700::1111", false}, + // Allowed — outside CGNAT range + {"100.63.255.255", false}, + {"100.128.0.1", false}, + } + for _, tc := range tests { + t.Run(tc.ip, func(t *testing.T) { + ip := net.ParseIP(tc.ip) + if ip == nil { + t.Fatalf("ParseIP(%q) returned nil", tc.ip) + } + if got := isBlockedIP(ip); got != tc.blocked { + t.Errorf("isBlockedIP(%q) = %v, want %v", tc.ip, got, tc.blocked) + } + }) + } +}
internal/api/handlers/v0/auth/http_test.go+0 −11 modified@@ -472,17 +472,6 @@ func TestHTTPAuthHandler_Permissions(t *testing.T) { "v1.api.example.com/*", // should be reversed }, }, - { - name: "single part domain", - domain: "localhost", - expectedPatterns: []string{ - "localhost/*", // exact pattern only (no reversal needed) - }, - unexpectedPatterns: []string{ - "localhost.*", // HTTP should not grant subdomain permissions - "*.localhost", // wrong wildcard position - }, - }, { name: "hyphenated domain", domain: "my-app.example-site.com",
internal/api/handlers/v0/edit.go+3 −1 modified@@ -3,6 +3,7 @@ package v0 import ( "context" "errors" + "log" "net/http" "net/url" "strings" @@ -74,7 +75,8 @@ func RegisterEditEndpoints(api huma.API, pathPrefix string, registry service.Reg if errors.Is(err, database.ErrNotFound) { return nil, huma.Error404NotFound("Server not found") } - return nil, huma.Error500InternalServerError("Failed to get current server", err) + log.Printf("edit: get current server (%q/%q) failed: %v", serverName, version, err) + return nil, huma.Error500InternalServerError("Failed to get current server") } // Verify edit permissions for this server using the existing server name
internal/api/handlers/v0/servers.go+7 −3 modified@@ -3,6 +3,7 @@ package v0 import ( "context" "errors" + "log" "net/http" "net/url" "reflect" @@ -131,7 +132,8 @@ func RegisterServersEndpoints(api huma.API, pathPrefix string, registry service. // Get paginated results with filtering servers, nextCursor, err := registry.ListServers(ctx, filter, input.Cursor, input.Limit) if err != nil { - return nil, huma.Error500InternalServerError("Failed to get registry list", err) + log.Printf("list servers failed: %v", err) + return nil, huma.Error500InternalServerError("Failed to get registry list") } // Convert []*ServerResponse to []ServerResponse @@ -184,7 +186,8 @@ func RegisterServersEndpoints(api huma.API, pathPrefix string, registry service. if err.Error() == errRecordNotFound || errors.Is(err, database.ErrNotFound) { return nil, huma.Error404NotFound("Server not found") } - return nil, huma.Error500InternalServerError("Failed to get server details", err) + log.Printf("get server details (%q/%q) failed: %v", serverName, version, err) + return nil, huma.Error500InternalServerError("Failed to get server details") } return &Response[apiv0.ServerResponse]{ @@ -213,7 +216,8 @@ func RegisterServersEndpoints(api huma.API, pathPrefix string, registry service. if err.Error() == errRecordNotFound || errors.Is(err, database.ErrNotFound) { return nil, huma.Error404NotFound("Server not found") } - return nil, huma.Error500InternalServerError("Failed to get server versions", err) + log.Printf("get server versions (%q) failed: %v", serverName, err) + return nil, huma.Error500InternalServerError("Failed to get server versions") } // Convert []*ServerResponse to []ServerResponse
internal/api/handlers/v0/status.go+7 −3 modified@@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log" "net/http" "net/url" "strings" @@ -122,7 +123,8 @@ func RegisterStatusEndpoints(api huma.API, pathPrefix string, registry service.R if errors.Is(err, database.ErrNotFound) { return nil, huma.Error404NotFound("Server version not found") } - return nil, huma.Error500InternalServerError("Failed to get server", err) + log.Printf("status: get server (%q/%q) failed: %v", serverName, version, err) + return nil, huma.Error500InternalServerError("Failed to get server") } // Verify publish or edit permissions for this server @@ -228,7 +230,8 @@ func RegisterAllVersionsStatusEndpoints(api huma.API, pathPrefix string, registr if errors.Is(err, database.ErrNotFound) { return nil, huma.Error404NotFound("Server not found") } - return nil, huma.Error500InternalServerError("Failed to get server", err) + log.Printf("status: get server (%q) failed: %v", serverName, err) + return nil, huma.Error500InternalServerError("Failed to get server") } // Verify publish or edit permissions for this server @@ -243,7 +246,8 @@ func RegisterAllVersionsStatusEndpoints(api huma.API, pathPrefix string, registr // Fetch all versions to validate the bulk status transition allVersions, err := registry.GetAllVersionsByServerName(ctx, serverName, true) if err != nil { - return nil, huma.Error500InternalServerError("Failed to get server versions", err) + log.Printf("status: get all versions (%q) failed: %v", serverName, err) + return nil, huma.Error500InternalServerError("Failed to get server versions") } // Validate bulk status transition - reject if no changes would occur
internal/api/router/router.go+22 −1 modified@@ -97,6 +97,7 @@ func WithSkipPaths(paths ...string) MiddlewareOption { // handle404 returns a helpful 404 error with suggestions for common mistakes func handle404(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/problem+json") + w.Header().Set("X-Content-Type-Options", "nosniff") w.WriteHeader(http.StatusNotFound) path := r.URL.Path @@ -187,8 +188,28 @@ func NewHumaAPI(cfg *config.Config, registry service.RegistryService, mux *http. // Add UI and 404 handler for all other routes mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { - // Serve UI at root + // Serve UI at root. The page renders publisher-controlled content + // (server names, descriptions, repository URLs) — server-side + // validation plus a JS escape function are the primary XSS + // defences; these headers are defence-in-depth in case any of + // those slip. w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + // connect-src is unrestricted because the UI exposes a base-URL + // selector (prod / staging / custom) that issues cross-origin + // XHRs to whichever target the operator picks. Constraining + // connect-src would silently break that affordance. The other + // directives still meaningfully limit the page's attack surface. + w.Header().Set("Content-Security-Policy", + "default-src 'self'; "+ + "script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; "+ + "style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; "+ + "img-src 'self' data:; "+ + "connect-src *; "+ + "frame-ancestors 'none'; "+ + "base-uri 'self'; "+ + "form-action 'self'") _, err := w.Write([]byte(v0.GetUIHTML())) if err != nil { http.Error(w, "Failed to write response", http.StatusInternalServerError)
internal/api/server.go+16 −2 modified@@ -5,6 +5,7 @@ import ( "encoding/json" "log" "net/http" + "path" "strings" "time" @@ -71,9 +72,11 @@ func TrailingSlashMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Only redirect if the path is not "/" and ends with a "/" if r.URL.Path != "/" && strings.HasSuffix(r.URL.Path, "/") { - // Create a copy of the URL and remove the trailing slash + // path.Clean both removes the trailing slash and collapses any + // leading "//" to "/", which prevents an open-redirect via a + // protocol-relative path like "//evil.com/" (GHSA-v8vw-gw5j-w7m6). newURL := *r.URL - newURL.Path = strings.TrimSuffix(r.URL.Path, "/") + newURL.Path = path.Clean(r.URL.Path) // Use 308 Permanent Redirect to preserve the request method http.Redirect(w, r, newURL.String(), http.StatusPermanentRedirect) @@ -127,6 +130,17 @@ func NewServer(cfg *config.Config, registryService service.RegistryService, metr Addr: cfg.ServerAddress, Handler: handler, ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + // WriteTimeout intentionally not set: the publish path runs + // outbound package validators sequentially (npm/pypi/nuget up to + // 10s each, OCI up to 30s), so any tight cap could cut off a + // legitimate multi-package publish mid-response — surfacing as a + // truncated read to the publisher even when the DB commit + // succeeded. Slow-response-read DoS is bounded upstream by + // NGINX ingress timeouts and the per-IP rate limit. Revisit once + // validators are parallelised or per-request package counts are + // bounded. + IdleTimeout: 120 * time.Second, }, }
internal/api/server_test.go+16 −0 modified@@ -232,6 +232,22 @@ func TestTrailingSlashMiddleware(t *testing.T) { expectedLocation: "/v0/servers?limit=10", expectRedirect: true, }, + { + // Regression test for GHSA-v8vw-gw5j-w7m6: a protocol-relative + // path like "//evil.com/" must not redirect off-host. + name: "protocol-relative path should not redirect off-host", + path: "//evil.com/", + expectedStatus: http.StatusPermanentRedirect, + expectedLocation: "/evil.com", + expectRedirect: true, + }, + { + name: "path with multiple leading slashes should be collapsed", + path: "///evil.com/foo/", + expectedStatus: http.StatusPermanentRedirect, + expectedLocation: "/evil.com/foo", + expectRedirect: true, + }, } for _, tt := range tests {
internal/auth/jwt.go+9 −2 modified@@ -81,10 +81,17 @@ func (j *JWTManager) GenerateTokenResponse(_ context.Context, claims JWTClaims) } } - // Check permissions against denylist, provided they are not an admin + // Check permissions against denylist, provided they are not an admin. + // Probe two synthetic resources per blocked namespace so that both the + // slash-suffix patterns (e.g. com.evil/*) and the dot-wildcard patterns + // (e.g. com.evil.mailer.* — granted to a subdomain claimant) are + // detected. Probing only "<blocked>/test" misses the dot-wildcard form + // because the prefix match against "com.evil.mailer." does not start + // with "com.evil/test". if !hasGlobalPermissions { for _, blockedNamespace := range BlockedNamespaces { - if j.HasPermission(blockedNamespace+"/test", PermissionActionPublish, claims.Permissions) { + if j.HasPermission(blockedNamespace+"/test", PermissionActionPublish, claims.Permissions) || + j.HasPermission(blockedNamespace+".test/x", 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") } }
internal/database/postgres.go+7 −2 modified@@ -118,8 +118,13 @@ func buildFilterConditions(filter *ServerFilter, argIndex int) ([]string, []any, argIndex++ } if filter.SubstringName != nil { - conditions = append(conditions, fmt.Sprintf("server_name ILIKE $%d", argIndex)) - args = append(args, "%"+*filter.SubstringName+"%") + // Escape LIKE metacharacters so that user input cannot expand into + // wildcard matches (e.g. `?search=_` matching every single-char name, + // `?search=%` matching everything). Order matters: backslashes must be + // escaped first so subsequent escape backslashes are not double-escaped. + escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(*filter.SubstringName) + conditions = append(conditions, fmt.Sprintf("server_name ILIKE $%d ESCAPE '\\'", argIndex)) + args = append(args, "%"+escaped+"%") argIndex++ } if filter.Version != nil {
internal/validators/registries/mcpb.go+20 −3 modified@@ -54,8 +54,17 @@ func ValidateMCPB(ctx context.Context, pkg model.Package, _ string) error { return fmt.Errorf("MCPB package URL must contain 'mcp': %s", pkg.Identifier) } - // Verify the file exists and is publicly accessible - client := &http.Client{Timeout: 10 * time.Second} + // Verify the file exists and is publicly accessible. Refuse to follow + // redirects: the URL allowlist (github.com / gitlab.com) only constrains + // the FIRST hop, and a 30x bouncing through CDN/release-asset hosts + // could otherwise be steered toward attacker infrastructure or + // internal-only endpoints reached via DNS quirks. + client := &http.Client{ + Timeout: 10 * time.Second, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } req, err := http.NewRequestWithContext(ctx, http.MethodHead, pkg.Identifier, nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) @@ -69,7 +78,15 @@ func ValidateMCPB(ctx context.Context, pkg model.Package, _ string) error { } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { + // GitHub serves release assets via a 302 to a signed S3 URL; a HEAD that + // returns 200 directly OR a 302 with a Location header is acceptable. + // Any other 3xx without a usable Location is treated as inaccessible. + switch { + case resp.StatusCode == http.StatusOK: + // fine + case resp.StatusCode >= 300 && resp.StatusCode < 400 && resp.Header.Get("Location") != "": + // fine — first-hop allowlist + we don't actually need the body + default: return fmt.Errorf("MCPB package '%s' is not publicly accessible (status: %d)", pkg.Identifier, resp.StatusCode) }
internal/validators/registries/nuget.go+10 −5 modified@@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "sync" "time" @@ -209,9 +210,11 @@ func validateReadme(ctx context.Context, serverName, lowerID, lowerVersion strin return NoReadme, fmt.Errorf("failed to get README URL template: %w", err) } - // Replace placeholders in the template - readmeURL := strings.ReplaceAll(readmeURLTemplate, "{lower_id}", lowerID) - readmeURL = strings.ReplaceAll(readmeURL, "{lower_version}", lowerVersion) + // Replace placeholders in the template. PathEscape both the id and version + // so a publisher cannot smuggle "/" / ".." through the template into a + // fetch against an unrelated package's README. + readmeURL := strings.ReplaceAll(readmeURLTemplate, "{lower_id}", url.PathEscape(lowerID)) + readmeURL = strings.ReplaceAll(readmeURL, "{lower_version}", url.PathEscape(lowerVersion)) req, err := http.NewRequestWithContext(ctx, http.MethodGet, readmeURL, nil) if err != nil { return NoReadme, fmt.Errorf("failed to create NuGet README request: %w", err) @@ -266,8 +269,10 @@ func validatePackageExists(ctx context.Context, lowerID, lowerVersion string, cl return PackageIDNotFound, fmt.Errorf("failed to get Package Base URL: %w", err) } - // Fetch the package content index to check if package ID and version exist - indexURL := fmt.Sprintf("%s/%s/index.json", strings.TrimRight(packageBaseURL, "/"), lowerID) + // Fetch the package content index to check if package ID and version exist. + // PathEscape so an identifier that smuggles "/" or ".." cannot redirect + // the metadata fetch to a different package than the one being claimed. + indexURL := fmt.Sprintf("%s/%s/index.json", strings.TrimRight(packageBaseURL, "/"), url.PathEscape(lowerID)) req, err := http.NewRequestWithContext(ctx, http.MethodGet, indexURL, nil) if err != nil {
internal/validators/registries/pypi.go+8 −2 modified@@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "strings" "time" @@ -52,8 +53,13 @@ func ValidatePyPI(ctx context.Context, pkg model.Package, serverName string) err client := &http.Client{Timeout: 10 * time.Second} - url := fmt.Sprintf("%s/pypi/%s/%s/json", pkg.RegistryBaseURL, pkg.Identifier, pkg.Version) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + // PathEscape so an identifier that smuggles "/" or ".." cannot redirect + // the metadata fetch to a different package than the one being claimed. + fetchURL := fmt.Sprintf("%s/pypi/%s/%s/json", + pkg.RegistryBaseURL, + url.PathEscape(pkg.Identifier), + url.PathEscape(pkg.Version)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fetchURL, nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) }
internal/validators/schemas/2025-12-11.json+1 −0 modified@@ -283,6 +283,7 @@ "description": "Package version. Must be a specific version. Version ranges are rejected (e.g., '^1.2.3', '~1.2.3', '\u003e=1.2.3', '1.x', '1.*').", "example": "1.0.2", "minLength": 1, + "maxLength": 255, "not": { "const": "latest" },
pkg/api/v0/types.go+1 −1 modified@@ -39,7 +39,7 @@ type ServerJSON struct { Description string `json:"description" minLength:"1" maxLength:"100" doc:"Clear human-readable explanation of server functionality." example:"MCP server providing weather data and forecasts via OpenWeatherMap API"` Title string `json:"title,omitempty" minLength:"1" maxLength:"100" doc:"Optional human-readable title or display name for the MCP server." example:"Weather API"` Repository *model.Repository `json:"repository,omitempty" doc:"Optional repository metadata for the MCP server source code."` - Version string `json:"version" doc:"Version string for this server. SHOULD follow semantic versioning." example:"1.0.2"` + Version string `json:"version" minLength:"1" maxLength:"255" doc:"Version string for this server. SHOULD follow semantic versioning." example:"1.0.2"` WebsiteURL string `json:"websiteUrl,omitempty" format:"uri" doc:"Optional URL to the server's homepage, documentation, or project website." example:"https://modelcontextprotocol.io/examples"` Icons []model.Icon `json:"icons,omitempty" doc:"Optional set of sized icons that the client can display in a user interface."` Packages []model.Package `json:"packages,omitempty" doc:"Array of package configurations"`
pkg/model/types.go+1 −1 modified@@ -35,7 +35,7 @@ type Package struct { // - For MCPB: direct download URL Identifier string `json:"identifier" minLength:"1" doc:"Package identifier - either a package name (for registries) or URL (for direct downloads)" example:"@modelcontextprotocol/server-brave-search"` // Version is the package version (required for npm, pypi, nuget; optional for mcpb; not used by oci where version is in the identifier) - Version string `json:"version,omitempty" minLength:"1" doc:"Package version. Must be a specific version. Version ranges are rejected (e.g., '^1.2.3', '~1.2.3', '>=1.2.3', '1.x', '1.*')." example:"1.0.2"` + Version string `json:"version,omitempty" minLength:"1" maxLength:"255" doc:"Package version. Must be a specific version. Version ranges are rejected (e.g., '^1.2.3', '~1.2.3', '>=1.2.3', '1.x', '1.*')." example:"1.0.2"` // FileSHA256 is the SHA-256 hash for integrity verification (required for mcpb, optional for others) FileSHA256 string `json:"fileSha256,omitempty" pattern:"^[a-f0-9]{64}$" doc:"SHA-256 hash of the package file for integrity verification. Required for MCPB packages and optional for other package types. Authors are responsible for generating correct SHA-256 hashes when creating server.json. If present, MCP clients must validate the downloaded file matches the hash before running packages to ensure file integrity." example:"fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce"` // RunTimeHint suggests the appropriate runtime for the package
Vulnerability mechanics
AI mechanics synthesis has not run for this CVE yet.
References
6- github.com/advisories/GHSA-v8vw-gw5j-w7m6ghsaADVISORY
- github.com/modelcontextprotocol/registry/commit/1201cbd82b2cf6d4b56edfc05c763059a12f9fdbghsa
- github.com/modelcontextprotocol/registry/pull/1227ghsa
- github.com/modelcontextprotocol/registry/releases/tag/v1.7.5ghsa
- github.com/modelcontextprotocol/registry/security/advisories/GHSA-v8vw-gw5j-w7m6nvd
- nvd.nist.gov/vuln/detail/CVE-2026-44427ghsa
News mentions
0No linked articles in our index yet.