VYPR
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

Patches

1
1201cbd82b2c

security: fix open redirect and add small hardening (#1227)

https://github.com/modelcontextprotocol/registryRadoslav DimitrovApr 29, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.