Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions pkg/transport/middleware/origin/origin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions pkg/transport/middleware/origin/origin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down
Loading