diff --git a/docs/configuration.md b/docs/configuration.md index 92e530e97..84bb6ed8d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -743,6 +743,11 @@ Add the public domain(s) to `trusted_hosts` to allow them: - A request that carries an `Origin` header must likewise have a loopback or trusted origin host (MCP spec requirement); requests without `Origin` (non-browser clients, reverse proxies) are never rejected by the Origin check. +- The same allowlist drives CORS on the REST API (`/api/v1/*`) and the `/events` SSE + stream: the request `Origin` is echoed back in `Access-Control-Allow-Origin` only when + it is loopback or trusted, and no CORS headers are sent otherwise. Earlier versions + sent `Access-Control-Allow-Origin: *` there unconditionally, so a separate web app that + calls the REST API cross-origin now needs its host in `trusted_hosts`. - Loopback hosts (`localhost`, `127.0.0.1`, `[::1]`) are always accepted; requests on non-loopback listeners are never subject to Host validation. - Environment override: `MCPPROXY_TRUSTED_HOSTS` (comma-separated list). diff --git a/docs/operations/reverse-proxy.md b/docs/operations/reverse-proxy.md index 4e254d0e5..8d0f83024 100644 --- a/docs/operations/reverse-proxy.md +++ b/docs/operations/reverse-proxy.md @@ -74,6 +74,24 @@ client served **from your public domain** works as soon as that domain is in frontend hosted on a **different** origin must have its own host added to `trusted_hosts` too, or its requests are rejected. +### CORS on the REST API and `/events` + +The same allowlist decides CORS on the REST API (`/api/v1/*`) and the SSE stream +(`/events`): MCPProxy echoes the request `Origin` in +`Access-Control-Allow-Origin` only when that origin is loopback or in +`trusted_hosts`, and sends no CORS headers at all otherwise. Every response +carries `Vary: Origin`. + +:::caution Behaviour change +Earlier versions sent `Access-Control-Allow-Origin: *` on these endpoints +unconditionally. If a **separate** web app calls the REST API cross-origin from a +public domain, add that app's host to `trusted_hosts` — unlike the `Host` check, +which only ever applied to the MCP endpoints, this one applies to the REST +surface, so a REST-only proxied deployment may have been running with +`trusted_hosts` empty. The embedded Web UI is same-origin under `/ui/` and is +unaffected, as are non-browser clients, which send no `Origin` at all. +::: + ## `trusted_proxies` — forwarded headers {#trusted_proxies-forwarded-headers} A reverse proxy rewrites the connection MCPProxy sees: the peer address becomes the diff --git a/internal/httpapi/cors_test.go b/internal/httpapi/cors_test.go new file mode 100644 index 000000000..9893e047b --- /dev/null +++ b/internal/httpapi/cors_test.go @@ -0,0 +1,172 @@ +package httpapi + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + + "github.com/stretchr/testify/assert" + "go.uber.org/zap" +) + +// corsController supplies both config doors the CORS middleware depends on: +// GetCurrentConfig (read by apiKeyAuthMiddleware) and GetConfig (read by +// trustedHostsProvider). +type corsController struct { + baseController + apiKey string + trustedHosts []string +} + +func (m *corsController) GetCurrentConfig() any { + return &config.Config{APIKey: m.apiKey} +} + +func (m *corsController) GetConfig() (*config.Config, error) { + return &config.Config{APIKey: m.apiKey, TrustedHosts: m.trustedHosts}, nil +} + +const corsTestAPIKey = "test-api-key" + +func newCORSServer(trustedHosts []string) *Server { + return NewServer(&corsController{apiKey: corsTestAPIKey, trustedHosts: trustedHosts}, zap.NewNop().Sugar(), nil) +} + +// TestCORSOriginAllowlist pins the exact Access-Control-Allow-Origin behavior +// on both doors that emit it: the shared /api/v1 surface and the /events SSE +// stream. The wildcard "*" must never appear, and a disallowed (or absent) +// Origin must produce no CORS headers at all. +func TestCORSOriginAllowlist(t *testing.T) { + tests := []struct { + name string + origin string + trustedHosts []string + wantEcho bool + }{ + {name: "absent origin", origin: "", wantEcho: false}, + {name: "loopback ipv4 with port", origin: "http://127.0.0.1:5173", wantEcho: true}, + {name: "localhost with port", origin: "http://localhost:3000", wantEcho: true}, + {name: "loopback ipv6 bracketed", origin: "http://[::1]:8080", wantEcho: true}, + {name: "remote https origin", origin: "https://evil.example", wantEcho: false}, + {name: "null origin", origin: "null", wantEcho: false}, + {name: "loopback suffix confusion", origin: "http://127.0.0.1.evil.com", wantEcho: false}, + {name: "origin with path is not an origin", origin: "http://localhost:3000/path", wantEcho: false}, + { + name: "trusted host echoed", + origin: "https://ui.example.com", + trustedHosts: []string{"ui.example.com"}, + wantEcho: true, + }, + { + name: "wildcard trusted host echoes concrete origin", + origin: "https://evil.example", + trustedHosts: []string{"*"}, + wantEcho: true, + }, + } + + doors := []struct { + name string + method string + path string + }{ + // HEAD, not GET: handleSSEEvents returns immediately on HEAD while a + // GET blocks on the live stream. + {name: "api", method: http.MethodGet, path: "/api/v1/status"}, + {name: "sse", method: http.MethodHead, path: "/events"}, + } + + for _, tc := range tests { + for _, door := range doors { + t.Run(tc.name+"/"+door.name, func(t *testing.T) { + srv := newCORSServer(tc.trustedHosts) + + req := httptest.NewRequest(door.method, door.path, nil) + req.Header.Set("X-API-Key", corsTestAPIKey) + if tc.origin != "" { + req.Header.Set("Origin", tc.origin) + } + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + gotOrigin := w.Header().Get("Access-Control-Allow-Origin") + assert.NotEqual(t, "*", gotOrigin, "wildcard CORS origin must never be emitted") + + if tc.wantEcho { + assert.Equal(t, tc.origin, gotOrigin, "allowed origin must be echoed verbatim") + } else { + assert.Empty(t, gotOrigin, "disallowed origin must get no Access-Control-Allow-Origin") + assert.Empty(t, w.Header().Get("Access-Control-Allow-Methods")) + assert.Empty(t, w.Header().Get("Access-Control-Allow-Headers")) + } + + // Vary: Origin is required on every response, allowed or not, + // so shared caches never serve one origin's response to another. + assert.Contains(t, w.Header().Values("Vary"), "Origin", "Vary must advertise Origin") + + // Credentialed CORS is never enabled. + assert.Empty(t, w.Header().Get("Access-Control-Allow-Credentials")) + }) + } + } +} + +// TestCORSOnUnauthenticatedResponse checks that the CORS decision is made +// ahead of authentication, so a 401 still carries Vary: Origin and does not +// become a cache entry served to a different origin. +func TestCORSOnUnauthenticatedResponse(t *testing.T) { + srv := newCORSServer(nil) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/servers", nil) + req.Header.Set("Origin", "http://localhost:5173") + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Contains(t, w.Header().Values("Vary"), "Origin") + assert.Equal(t, "http://localhost:5173", w.Header().Get("Access-Control-Allow-Origin")) +} + +// TestCORSPreflight pins the OPTIONS short-circuit, which runs ahead of +// apiKeyAuthMiddleware: browsers never send credentials on a preflight, so it +// must stay unauthenticated, but it must still only advertise CORS to an +// allowed origin. +func TestCORSPreflight(t *testing.T) { + t.Run("allowed origin", func(t *testing.T) { + srv := newCORSServer(nil) + + req := httptest.NewRequest(http.MethodOptions, "/api/v1/servers", nil) + req.Header.Set("Origin", "http://localhost:5173") + req.Header.Set("Access-Control-Request-Method", "POST") + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + assert.Less(t, w.Code, 300, "preflight must not require an API key") + assert.Equal(t, "http://localhost:5173", w.Header().Get("Access-Control-Allow-Origin")) + // PATCH is advertised: the REST API registers PATCH routes + // (/api/v1/servers/{id}, /api/v1/config), and omitting it makes them + // unreachable from an allowed cross-origin caller. + assert.Equal(t, "GET, POST, PUT, PATCH, DELETE, OPTIONS", w.Header().Get("Access-Control-Allow-Methods")) + assert.NotEmpty(t, w.Header().Get("Access-Control-Allow-Headers")) + assert.Contains(t, w.Header().Values("Vary"), "Origin") + assert.Empty(t, w.Header().Get("Access-Control-Allow-Credentials")) + }) + + t.Run("disallowed origin", func(t *testing.T) { + srv := newCORSServer(nil) + + req := httptest.NewRequest(http.MethodOptions, "/api/v1/servers", nil) + req.Header.Set("Origin", "https://evil.example") + req.Header.Set("Access-Control-Request-Method", "POST") + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + assert.Less(t, w.Code, 300, "preflight still short-circuits") + assert.Empty(t, w.Header().Get("Access-Control-Allow-Origin")) + assert.Empty(t, w.Header().Get("Access-Control-Allow-Methods")) + assert.Empty(t, w.Header().Get("Access-Control-Allow-Headers")) + assert.Contains(t, w.Header().Values("Vary"), "Origin") + }) +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 894093ae9..c2a07d2da 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -25,6 +25,7 @@ import ( "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" "github.com/smart-mcp-proxy/mcpproxy-go/internal/connect" "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/httpx" "github.com/smart-mcp-proxy/mcpproxy-go/internal/launch" "github.com/smart-mcp-proxy/mcpproxy-go/internal/logs" "github.com/smart-mcp-proxy/mcpproxy-go/internal/management" @@ -761,6 +762,64 @@ func (s *Server) trustedProxiesProvider() config.TrustedProxiesProvider { } } +// trustedHostsProvider yields the LIVE trusted_hosts list through the +// controller's config, evaluated per request so hot-reload takes effect +// without a restart. Same shape as trustedProxiesProvider: the nil guards are +// load-bearing because test controllers return a nil config. +func (s *Server) trustedHostsProvider() func() []string { + return func() []string { + if s.controller == nil { + return nil + } + if cfg, err := s.controller.GetConfig(); err == nil && cfg != nil { + return cfg.TrustedHosts + } + return nil + } +} + +// corsMiddleware replaces the former unconditional +// "Access-Control-Allow-Origin: *" (SEC-04). It echoes the request Origin only +// when that origin passes the same allowlist the MCP surface uses +// (httpx.OriginAllowed: loopback on any port, or a host in config +// trusted_hosts) and emits nothing at all otherwise - including when there is +// no Origin header, which is every non-browser client. The embedded Web UI is +// same-origin under /ui/, so nothing legitimate needs the wildcard. +// +// The OPTIONS short-circuit stays ahead of apiKeyAuthMiddleware: browsers +// never send credentials on a preflight, so gating it behind the API key would +// break legitimate cross-origin use. +func (s *Server) corsMiddleware() func(http.Handler) http.Handler { + trustedHosts := s.trustedHostsProvider() + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Add, never Set: another layer may already have written a Vary + // and clobbering it would poison shared caches. + w.Header().Add("Vary", "Origin") + + origin := r.Header.Get("Origin") + if origin != "" && httpx.OriginAllowed(origin, trustedHosts()) { + // Echo the concrete origin rather than "*", even when + // trusted_hosts is ["*"]: a reflected origin is what lets a + // browser cache the response per origin alongside Vary, and + // "*" is illegal on a credentialed response. (Reflecting an + // arbitrary origin is NOT itself a substitute for the + // allowlist — trusted_hosts ["*"] deliberately disables it.) + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key") + } + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + next.ServeHTTP(w, r) + }) + } +} + // setupRoutes configures all API routes func (s *Server) setupRoutes() { s.logger.Debug("Setting up HTTP API routes") @@ -780,21 +839,9 @@ func (s *Server) setupRoutes() { s.router.Use(s.correlationIDMiddleware()) // Correlation ID and request source tracking s.logger.Debug("Core middleware configured (request ID, logging, recovery, correlation ID)") - // CORS headers for browser access - s.router.Use(func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key") - - if r.Method == "OPTIONS" { - w.WriteHeader(http.StatusOK) - return - } - - next.ServeHTTP(w, r) - }) - }) + // CORS headers for browser access (SEC-04). Registered with Use so it + // covers every route on this router, /events included. + s.router.Use(s.corsMiddleware()) // Health and readiness endpoints (Kubernetes-compatible with legacy aliases) // See healthzHandler() and readyzHandler() for swagger documentation @@ -3954,7 +4001,9 @@ func (s *Server) handleSSEEvents(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") - w.Header().Set("Access-Control-Allow-Origin", "*") + // No Access-Control-Allow-Origin here: /events is a chi route on the same + // router, so corsMiddleware already decided this response's CORS headers. + // A second write would only be a place for the two policies to drift. w.Header().Set("X-Accel-Buffering", "no") // Disable nginx buffering // For HEAD requests, just return headers without body diff --git a/internal/httpx/origin.go b/internal/httpx/origin.go new file mode 100644 index 000000000..4193460ea --- /dev/null +++ b/internal/httpx/origin.go @@ -0,0 +1,114 @@ +// Package httpx holds HTTP primitives shared by the MCP surface +// (internal/server) and the REST/SSE surface (internal/httpapi). It is a leaf +// package: it imports nothing from the rest of the tree, so both surfaces can +// depend on it without an import cycle (internal/server already imports +// internal/httpapi). +package httpx + +import ( + "net" + "net/netip" + "net/url" + "strconv" + "strings" +) + +// IsLoopbackHost reports whether addr refers to a loopback interface. addr may +// be a bare host ("localhost", "127.0.0.1", "::1", "[::1]") or a host:port +// pair ("localhost:3000", "127.0.0.1:3000", "[::1]:3000"). +func IsLoopbackHost(addr string) bool { + host, _, err := net.SplitHostPort(addr) + if err != nil { + // addr might be a bare host without a port. + host = strings.Trim(addr, "[]") + } + if strings.EqualFold(host, "localhost") { + return true + } + ip, err := netip.ParseAddr(host) + if err != nil { + return false + } + return ip.IsLoopback() +} + +// HostMatchesTrusted reports whether host matches one of the configured +// trusted_hosts entries. Matching is case-insensitive on the hostname. An +// entry without a port matches that hostname on any port; an entry with a port +// requires the request port to match too. An entry with a leading dot is a +// subdomain wildcard (Django/Vite/webpack convention): ".example.com" matches +// example.com and every subdomain of it. The single entry "*" disables +// validation entirely. +func HostMatchesTrusted(host string, trusted []string) bool { + reqHost, reqPort, err := net.SplitHostPort(host) + if err != nil { + reqHost, reqPort = strings.Trim(host, "[]"), "" + } + // A trailing dot ("example.com.") is DNS-equivalent to the undotted name. + reqHost = strings.TrimSuffix(reqHost, ".") + for _, entry := range trusted { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + if entry == "*" { + return true + } + entryHost, entryPort, err := net.SplitHostPort(entry) + if err != nil { + entryHost, entryPort = strings.Trim(entry, "[]"), "" + } + if bare, isWildcard := strings.CutPrefix(entryHost, "."); isWildcard { + if !strings.EqualFold(reqHost, bare) && !hasSuffixFold(reqHost, "."+bare) { + continue + } + } else if !strings.EqualFold(reqHost, entryHost) { + continue + } + if entryPort == "" || entryPort == reqPort { + return true + } + } + return false +} + +// hasSuffixFold reports whether s ends with suffix, case-insensitively. +func hasSuffixFold(s, suffix string) bool { + return len(s) >= len(suffix) && strings.EqualFold(s[len(s)-len(suffix):], suffix) +} + +// OriginAllowed implements the MCP spec's Origin validation (2025-11-25 basic +// security best practices): a present Origin must be a well-formed serialized +// origin — scheme://host[:port] over http(s)/ws(s), no userinfo, path, query, +// or fragment — whose host is loopback or trusted. "null", unparseable, and +// non-origin-shaped values are invalid. Absence is handled by the caller +// (non-browser clients don't send Origin at all). +func OriginAllowed(origin string, trusted []string) bool { + // A query or fragment delimiter disqualifies the value even when what + // follows it is empty: url.Parse records a bare "?" in ForceQuery (not + // RawQuery) and drops a bare "#" entirely, so the struct checks below + // cannot see either one. + if strings.ContainsAny(origin, "?#") { + return false + } + u, err := url.Parse(origin) + if err != nil || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" { + return false + } + switch strings.ToLower(u.Scheme) { + case "http", "https", "ws", "wss": + default: + return false + } + // url.Parse tolerates a dangling colon ("host:") and any digit run as a + // port; a serialized origin's port must be a real one. + if strings.HasSuffix(u.Host, ":") { + return false + } + if p := u.Port(); p != "" { + if n, err := strconv.Atoi(p); err != nil || n < 1 || n > 65535 { + return false + } + } + return IsLoopbackHost(u.Host) || HostMatchesTrusted(u.Host, trusted) +} diff --git a/internal/httpx/origin_test.go b/internal/httpx/origin_test.go new file mode 100644 index 000000000..df2f6dffd --- /dev/null +++ b/internal/httpx/origin_test.go @@ -0,0 +1,101 @@ +package httpx + +import "testing" + +func TestIsLoopbackHost(t *testing.T) { + tests := []struct { + addr string + want bool + }{ + {"localhost", true}, + {"LocalHost:3000", true}, + {"127.0.0.1", true}, + {"127.0.0.1:8080", true}, + {"127.9.9.9", true}, + {"::1", true}, + {"[::1]", true}, + {"[::1]:8080", true}, + {"example.com", false}, + {"example.com:443", false}, + {"127.0.0.1.evil.com", false}, + {"", false}, + {"10.0.0.1", false}, + } + for _, tc := range tests { + if got := IsLoopbackHost(tc.addr); got != tc.want { + t.Errorf("IsLoopbackHost(%q) = %v, want %v", tc.addr, got, tc.want) + } + } +} + +func TestHostMatchesTrusted(t *testing.T) { + tests := []struct { + name string + host string + trusted []string + want bool + }{ + {name: "empty list matches nothing", host: "example.com", want: false}, + {name: "exact", host: "example.com", trusted: []string{"example.com"}, want: true}, + {name: "case insensitive", host: "EXAMPLE.com", trusted: []string{"example.COM"}, want: true}, + {name: "entry without port matches any port", host: "example.com:8443", trusted: []string{"example.com"}, want: true}, + {name: "entry with port must match", host: "example.com:8443", trusted: []string{"example.com:443"}, want: false}, + {name: "entry with matching port", host: "example.com:443", trusted: []string{"example.com:443"}, want: true}, + {name: "trailing dot is DNS-equivalent", host: "example.com.", trusted: []string{"example.com"}, want: true}, + {name: "subdomain wildcard matches bare", host: "example.com", trusted: []string{".example.com"}, want: true}, + {name: "subdomain wildcard matches child", host: "ui.example.com", trusted: []string{".example.com"}, want: true}, + {name: "subdomain wildcard rejects suffix confusion", host: "notexample.com", trusted: []string{".example.com"}, want: false}, + {name: "star matches everything", host: "evil.example", trusted: []string{"*"}, want: true}, + {name: "blank entries skipped", host: "example.com", trusted: []string{"", " "}, want: false}, + {name: "bracketed ipv6", host: "[2001:db8::1]:8080", trusted: []string{"2001:db8::1"}, want: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := HostMatchesTrusted(tc.host, tc.trusted); got != tc.want { + t.Errorf("HostMatchesTrusted(%q, %v) = %v, want %v", tc.host, tc.trusted, got, tc.want) + } + }) + } +} + +func TestOriginAllowed(t *testing.T) { + tests := []struct { + name string + origin string + trusted []string + want bool + }{ + {name: "loopback ipv4 any port", origin: "http://127.0.0.1:5173", want: true}, + {name: "localhost", origin: "http://localhost:3000", want: true}, + {name: "bracketed ipv6 loopback", origin: "http://[::1]:8080", want: true}, + {name: "https loopback", origin: "https://localhost", want: true}, + {name: "ws scheme", origin: "ws://127.0.0.1:9000", want: true}, + {name: "remote origin untrusted", origin: "https://evil.example", want: false}, + {name: "remote origin trusted", origin: "https://ui.example.com", trusted: []string{"ui.example.com"}, want: true}, + {name: "null", origin: "null", want: false}, + {name: "empty", origin: "", want: false}, + {name: "suffix confusion", origin: "http://127.0.0.1.evil.com", want: false}, + {name: "path is not an origin", origin: "http://localhost:3000/path", want: false}, + {name: "query is not an origin", origin: "http://localhost:3000?a=1", want: false}, + {name: "fragment is not an origin", origin: "http://localhost:3000#x", want: false}, + // An empty delimiter still makes the value something other than a + // serialized origin, and url.Parse hides it in ForceQuery / a dropped + // fragment rather than in RawQuery / Fragment. + {name: "empty query delimiter", origin: "http://localhost:3000?", want: false}, + {name: "empty fragment delimiter", origin: "http://localhost:3000#", want: false}, + {name: "trusted host with empty query delimiter", origin: "https://ui.example.com?", trusted: []string{"ui.example.com"}, want: false}, + {name: "userinfo rejected", origin: "http://user@localhost:3000", want: false}, + {name: "file scheme rejected", origin: "file://localhost", want: false}, + {name: "dangling colon rejected", origin: "http://localhost:", want: false}, + {name: "port out of range", origin: "http://localhost:99999", want: false}, + {name: "port zero", origin: "http://localhost:0", want: false}, + {name: "star trusted echoes allowance", origin: "https://evil.example", trusted: []string{"*"}, want: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := OriginAllowed(tc.origin, tc.trusted); got != tc.want { + t.Errorf("OriginAllowed(%q, %v) = %v, want %v", tc.origin, tc.trusted, got, tc.want) + } + }) + } +} diff --git a/internal/server/host_validation.go b/internal/server/host_validation.go index a26861f1b..ef21878dc 100644 --- a/internal/server/host_validation.go +++ b/internal/server/host_validation.go @@ -4,105 +4,25 @@ import ( "fmt" "net" "net/http" - "net/netip" - "net/url" - "strconv" - "strings" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/httpx" "go.uber.org/zap" ) -// isLoopbackHost reports whether addr refers to a loopback interface. addr may -// be a bare host ("localhost", "127.0.0.1", "::1", "[::1]") or a host:port -// pair ("localhost:3000", "127.0.0.1:3000", "[::1]:3000"). -func isLoopbackHost(addr string) bool { - host, _, err := net.SplitHostPort(addr) - if err != nil { - // addr might be a bare host without a port. - host = strings.Trim(addr, "[]") - } - if strings.EqualFold(host, "localhost") { - return true - } - ip, err := netip.ParseAddr(host) - if err != nil { - return false - } - return ip.IsLoopback() -} +// isLoopbackHost delegates to httpx.IsLoopbackHost. The MCP surface and the +// REST/SSE surface must share one origin policy, so the implementation lives in +// the leaf package internal/httpx; these thin wrappers keep this file readable. +func isLoopbackHost(addr string) bool { return httpx.IsLoopbackHost(addr) } -// hostMatchesTrusted reports whether the request Host header matches one of the -// configured trusted_hosts entries. Matching is case-insensitive on the -// hostname. An entry without a port matches that hostname on any port; an -// entry with a port requires the request port to match too. An entry with a -// leading dot is a subdomain wildcard (Django/Vite/webpack convention): -// ".example.com" matches example.com and every subdomain of it. The single -// entry "*" disables Host validation entirely. +// hostMatchesTrusted delegates to httpx.HostMatchesTrusted. func hostMatchesTrusted(host string, trusted []string) bool { - reqHost, reqPort, err := net.SplitHostPort(host) - if err != nil { - reqHost, reqPort = strings.Trim(host, "[]"), "" - } - // A trailing dot ("example.com.") is DNS-equivalent to the undotted name. - reqHost = strings.TrimSuffix(reqHost, ".") - for _, entry := range trusted { - entry = strings.TrimSpace(entry) - if entry == "" { - continue - } - if entry == "*" { - return true - } - entryHost, entryPort, err := net.SplitHostPort(entry) - if err != nil { - entryHost, entryPort = strings.Trim(entry, "[]"), "" - } - if bare, isWildcard := strings.CutPrefix(entryHost, "."); isWildcard { - if !strings.EqualFold(reqHost, bare) && !hasSuffixFold(reqHost, "."+bare) { - continue - } - } else if !strings.EqualFold(reqHost, entryHost) { - continue - } - if entryPort == "" || entryPort == reqPort { - return true - } - } - return false + return httpx.HostMatchesTrusted(host, trusted) } -// hasSuffixFold reports whether s ends with suffix, case-insensitively. -func hasSuffixFold(s, suffix string) bool { - return len(s) >= len(suffix) && strings.EqualFold(s[len(s)-len(suffix):], suffix) -} - -// originAllowed implements the MCP spec's Origin validation (2025-11-25 basic -// security best practices): a present Origin must be a well-formed serialized -// origin — scheme://host[:port] over http(s)/ws(s), no userinfo, path, query, -// or fragment — whose host is loopback or trusted. "null", unparseable, and -// non-origin-shaped values are invalid. Absence is handled by the caller -// (non-browser clients don't send Origin at all). +// originAllowed delegates to httpx.OriginAllowed. func originAllowed(origin string, trusted []string) bool { - u, err := url.Parse(origin) - if err != nil || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" { - return false - } - switch strings.ToLower(u.Scheme) { - case "http", "https", "ws", "wss": - default: - return false - } - // url.Parse tolerates a dangling colon ("host:") and any digit run as a - // port; a serialized origin's port must be a real one. - if strings.HasSuffix(u.Host, ":") { - return false - } - if p := u.Port(); p != "" { - if n, err := strconv.Atoi(p); err != nil || n < 1 || n > 65535 { - return false - } - } - return isLoopbackHost(u.Host) || hostMatchesTrusted(u.Host, trusted) + return httpx.OriginAllowed(origin, trusted) } // newHostValidationHandler applies DNS-rebinding protection with a