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

## 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>
This commit is contained in:
Radoslav Dimitrov 2026-04-29 19:14:00 -04:00 • committed by GitHub
commit 1201cbd82b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 330 additions and 48 deletions

View file

@ -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)

View file

@ -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)
}
})
}
}

View file

@ -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",

View file

@ -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 {

View file

@ -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)
}
})
}
}

View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -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