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.
pull/6027/head
johnnyjoygh 3 weeks ago
parent 96cb65320b
commit 8fa2ff4423

@ -57,6 +57,13 @@ func NewMCPService(profile *profile.Profile, echoServer *echo.Echo) (*MCPService
}, &sdkmcp.StreamableHTTPOptions{ }, &sdkmcp.StreamableHTTPOptions{
Stateless: true, Stateless: true,
JSONResponse: 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{ return &MCPService{

@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@ -246,6 +247,55 @@ func TestMCPToolCallRejectsInvalidArguments(t *testing.T) {
require.Zero(t, routeHits) 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) { func initializeMCP(t *testing.T, echoServer *echo.Echo) {
t.Helper() t.Helper()
response := postMCP(t, echoServer, map[string]any{ response := postMCP(t, echoServer, map[string]any{

Loading…
Cancel
Save