diff --git a/server/router/mcp/service.go b/server/router/mcp/service.go index 7661e3948..b7c4a1921 100644 --- a/server/router/mcp/service.go +++ b/server/router/mcp/service.go @@ -57,6 +57,13 @@ func NewMCPService(profile *profile.Profile, echoServer *echo.Echo) (*MCPService }, &sdkmcp.StreamableHTTPOptions{ Stateless: true, JSONResponse: true, + // memos is typically served behind a reverse proxy with the app bound to a + // loopback address while the public Host header is a real domain. The SDK's + // DNS-rebinding guard treats that shape as an attack and rejects every + // request with 403 ("invalid Host header"). Disable it and rely on memos' + // own Origin/Host allowlist (see RegisterRoutes -> isAllowedMCPOrigin) for + // CSRF / DNS-rebinding protection instead. + DisableLocalhostProtection: true, }) return &MCPService{ diff --git a/server/router/mcp/service_test.go b/server/router/mcp/service_test.go index e1ea1efe7..ebfb3ab3d 100644 --- a/server/router/mcp/service_test.go +++ b/server/router/mcp/service_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "net" "net/http" "net/http/httptest" "os" @@ -246,6 +247,55 @@ func TestMCPToolCallRejectsInvalidArguments(t *testing.T) { require.Zero(t, routeHits) } +// TestMCPLoopbackBehindReverseProxy verifies that a loopback-bound instance +// served under a non-loopback Host (the reverse-proxy deployment shape) is no +// longer rejected by the SDK's DNS-rebinding guard, while memos' own Origin +// allowlist still rejects disallowed origins. +func TestMCPLoopbackBehindReverseProxy(t *testing.T) { + echoServer := echo.New() + service, err := NewMCPService(&profile.Profile{Version: "test-version"}, echoServer) + require.NoError(t, err) + service.RegisterRoutes(echoServer) + + initialize, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": map[string]any{ + "protocolVersion": "2025-06-18", + "capabilities": map[string]any{}, + "clientInfo": map[string]any{"name": "memos-test", "version": "1.0.0"}, + }, + }) + require.NoError(t, err) + + // Simulate the proxied deployment: the connection terminates on a loopback + // address, but the public Host header is a real domain. + newRequest := func() *http.Request { + request := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(initialize)) + request.Host = "demo.usememos.com" + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json, text/event-stream") + loopback := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 5230} + ctx := context.WithValue(request.Context(), http.LocalAddrContextKey, loopback) + return request.WithContext(ctx) + } + + t.Run("allows non-loopback host with no origin", func(t *testing.T) { + recorder := httptest.NewRecorder() + echoServer.ServeHTTP(recorder, newRequest()) + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + }) + + t.Run("still rejects a disallowed origin", func(t *testing.T) { + request := newRequest() + request.Header.Set("Origin", "https://evil.example.com") + recorder := httptest.NewRecorder() + echoServer.ServeHTTP(recorder, request) + require.Equal(t, http.StatusForbidden, recorder.Code) + }) +} + func initializeMCP(t *testing.T, echoServer *echo.Echo) { t.Helper() response := postMCP(t, echoServer, map[string]any{