registry/internal/validators/registries/oci.go
Radoslav Dimitrov 6d110bf99e
Use distribution/reference for parsing OCI references (#651)
<!-- Provide a brief summary of your changes -->

## Motivation and Context
<!-- Why is this change needed? What problem does it solve? -->
The following PR addresses the feedback from #634 around:
* Use a proper library - https://github.com/distribution/reference,
instead of the implementing the OCI parsing ourselves (note: left the
tests so we can ensure we are not breaking any existing behavior)
* Updating one of the error messages to now assume MCPB.

## How Has This Been Tested?
<!-- Have you tested this in a real application? Which scenarios were
tested? -->

## Breaking Changes
<!-- Will users need to update their code or configurations? -->

## Types of changes
<!-- What types of changes does your code introduce? Put an `x` in all
the boxes that apply: -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update

## Checklist
<!-- Go over all the following points, and put an `x` in all the boxes
that apply. -->
- [ ] I have read the [MCP
Documentation](https://modelcontextprotocol.io)
- [ ] My code follows the repository's style guidelines
- [ ] New and existing tests pass locally
- [ ] I have added appropriate error handling
- [ ] I have added or updated documentation as needed

## Additional context
<!-- Add any other context, implementation notes, or design decisions
-->

Signed-off-by: Radoslav Dimitrov <radoslav@stacklok.com>
2025-10-14 15:27:57 +03:00

349 lines
12 KiB
Go

package registries
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"time"
"github.com/modelcontextprotocol/registry/pkg/model"
)
var (
ErrMissingIdentifierForOCI = errors.New("package identifier is required for OCI packages")
ErrMissingVersionForOCI = errors.New("package version is required for OCI packages")
)
const (
dockerIoAPIBaseURL = "https://registry-1.docker.io"
ghcrAPIBaseURL = "https://ghcr.io"
)
// ErrRateLimited is returned when a registry rate limits our requests
var ErrRateLimited = errors.New("rate limited by registry")
// OCIAuthResponse represents an OCI registry authentication response
type OCIAuthResponse struct {
Token string `json:"token"`
}
// RegistryConfig holds configuration for different OCI registries
type RegistryConfig struct {
APIBaseURL string
AuthURL string
Service string
Scope string
}
// getRegistryConfig returns the configuration for a specific registry
func getRegistryConfig(registryBaseURL, namespace, repo string) *RegistryConfig {
switch registryBaseURL {
case model.RegistryURLDocker:
return &RegistryConfig{
APIBaseURL: dockerIoAPIBaseURL,
AuthURL: "https://auth.docker.io/token",
Service: "registry.docker.io",
Scope: fmt.Sprintf("repository:%s/%s:pull", namespace, repo),
}
case model.RegistryURLGHCR:
return &RegistryConfig{
APIBaseURL: ghcrAPIBaseURL,
AuthURL: fmt.Sprintf("%s/token", ghcrAPIBaseURL),
Service: "ghcr.io",
Scope: fmt.Sprintf("repository:%s/%s:pull", namespace, repo),
}
default:
return nil
}
}
// OCIManifest represents an OCI image manifest
type OCIManifest struct {
Manifests []struct {
Digest string `json:"digest"`
} `json:"manifests,omitempty"`
Config struct {
Digest string `json:"digest"`
} `json:"config,omitempty"`
}
// OCIImageConfig represents an OCI image configuration
type OCIImageConfig struct {
Config struct {
Labels map[string]string `json:"Labels"`
} `json:"config"`
}
// ValidateOCI validates that an OCI image contains the correct MCP server name annotation.
// Supports canonical OCI references including:
// - registry/namespace/image:tag
// - registry/namespace/image@sha256:digest
// - registry/namespace/image:tag@sha256:digest
// - namespace/image:tag (defaults to docker.io)
func ValidateOCI(ctx context.Context, pkg model.Package, serverName string) error {
if pkg.Identifier == "" {
return ErrMissingIdentifierForOCI
}
// Validate that old format fields are not present
if pkg.RegistryBaseURL != "" {
return fmt.Errorf("OCI packages must not have 'registryBaseUrl' field - use canonical reference in 'identifier' instead (e.g., 'docker.io/owner/image:1.0.0')")
}
if pkg.Version != "" {
return fmt.Errorf("OCI packages must not have 'version' field - include version in 'identifier' instead (e.g., 'docker.io/owner/image:1.0.0')")
}
if pkg.FileSHA256 != "" {
return fmt.Errorf("OCI packages must not have 'fileSha256' field")
}
// Parse the canonical OCI reference from the identifier
ociRef, err := ParseOCIReference(pkg.Identifier)
if err != nil {
return fmt.Errorf("invalid OCI reference: %w", err)
}
// Validate that the registry is supported
registryBaseURL := ociRef.GetRegistryBaseURL()
if err := validateRegistryURL(registryBaseURL); err != nil {
return err
}
client := &http.Client{Timeout: 10 * time.Second}
// Get registry configuration
registryConfig := getRegistryConfig(registryBaseURL, ociRef.Namespace, ociRef.Image)
if registryConfig == nil {
return fmt.Errorf("unsupported registry: %s", registryBaseURL)
}
// Determine what to use for manifest lookup: digest if available (most secure), otherwise tag
manifestRef := ociRef.Tag
if ociRef.Digest != "" {
manifestRef = ociRef.Digest
}
// Get the image manifest
manifest, err := fetchImageManifest(ctx, client, registryConfig, ociRef.Namespace, ociRef.Image, manifestRef)
if err != nil {
// Handle rate limiting explicitly - skip validation
if errors.Is(err, ErrRateLimited) {
log.Printf("Skipping OCI validation for %s due to rate limiting", ociRef.String())
return nil
}
return err
}
// Get config digest from manifest
configDigest, err := getConfigDigestFromManifest(ctx, client, registryConfig, ociRef.Namespace, ociRef.Image, manifest)
if err != nil {
return err
}
// Validate server name annotation
return validateServerNameAnnotation(ctx, client, registryConfig, ociRef.Namespace, ociRef.Image, ociRef.Tag, configDigest, serverName)
}
// validateRegistryURL validates that the registry base URL is supported
func validateRegistryURL(registryURL string) error {
if registryURL != model.RegistryURLDocker && registryURL != model.RegistryURLGHCR {
return fmt.Errorf("registry type and base URL do not match: '%s' is not valid for registry type '%s'. Expected: %s or %s",
registryURL, model.RegistryTypeOCI, model.RegistryURLDocker, model.RegistryURLGHCR)
}
return nil
}
// fetchImageManifest fetches the OCI manifest for an image
func fetchImageManifest(ctx context.Context, client *http.Client, registryConfig *RegistryConfig, namespace, repo, tag string) (*OCIManifest, error) {
manifestURL := fmt.Sprintf("%s/v2/%s/%s/manifests/%s", registryConfig.APIBaseURL, namespace, repo, tag)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create manifest request: %w", err)
}
// Get auth token if registry requires it
if registryConfig.AuthURL != "" {
token, err := getRegistryAuthToken(ctx, client, registryConfig)
if err != nil {
return nil, fmt.Errorf("failed to authenticate with registry: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("Accept", "application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json")
req.Header.Set("User-Agent", "MCP-Registry-Validator/1.0")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch OCI manifest: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusUnauthorized {
return nil, fmt.Errorf("OCI image '%s/%s:%s' not found (status: %d)", namespace, repo, tag, resp.StatusCode)
}
if resp.StatusCode == http.StatusTooManyRequests {
// Rate limited, return explicit error
log.Printf("Rate limited when accessing OCI image '%s/%s:%s'", namespace, repo, tag)
return nil, fmt.Errorf("%w: %s/%s:%s", ErrRateLimited, namespace, repo, tag)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch OCI manifest (status: %d)", resp.StatusCode)
}
var manifest OCIManifest
if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
return nil, fmt.Errorf("failed to parse OCI manifest: %w", err)
}
return &manifest, nil
}
// getConfigDigestFromManifest extracts the config digest from an OCI manifest
func getConfigDigestFromManifest(ctx context.Context, client *http.Client, registryConfig *RegistryConfig, namespace, repo string, manifest *OCIManifest) (string, error) {
// Handle multi-arch images by using first manifest
if len(manifest.Manifests) > 0 {
// This is a multi-arch image, get the specific manifest
specificManifest, err := getSpecificManifest(ctx, client, registryConfig, namespace, repo, manifest.Manifests[0].Digest)
if err != nil {
return "", fmt.Errorf("failed to get specific manifest: %w", err)
}
return specificManifest.Config.Digest, nil
}
// For single-arch images, validate we have a config digest
if manifest.Config.Digest == "" {
return "", fmt.Errorf("manifest missing config digest - invalid or corrupted manifest")
}
return manifest.Config.Digest, nil
}
// validateServerNameAnnotation validates the MCP server name annotation in the image config
func validateServerNameAnnotation(ctx context.Context, client *http.Client, registryConfig *RegistryConfig, namespace, repo, tag, configDigest, serverName string) error {
// Get image config (contains labels)
config, err := getImageConfig(ctx, client, registryConfig, namespace, repo, configDigest)
if err != nil {
return fmt.Errorf("failed to get image config: %w", err)
}
mcpName, exists := config.Config.Labels["io.modelcontextprotocol.server.name"]
if !exists {
return fmt.Errorf("OCI image '%s/%s:%s' is missing required annotation. Add this to your Dockerfile: LABEL io.modelcontextprotocol.server.name=\"%s\"", namespace, repo, tag, serverName)
}
if mcpName != serverName {
return fmt.Errorf("OCI image ownership validation failed. Expected annotation 'io.modelcontextprotocol.server.name' = '%s', got '%s'", serverName, mcpName)
}
return nil
}
// getRegistryAuthToken retrieves an authentication token from a registry
func getRegistryAuthToken(ctx context.Context, client *http.Client, config *RegistryConfig) (string, error) {
if config.AuthURL == "" {
return "", nil // No auth required
}
authURL := fmt.Sprintf("%s?service=%s&scope=%s", config.AuthURL, config.Service, config.Scope)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, authURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create auth request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to request auth token: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("auth request failed with status %d", resp.StatusCode)
}
var authResp OCIAuthResponse
if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil {
return "", fmt.Errorf("failed to parse auth response: %w", err)
}
return authResp.Token, nil
}
// getSpecificManifest retrieves a specific manifest for multi-arch images
func getSpecificManifest(ctx context.Context, client *http.Client, registryConfig *RegistryConfig, namespace, repo, digest string) (*OCIManifest, error) {
manifestURL := fmt.Sprintf("%s/v2/%s/%s/manifests/%s", registryConfig.APIBaseURL, namespace, repo, digest)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create specific manifest request: %w", err)
}
// Get auth token if registry requires it
if registryConfig.AuthURL != "" {
token, err := getRegistryAuthToken(ctx, client, registryConfig)
if err != nil {
return nil, fmt.Errorf("failed to authenticate with registry: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("Accept", "application/vnd.oci.image.manifest.v1+json")
req.Header.Set("User-Agent", "MCP-Registry-Validator/1.0")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch specific manifest: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("specific manifest not found (status: %d)", resp.StatusCode)
}
var manifest OCIManifest
if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
return nil, fmt.Errorf("failed to parse specific manifest: %w", err)
}
return &manifest, nil
}
// getImageConfig retrieves the image configuration containing labels
func getImageConfig(ctx context.Context, client *http.Client, registryConfig *RegistryConfig, namespace, repo, configDigest string) (*OCIImageConfig, error) {
configURL := fmt.Sprintf("%s/v2/%s/%s/blobs/%s", registryConfig.APIBaseURL, namespace, repo, configDigest)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, configURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create config request: %w", err)
}
// Get auth token if registry requires it
if registryConfig.AuthURL != "" {
token, err := getRegistryAuthToken(ctx, client, registryConfig)
if err != nil {
return nil, fmt.Errorf("failed to authenticate with registry: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json")
req.Header.Set("User-Agent", "MCP-Registry-Validator/1.0")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch image config: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("image config not found (status: %d)", resp.StatusCode)
}
var config OCIImageConfig
if err := json.NewDecoder(resp.Body).Decode(&config); err != nil {
return nil, fmt.Errorf("failed to parse image config: %w", err)
}
return &config, nil
}