fix(mcp): allow reverse-proxied instances to serve /mcp
The go-sdk Streamable HTTP handler enables DNS-rebinding protection that rejects any request whose Host header is non-loopback while the server is bound to a loopback address. memos is commonly run bound to loopback behind a reverse proxy (e.g. the public demo), so every /mcp request was rejected with "403 Forbidden: invalid Host header" before authentication ran. Disable the SDK's localhost protection and rely on memos' own Origin/Host allowlist (isAllowedMCPOrigin) for CSRF / DNS-rebinding protection. Add a regression test covering the proxied shape and confirming disallowed origins are still rejected.
This commit is contained in:
@@ -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{
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user