Insecure functionality In github.com/modelcontextprotocol/registry
Description
MCP Registry: OCI validator skips ownership check on upstream rate limits # OCI ownership validation fails open on upstream rate limits, allowing attacker to claim arbitrary public OCI images under their own namespace Severity: Low (re-scored post-triage; see Maintainer triage note below) Affected: modelcontextprotocol/registry main branch at commit fe0cb3b (current HEAD as of 2026-05-09). Live deployment: https://registry.modelcontextprotocol.io (per repo README). Route: GitHub private security advisory (per repo SECURITY.md). --- ## Title OCI ownership validation skips label-match check when upstream OCI registry returns HTTP 429, letting any authenticated publisher bind their io.github.<user>/* namespace to OCI images they do not control. ## Summary internal/validators/registries/oci.go:104-119 fails open on http.StatusTooManyRequests: when the registry's anonymous fetch to the upstream OCI registry is rate-limited, ValidateOCI returns nil and the publish is accepted without ever running the io.modelcontextprotocol.server.name label-match check at lines 122-141. That label check is the only cross-system ownership proof the registry applies to OCI packages — every other registry type (NPM, PyPI, NuGet, MCPB) treats a non-200 upstream response as a hard error. The fail-open trigger is attacker-controllable. The registry uses authn.Anonymous against Docker Hub, which is rate-limited to 100 manifest pulls per 6 hours per egress IP, and the production NGINX rate limit allows 180 publishes/minute (3 RPS, burst 540) per source IP. A single attacker from a single IP can exhaust the registry's shared anonymous quota in roughly 33 seconds, then submit a final publish that points packages[].identifier at a Docker Hub image they do not own. The validator hits the 429 fail-open branch, returns nil, and the registry stores a record under the attacker's namespace claiming the unrelated image as its package payload, with no label proof in evidence. The fail-open is also reached without an attacker present. Docker Hub routinely 429s busy egress IPs during organic traffic, so publishes during those windows skip OCI ownership validation silently. ## Vulnerable code internal/validators/registries/oci.go:97-142: go img, err := remote.Image(ref, remote.WithAuth(authn.Anonymous), remote.WithContext(timeoutCtx)) if err != nil { if errors.Is(err, context.DeadlineExceeded) { return fmt.Errorf("OCI image validation timed out after 30 seconds for '%s'. The registry may be slow or unreachable", pkg.Identifier) } var transportErr *transport.Error if errors.As(err, &transportErr) { switch transportErr.StatusCode { case http.StatusTooManyRequests: // Rate limited - skip validation to avoid blocking publishers // This is intentional: we prioritize UX over strict validation during high traffic log.Printf("Skipping OCI validation for %s due to rate limiting", pkg.Identifier) return nil // <-- FAIL-OPEN case http.StatusNotFound: return fmt.Errorf("OCI image '%s' does not exist in the registry", pkg.Identifier) case http.StatusUnauthorized, http.StatusForbidden: return fmt.Errorf("OCI image '%s' is private or requires authentication. Only public images are supported", pkg.Identifier) } } return fmt.Errorf("failed to fetch OCI image: %w", err) } // Get the image config which contains labels configFile, err := img.ConfigFile() if err != nil { return fmt.Errorf("failed to get image config: %w", err) } // Validate the MCP server name label if configFile.Config.Labels == nil { return fmt.Errorf("OCI image '%s' is missing required annotation. Add this to your Dockerfile: LABEL io.modelcontextprotocol.server.name=\"%s\"", pkg.Identifier, serverName) } mcpName, exists := configFile.Config.Labels["io.modelcontextprotocol.server.name"] if !exists { return fmt.Errorf("OCI image '%s' is missing required annotation. Add this to your Dockerfile: LABEL io.modelcontextprotocol.server.name=\"%s\"", pkg.Identifier, serverName) } if mcpName != serverName { return fmt.Errorf("OCI image ownership validation failed. Expected annotation 'io.modelcontextprotocol.server.name' = '%s', got '%s'", serverName, mcpName) } The fail-open returns before any of the three label-match guards run. The validator is reached on every publish per internal/service/registry_service.go:151-158, gated by cfg.EnableRegistryValidation, which defaults to true in internal/config/config.go:18. ## Reachability and authorization POST /v0/publish (and /v0.1/publish) is registered with bearer-JWT auth in internal/api/handlers/v0/publish.go:30-50. JWTs are issued by /v0/auth/github-at (internal/api/handlers/v0/auth/github_at.go:46-67), which exchanges any GitHub OAuth access token for a 5-minute registry JWT carrying Permission{Action: Publish, ResourcePattern: "io.github.<login>/*"}. Any free GitHub account can mint such a JWT, so the publish path is reachable to anyone on the internet at the cost of a GitHub account. ## Trigger conditions - internal/validators/registries/oci.go:97: anonymous Docker Hub auth, subject to the 100 manifest-pulls/6h/IP unauthenticated rate limit Docker Hub publishes. - deploy/pkg/k8s/registry.go:330-331: production NGINX limits incoming requests to 180/minute per source IP with a 3× burst multiplier (540). - A single source IP at 3 RPS exhausts the registry's anonymous Docker Hub quota in roughly 33 seconds. Each /publish against an allowlisted OCI identifier in internal/validators/registries/oci.go:29-42 (docker.io / registry-1.docker.io / index.docker.io / ghcr.io / quay.io / mcr.microsoft.com / *.pkg.dev / *.azurecr.io) consumes one slot, including publishes that go on to fail with the missing-annotation error after the manifest is fetched. - Once Docker Hub starts returning 429, every subsequent publish hits the fail-open branch until the quota replenishes. ## Attacker chain 1. Free GitHub account attacker → POST /v0/auth/github-at → registry JWT with Permission{Action: Publish, ResourcePattern: "io.github.attacker/*"}. 2. From a single IP, send ~100 publishes whose packages[].identifier references real public Docker Hub images that lack the io.modelcontextprotocol.server.name label (e.g. docker.io/library/alpine:latest, docker.io/library/nginx:latest, …). Each publish fails with "OCI image is missing required annotation" but consumes one anonymous-quota slot from the registry's shared egress IP. 3. While the egress IP is rate-limited by Docker Hub, submit the final publish: name = "io.github.attacker/<typo-squat-name>", packages[].registryType = "oci", packages[].identifier = "docker.io/<reputable-org>/<reputable-image>:<tag>". 4. ValidateOCI calls remote.Image(ref, authn.Anonymous, …); Docker Hub returns 429; transportErr.StatusCode == http.StatusTooManyRequests matches the fail-open branch; ValidateOCI returns nil; ValidatePackage returns nil; validateRegistryOwnership returns nil; the publish proceeds and CreateServer writes the record. The registry now publishes a server record under io.github.attacker/<typo-squat-name> that asserts the reputable image as its package payload, without ever inspecting that image's labels. ## Boundary delta | | Starting capability | After exploit | |---|---|---| | Identity | Holder of a fresh io.github.<attacker> GitHub account | Same | | Publish scope | io.github.<attacker>/* only | io.github.<attacker>/* only (unchanged) | | OCI claim scope | OCI images the attacker controls and has labelled with io.modelcontextprotocol.server.name = io.github.<attacker>/<name> | Any public OCI image at any allowlisted registry, regardless of label | The attacker's namespace stays bounded. What changes is that the registry's claim "this OCI image is the package payload of this MCP server" is no longer backed by any cross-system proof. The label check
Mitigation
Update Impact
Minimal update. May introduce new vulnerabilities or breaking changes.
Ecosystem | Package | Affected version | Patched versions |
|---|---|---|---|
go | 1.7.9 |
Aliases
References