Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5c7e855
fix(api): prevent open redirect via protocol-relative path in trailin…
rdimitrov Apr 29, 2026
257eb17
fix(auth): block SSRF via HTTP namespace verification
rdimitrov Apr 29, 2026
f3f3cc7
fix(auth): fall through to next IP when first dial fails in safeDialC…
rdimitrov Apr 29, 2026
4c97820
fix(api): add ReadTimeout and IdleTimeout to mitigate slow-client DoS
rdimitrov Apr 29, 2026
d7c8d8e
fix(ui): add security headers to root HTML response
rdimitrov Apr 29, 2026
7d2619e
fix(handlers): stop leaking wrapped errors on 500-class responses
rdimitrov Apr 29, 2026
61de458
fix(db): escape LIKE metacharacters in SubstringName filter
rdimitrov Apr 29, 2026
aee982d
fix(api): bound Version fields with maxLength to prevent pool-stall a…
rdimitrov Apr 29, 2026
6023f82
fix(validators): URL-escape PyPI and NuGet identifiers before fetch
rdimitrov Apr 29, 2026
f16039e
fix(validators): refuse redirects in MCPB accessibility probe
rdimitrov Apr 29, 2026
7267b2e
fix(handlers): quote user-controlled paths in log lines
rdimitrov Apr 29, 2026
a64d7f0
fix(auth): broaden isBlockedIP coverage for SSRF dial guard
rdimitrov Apr 29, 2026
4899f7d
fix(api): set X-Content-Type-Options on 404 responses
rdimitrov Apr 29, 2026
c6d5d11
fix(auth): probe both pattern shapes when checking BlockedNamespaces
rdimitrov Apr 29, 2026
05d8080
fix(ui): drop connect-src restriction so the base-URL selector keeps …
rdimitrov Apr 29, 2026
bc2e177
fix(auth): bound each safeDialContext attempt with a 3s per-IP timeout
rdimitrov Apr 29, 2026
66ee821
fix(schema): bound Package.version with maxLength in embedded validat…
rdimitrov Apr 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/reference/api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions docs/reference/server-json/draft/server.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
13 changes: 13 additions & 0 deletions internal/api/handlers/v0/auth/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"errors"
"fmt"
"math/big"
"net"
"regexp"
"strings"
"time"
Expand Down Expand Up @@ -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)
Expand Down
49 changes: 49 additions & 0 deletions internal/api/handlers/v0/auth/common_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
11 changes: 0 additions & 11 deletions internal/api/handlers/v0/auth/dns_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
86 changes: 86 additions & 0 deletions internal/api/handlers/v0/auth/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
52 changes: 52 additions & 0 deletions internal/api/handlers/v0/auth/http_internal_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
11 changes: 0 additions & 11 deletions internal/api/handlers/v0/auth/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion internal/api/handlers/v0/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package v0
import (
"context"
"errors"
"log"
"net/http"
"net/url"
"strings"
Expand Down Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions internal/api/handlers/v0/servers.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package v0
import (
"context"
"errors"
"log"
"net/http"
"net/url"
"reflect"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]{
Expand Down Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions internal/api/handlers/v0/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strings"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading