diff --git a/pkg/transport/middleware/origin/origin.go b/pkg/transport/middleware/origin/origin.go index 556a988f66..59322d3125 100644 --- a/pkg/transport/middleware/origin/origin.go +++ b/pkg/transport/middleware/origin/origin.go @@ -37,6 +37,10 @@ const ( // forbiddenBodyFallback is returned if JSON marshalling of the error body // fails (should never happen with simple map types). forbiddenBodyFallback = `{"jsonrpc":"2.0","error":{"code":-32600,"message":"Origin not allowed"}}` + + accessControlAllowMethods = "GET, POST, DELETE, OPTIONS" + accessControlAllowHeaders = "Authorization, Content-Type, Accept, Mcp-Session-Id, MCP-Protocol-Version, Last-Event-ID" + accessControlExposeHeaders = "Mcp-Session-Id, Last-Event-ID" ) // MiddlewareParams holds the parameters for the origin middleware factory. @@ -93,9 +97,10 @@ func CreateMiddleware(config *types.MiddlewareConfig, runner types.MiddlewareRun // Warning" — requests whose Origin header is present and not in allowedOrigins // receive HTTP 403 with a JSON-RPC error body. // -// What this does NOT solve: CORS, CSRF token validation, authentication, or -// Origin-header injection via trusted reverse proxies (the caller's reverse -// proxy must deduplicate Origin headers upstream). +// This also handles CORS for allowed browser Origins. It does not provide CSRF +// token validation, authentication, or Origin-header injection protection via +// trusted reverse proxies (the caller's reverse proxy must deduplicate Origin +// headers upstream). // // An empty allowedOrigins slice produces a pass-through handler — the caller // is responsible for deciding whether that is acceptable (e.g. when bind is @@ -157,11 +162,25 @@ func NewHandler(allowedOrigins []string) types.MiddlewareFunction { writeForbidden(w) return } + + writeCORSHeaders(w, origin) + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } next.ServeHTTP(w, r) }) } } +func writeCORSHeaders(w http.ResponseWriter, requestOrigin string) { + w.Header().Set("Access-Control-Allow-Origin", requestOrigin) + w.Header().Add("Vary", "Origin") + w.Header().Set("Access-Control-Allow-Methods", accessControlAllowMethods) + w.Header().Set("Access-Control-Allow-Headers", accessControlAllowHeaders) + w.Header().Set("Access-Control-Expose-Headers", accessControlExposeHeaders) +} + // canonicalizeOrigin normalizes an Origin value for exact-match comparison. // It parses the value with net/url.Parse and rebuilds it as // "scheme://host[:port]" with the scheme and host lowercased (RFC 6454 §4 diff --git a/pkg/transport/middleware/origin/origin_test.go b/pkg/transport/middleware/origin/origin_test.go index e3b8683500..7857ee1036 100644 --- a/pkg/transport/middleware/origin/origin_test.go +++ b/pkg/transport/middleware/origin/origin_test.go @@ -172,6 +172,47 @@ func TestOriginMiddleware_MultipleOriginHeadersRejected(t *testing.T) { assertForbiddenJSONRPC(t, rec, nextCalled) } +func TestOriginMiddleware_CORSPreflight(t *testing.T) { + t.Parallel() + + var nextCalled bool + handler := NewHandler([]string{"http://localhost:6274"})(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + nextCalled = true + w.WriteHeader(http.StatusOK) + })) + req := httptest.NewRequest(http.MethodOptions, "/mcp", nil) + req.Header.Set("Origin", "http://localhost:6274") + req.Header.Set("Access-Control-Request-Method", http.MethodPost) + req.Header.Set("Access-Control-Request-Headers", "Content-Type, MCP-Protocol-Version") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.False(t, nextCalled, "preflight must be handled by the middleware") + assert.Equal(t, http.StatusNoContent, rec.Code) + assert.Equal(t, "http://localhost:6274", rec.Header().Get("Access-Control-Allow-Origin")) + assert.Equal(t, "Origin", rec.Header().Get("Vary")) + assert.Contains(t, rec.Header().Get("Access-Control-Allow-Methods"), http.MethodPost) + assert.Contains(t, rec.Header().Get("Access-Control-Allow-Headers"), "MCP-Protocol-Version") +} + +func TestOriginMiddleware_CORSResponseHeaders(t *testing.T) { + t.Parallel() + + handler := NewHandler([]string{"http://localhost:6274"})(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusAccepted) + })) + req := httptest.NewRequest(http.MethodPost, "/mcp", nil) + req.Header.Set("Origin", "http://localhost:6274") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusAccepted, rec.Code) + assert.Equal(t, "http://localhost:6274", rec.Header().Get("Access-Control-Allow-Origin")) + assert.Equal(t, "Origin", rec.Header().Get("Vary")) +} + // assertForbiddenJSONRPC validates that rec carries a 403 with a canonical // JSON-RPC error body and that the inner handler was never invoked. //