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:
parent
f7663fe91a
commit
1201cbd82b
18 changed files with 330 additions and 48 deletions
|
|
@ -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)
|
||||
|
|
|
|||
49
internal/api/handlers/v0/auth/common_test.go
Normal file
49
internal/api/handlers/v0/auth/common_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
52
internal/api/handlers/v0/auth/http_internal_test.go
Normal file
52
internal/api/handlers/v0/auth/http_internal_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ func WithSkipPaths(paths ...string) MiddlewareOption {
|
|||
// handle404 returns a helpful 404 error with suggestions for common mistakes
|
||||
func handle404(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/problem+json")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
|
||||
path := r.URL.Path
|
||||
|
|
@ -187,8 +188,28 @@ func NewHumaAPI(cfg *config.Config, registry service.RegistryService, mux *http.
|
|||
// Add UI and 404 handler for all other routes
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
// Serve UI at root
|
||||
// Serve UI at root. The page renders publisher-controlled content
|
||||
// (server names, descriptions, repository URLs) — server-side
|
||||
// validation plus a JS escape function are the primary XSS
|
||||
// defences; these headers are defence-in-depth in case any of
|
||||
// those slip.
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
// connect-src is unrestricted because the UI exposes a base-URL
|
||||
// selector (prod / staging / custom) that issues cross-origin
|
||||
// XHRs to whichever target the operator picks. Constraining
|
||||
// connect-src would silently break that affordance. The other
|
||||
// directives still meaningfully limit the page's attack surface.
|
||||
w.Header().Set("Content-Security-Policy",
|
||||
"default-src 'self'; "+
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; "+
|
||||
"style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; "+
|
||||
"img-src 'self' data:; "+
|
||||
"connect-src *; "+
|
||||
"frame-ancestors 'none'; "+
|
||||
"base-uri 'self'; "+
|
||||
"form-action 'self'")
|
||||
_, err := w.Write([]byte(v0.GetUIHTML()))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to write response", http.StatusInternalServerError)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -71,9 +72,11 @@ func TrailingSlashMiddleware(next http.Handler) http.Handler {
|
|||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Only redirect if the path is not "/" and ends with a "/"
|
||||
if r.URL.Path != "/" && strings.HasSuffix(r.URL.Path, "/") {
|
||||
// Create a copy of the URL and remove the trailing slash
|
||||
// path.Clean both removes the trailing slash and collapses any
|
||||
// leading "//" to "/", which prevents an open-redirect via a
|
||||
// protocol-relative path like "//evil.com/" (GHSA-v8vw-gw5j-w7m6).
|
||||
newURL := *r.URL
|
||||
newURL.Path = strings.TrimSuffix(r.URL.Path, "/")
|
||||
newURL.Path = path.Clean(r.URL.Path)
|
||||
|
||||
// Use 308 Permanent Redirect to preserve the request method
|
||||
http.Redirect(w, r, newURL.String(), http.StatusPermanentRedirect)
|
||||
|
|
@ -127,6 +130,17 @@ func NewServer(cfg *config.Config, registryService service.RegistryService, metr
|
|||
Addr: cfg.ServerAddress,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
// WriteTimeout intentionally not set: the publish path runs
|
||||
// outbound package validators sequentially (npm/pypi/nuget up to
|
||||
// 10s each, OCI up to 30s), so any tight cap could cut off a
|
||||
// legitimate multi-package publish mid-response — surfacing as a
|
||||
// truncated read to the publisher even when the DB commit
|
||||
// succeeded. Slow-response-read DoS is bounded upstream by
|
||||
// NGINX ingress timeouts and the per-IP rate limit. Revisit once
|
||||
// validators are parallelised or per-request package counts are
|
||||
// bounded.
|
||||
IdleTimeout: 120 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -232,6 +232,22 @@ func TestTrailingSlashMiddleware(t *testing.T) {
|
|||
expectedLocation: "/v0/servers?limit=10",
|
||||
expectRedirect: true,
|
||||
},
|
||||
{
|
||||
// Regression test for GHSA-v8vw-gw5j-w7m6: a protocol-relative
|
||||
// path like "//evil.com/" must not redirect off-host.
|
||||
name: "protocol-relative path should not redirect off-host",
|
||||
path: "//evil.com/",
|
||||
expectedStatus: http.StatusPermanentRedirect,
|
||||
expectedLocation: "/evil.com",
|
||||
expectRedirect: true,
|
||||
},
|
||||
{
|
||||
name: "path with multiple leading slashes should be collapsed",
|
||||
path: "///evil.com/foo/",
|
||||
expectedStatus: http.StatusPermanentRedirect,
|
||||
expectedLocation: "/evil.com/foo",
|
||||
expectRedirect: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
|
|||
|
|
@ -81,10 +81,17 @@ func (j *JWTManager) GenerateTokenResponse(_ context.Context, claims JWTClaims)
|
|||
}
|
||||
}
|
||||
|
||||
// Check permissions against denylist, provided they are not an admin
|
||||
// Check permissions against denylist, provided they are not an admin.
|
||||
// Probe two synthetic resources per blocked namespace so that both the
|
||||
// slash-suffix patterns (e.g. com.evil/*) and the dot-wildcard patterns
|
||||
// (e.g. com.evil.mailer.* — granted to a subdomain claimant) are
|
||||
// detected. Probing only "<blocked>/test" misses the dot-wildcard form
|
||||
// because the prefix match against "com.evil.mailer." does not start
|
||||
// with "com.evil/test".
|
||||
if !hasGlobalPermissions {
|
||||
for _, blockedNamespace := range BlockedNamespaces {
|
||||
if j.HasPermission(blockedNamespace+"/test", PermissionActionPublish, claims.Permissions) {
|
||||
if j.HasPermission(blockedNamespace+"/test", PermissionActionPublish, claims.Permissions) ||
|
||||
j.HasPermission(blockedNamespace+".test/x", PermissionActionPublish, claims.Permissions) {
|
||||
return nil, fmt.Errorf("your namespace is blocked. raise an issue at https://github.com/modelcontextprotocol/registry/ if you think this is a mistake")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,8 +118,13 @@ func buildFilterConditions(filter *ServerFilter, argIndex int) ([]string, []any,
|
|||
argIndex++
|
||||
}
|
||||
if filter.SubstringName != nil {
|
||||
conditions = append(conditions, fmt.Sprintf("server_name ILIKE $%d", argIndex))
|
||||
args = append(args, "%"+*filter.SubstringName+"%")
|
||||
// Escape LIKE metacharacters so that user input cannot expand into
|
||||
// wildcard matches (e.g. `?search=_` matching every single-char name,
|
||||
// `?search=%` matching everything). Order matters: backslashes must be
|
||||
// escaped first so subsequent escape backslashes are not double-escaped.
|
||||
escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(*filter.SubstringName)
|
||||
conditions = append(conditions, fmt.Sprintf("server_name ILIKE $%d ESCAPE '\\'", argIndex))
|
||||
args = append(args, "%"+escaped+"%")
|
||||
argIndex++
|
||||
}
|
||||
if filter.Version != nil {
|
||||
|
|
|
|||
|
|
@ -54,8 +54,17 @@ func ValidateMCPB(ctx context.Context, pkg model.Package, _ string) error {
|
|||
return fmt.Errorf("MCPB package URL must contain 'mcp': %s", pkg.Identifier)
|
||||
}
|
||||
|
||||
// Verify the file exists and is publicly accessible
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
// Verify the file exists and is publicly accessible. Refuse to follow
|
||||
// redirects: the URL allowlist (github.com / gitlab.com) only constrains
|
||||
// the FIRST hop, and a 30x bouncing through CDN/release-asset hosts
|
||||
// could otherwise be steered toward attacker infrastructure or
|
||||
// internal-only endpoints reached via DNS quirks.
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodHead, pkg.Identifier, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
|
|
@ -69,7 +78,15 @@ func ValidateMCPB(ctx context.Context, pkg model.Package, _ string) error {
|
|||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// GitHub serves release assets via a 302 to a signed S3 URL; a HEAD that
|
||||
// returns 200 directly OR a 302 with a Location header is acceptable.
|
||||
// Any other 3xx without a usable Location is treated as inaccessible.
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusOK:
|
||||
// fine
|
||||
case resp.StatusCode >= 300 && resp.StatusCode < 400 && resp.Header.Get("Location") != "":
|
||||
// fine — first-hop allowlist + we don't actually need the body
|
||||
default:
|
||||
return fmt.Errorf("MCPB package '%s' is not publicly accessible (status: %d)", pkg.Identifier, resp.StatusCode)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
|
@ -209,9 +210,11 @@ func validateReadme(ctx context.Context, serverName, lowerID, lowerVersion strin
|
|||
return NoReadme, fmt.Errorf("failed to get README URL template: %w", err)
|
||||
}
|
||||
|
||||
// Replace placeholders in the template
|
||||
readmeURL := strings.ReplaceAll(readmeURLTemplate, "{lower_id}", lowerID)
|
||||
readmeURL = strings.ReplaceAll(readmeURL, "{lower_version}", lowerVersion)
|
||||
// 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)
|
||||
|
|
@ -266,8 +269,10 @@ func validatePackageExists(ctx context.Context, lowerID, lowerVersion string, cl
|
|||
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
|
||||
indexURL := fmt.Sprintf("%s/%s/index.json", strings.TrimRight(packageBaseURL, "/"), lowerID)
|
||||
// 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 {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -52,8 +53,13 @@ func ValidatePyPI(ctx context.Context, pkg model.Package, serverName string) err
|
|||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
url := fmt.Sprintf("%s/pypi/%s/%s/json", pkg.RegistryBaseURL, pkg.Identifier, pkg.Version)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
// PathEscape so an identifier that smuggles "/" or ".." cannot redirect
|
||||
// the metadata fetch to a different package than the one being claimed.
|
||||
fetchURL := fmt.Sprintf("%s/pypi/%s/%s/json",
|
||||
pkg.RegistryBaseURL,
|
||||
url.PathEscape(pkg.Identifier),
|
||||
url.PathEscape(pkg.Version))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fetchURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -283,6 +283,7 @@
|
|||
"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",
|
||||
"minLength": 1,
|
||||
"maxLength": 255,
|
||||
"not": {
|
||||
"const": "latest"
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue