## What this does Fixes an open redirect plus a handful of small, mostly-invisible hardening changes around it. ## What's in it - **The fix**: trailing-slash redirects no longer let `//evil.com/` redirect off-host. - **SSRF block** on the DNS/HTTP namespace verification — refuses to dial loopback, RFC1918, link-local, multicast, CGNAT, or IP-literal/single-label \"domains\". - **Slow-client guard**: `ReadTimeout` and `IdleTimeout` on the HTTP server (no `WriteTimeout` — would risk cutting off legitimate multi-package publishes). - **Defence-in-depth headers** on the UI (`/`) and 404 responses. - **5xx responses** stop leaking raw DB error text to clients (logged server-side instead). - **Search filter** stops treating `%` and `_` as wildcards. - **Validators**: PyPI/NuGet identifiers URL-escaped before fetching; MCPB no longer follows redirects. - **Misc**: `Version` field bounded to 255 chars; logs quote user input; `BlockedNamespaces` denylist now catches subdomain claimants. Each change is its own commit so they can be reviewed (or reverted) independently. ## Test plan - [ ] CI green - [ ] Synthetic publish in staging for each package type (npm, pypi, nuget, oci, mcpb) — confirms validator changes don't regress - [ ] Quick browser check of `/` after deploy to confirm UI still loads with the new CSP - [ ] `curl https://<host>//evil.com/` — confirm the open redirect is closed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
311 lines
9.2 KiB
Go
311 lines
9.2 KiB
Go
package registries
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/modelcontextprotocol/registry/pkg/model"
|
|
)
|
|
|
|
var (
|
|
ErrMissingIdentifierForNuget = errors.New("package identifier is required for NuGet packages")
|
|
ErrMissingVersionForNuget = errors.New("package version is required for NuGet packages")
|
|
)
|
|
|
|
const userAgent = "MCP-Registry-Validator/1.0"
|
|
|
|
type cachedServiceIndex struct {
|
|
index *serviceIndex
|
|
expiresAt time.Time
|
|
}
|
|
|
|
var (
|
|
serviceIndexCache = make(map[string]*cachedServiceIndex)
|
|
cacheMu sync.RWMutex
|
|
cacheDuration = 1 * time.Hour
|
|
)
|
|
|
|
type serviceIndexResource struct {
|
|
ID string `json:"@id"`
|
|
Type string `json:"@type"`
|
|
}
|
|
|
|
type serviceIndex struct {
|
|
Resources []serviceIndexResource `json:"resources"`
|
|
}
|
|
|
|
type packageContentIndex struct {
|
|
Versions []string `json:"versions"`
|
|
}
|
|
|
|
type ReadmeState int
|
|
|
|
const (
|
|
ValidReadme ReadmeState = iota
|
|
InvalidReadme
|
|
NoReadme
|
|
)
|
|
|
|
type PackageExistenceState int
|
|
|
|
const (
|
|
PackageAndVersionExist PackageExistenceState = iota
|
|
PackageExistsVersionMissing
|
|
PackageIDNotFound
|
|
)
|
|
|
|
// ValidateNuGet validates that a NuGet package contains the correct MCP server name
|
|
func ValidateNuGet(ctx context.Context, pkg model.Package, serverName string) error {
|
|
err := validateAndNormalizeBaseURL(&pkg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if pkg.Version == "" {
|
|
return ErrMissingVersionForNuget
|
|
}
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
|
|
// Fetch the service serviceIndex
|
|
serviceIndex, err := fetchAndCacheServiceIndex(ctx, client, pkg.RegistryBaseURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
lowerID := strings.ToLower(pkg.Identifier)
|
|
lowerVersion := strings.ToLower(pkg.Version)
|
|
|
|
// remove any SemVer 2.0.0 build metadata suffix (e.g., +abc)
|
|
if i := strings.Index(lowerVersion, "+"); i >= 0 {
|
|
lowerVersion = lowerVersion[:i]
|
|
}
|
|
|
|
status, err := validateReadme(ctx, serverName, lowerID, lowerVersion, client, serviceIndex)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
switch status {
|
|
case ValidReadme:
|
|
return nil
|
|
case InvalidReadme:
|
|
return fmt.Errorf("NuGet package '%s' ownership validation for version %s failed. The server name '%s' must appear as 'mcp-name: %s' in the package README. Add it to your README and publish a new package version", pkg.Identifier, pkg.Version, serverName, serverName)
|
|
case NoReadme:
|
|
// Continue to check if package exists
|
|
default:
|
|
return fmt.Errorf("unexpected readme state: %d", status)
|
|
}
|
|
|
|
existenceState, err := validatePackageExists(ctx, lowerID, lowerVersion, client, serviceIndex)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
switch existenceState {
|
|
case PackageIDNotFound:
|
|
return fmt.Errorf("NuGet package '%s' does not exist in the registry. If you recently published the package for the first time, wait for validation to complete", pkg.Identifier)
|
|
case PackageExistsVersionMissing:
|
|
return fmt.Errorf("NuGet package '%s' exists but version %s does not exist in the registry. If you recently published the version, wait for validation to complete", pkg.Identifier, pkg.Version)
|
|
case PackageAndVersionExist:
|
|
return fmt.Errorf("NuGet package '%s' ownership validation for version %s failed because it does not have an embedded README. Add one to your package and publish a new version", pkg.Identifier, pkg.Version)
|
|
default:
|
|
return fmt.Errorf("unexpected package existence state: %d", existenceState)
|
|
}
|
|
}
|
|
|
|
func validateAndNormalizeBaseURL(pkg *model.Package) error {
|
|
if pkg.RegistryBaseURL == "" {
|
|
pkg.RegistryBaseURL = model.RegistryURLNuGet
|
|
}
|
|
|
|
if pkg.Identifier == "" {
|
|
return ErrMissingIdentifierForNuget
|
|
}
|
|
|
|
// Validate that MCPB-specific fields are not present
|
|
if pkg.FileSHA256 != "" {
|
|
return fmt.Errorf("NuGet packages must not have 'fileSha256' field - this is only for MCPB packages")
|
|
}
|
|
|
|
// Validate that the registry base URL matches NuGet exactly
|
|
if pkg.RegistryBaseURL != model.RegistryURLNuGet {
|
|
return fmt.Errorf("registry type and base URL do not match: '%s' is not valid for registry type '%s'. Expected: %s",
|
|
pkg.RegistryBaseURL, model.RegistryTypeNuGet, model.RegistryURLNuGet)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func fetchAndCacheServiceIndex(ctx context.Context, client *http.Client, baseURL string) (*serviceIndex, error) {
|
|
cacheMu.RLock()
|
|
if cached, exists := serviceIndexCache[baseURL]; exists {
|
|
if time.Now().Before(cached.expiresAt) {
|
|
cacheMu.RUnlock()
|
|
return cached.index, nil
|
|
}
|
|
}
|
|
cacheMu.RUnlock()
|
|
|
|
cacheMu.Lock()
|
|
defer cacheMu.Unlock()
|
|
|
|
if cached, exists := serviceIndexCache[baseURL]; exists {
|
|
if time.Now().Before(cached.expiresAt) {
|
|
return cached.index, nil
|
|
}
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create NuGet service index request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("User-Agent", userAgent)
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch NuGet service index: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("NuGet service index returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
var index serviceIndex
|
|
if err := json.NewDecoder(resp.Body).Decode(&index); err != nil {
|
|
return nil, fmt.Errorf("failed to parse NuGet service index: %w", err)
|
|
}
|
|
|
|
serviceIndexCache[baseURL] = &cachedServiceIndex{
|
|
index: &index,
|
|
expiresAt: time.Now().Add(cacheDuration),
|
|
}
|
|
|
|
return &index, nil
|
|
}
|
|
|
|
func getReadmeURLTemplate(index *serviceIndex) (string, error) {
|
|
for _, resource := range index.Resources {
|
|
if resource.Type == "ReadmeUriTemplate/6.13.0" {
|
|
return resource.ID, nil
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("ReadmeUriTemplate/6.13.0 not found in service index")
|
|
}
|
|
|
|
func validateReadme(ctx context.Context, serverName, lowerID, lowerVersion string, client *http.Client, index *serviceIndex) (ReadmeState, error) {
|
|
readmeURLTemplate, err := getReadmeURLTemplate(index)
|
|
if err != nil {
|
|
return NoReadme, fmt.Errorf("failed to get README URL template: %w", err)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
req.Header.Set("User-Agent", userAgent)
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return NoReadme, fmt.Errorf("failed to fetch NuGet README: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusOK {
|
|
// Check README content
|
|
readmeBytes, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return NoReadme, fmt.Errorf("failed to read NuGet README content: %w", err)
|
|
}
|
|
|
|
readmeContent := string(readmeBytes)
|
|
|
|
// Check for mcp-name: format (more specific)
|
|
mcpNamePattern := "mcp-name: " + serverName
|
|
if strings.Contains(readmeContent, mcpNamePattern) {
|
|
return ValidReadme, nil // Found as mcp-name: format
|
|
}
|
|
|
|
return InvalidReadme, nil
|
|
}
|
|
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
return NoReadme, nil
|
|
}
|
|
|
|
return InvalidReadme, fmt.Errorf("NuGet README request returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
func getPackageContentBaseURL(index *serviceIndex) (string, error) {
|
|
for _, resource := range index.Resources {
|
|
if resource.Type == "PackageBaseAddress/3.0.0" {
|
|
return resource.ID, nil
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("PackageBaseAddress/3.0.0 not found in service index")
|
|
}
|
|
|
|
func validatePackageExists(ctx context.Context, lowerID, lowerVersion string, client *http.Client, index *serviceIndex) (PackageExistenceState, error) {
|
|
packageBaseURL, err := getPackageContentBaseURL(index)
|
|
if err != nil {
|
|
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.
|
|
// 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 {
|
|
return PackageIDNotFound, fmt.Errorf("failed to create NuGet package index request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("User-Agent", userAgent)
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return PackageIDNotFound, fmt.Errorf("failed to fetch NuGet package index: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
return PackageIDNotFound, nil // Package ID does not exist
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return PackageIDNotFound, fmt.Errorf("NuGet package index returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
var contentIndex packageContentIndex
|
|
if err := json.NewDecoder(resp.Body).Decode(&contentIndex); err != nil {
|
|
return PackageIDNotFound, fmt.Errorf("failed to parse NuGet package index: %w", err)
|
|
}
|
|
|
|
// Check if the version exists in the versions list
|
|
for _, v := range contentIndex.Versions {
|
|
if strings.EqualFold(v, lowerVersion) {
|
|
return PackageAndVersionExist, nil
|
|
}
|
|
}
|
|
|
|
return PackageExistsVersionMissing, nil
|
|
}
|