From 30e945fcbea16dd949a9d8edc7f8aab37aa95e7e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 10 Aug 2026 14:12:43 +0000 Subject: [PATCH] fix(coderd): enforce Force On MCP server policy on the backend The Force On MCP server availability policy was only enforced client-side: the frontend appended force_on server IDs to the mcp_server_ids request parameter, so a user could strip them from the request when creating a chat or sending a message and the forced servers were silently omitted (Cure53 CDM-02-010). Enforce the policy server-side at three points: - chatd.CreateChat unions the requested IDs with every enabled force_on config before persisting the chat. - chatd.SendMessage applies the same union inside the update transaction whenever a caller-provided ID list would overwrite the chat's stored selection. - prepareGeneration merges force_on configs into the effective config set for each turn, covering chats persisted before enforcement existed and servers marked force_on after chat creation. Existing plan-mode filtering still narrows the merged set, and Explore chats keep their immutable spawn-time snapshot. The forced-config lookup fails closed. User MCP tokens are now loaded whenever external MCP servers are connected, not only when the chat's stored ID list is non-empty. Regression coverage: chatd-level tests for stripped create lists, emptied update lists, and generation-time enforcement for pre-existing chats, plus an endpoint-level test walking the original reproduction steps through POST /api/experimental/chats and POST /api/experimental/chats/{chat}/messages. --- _Generated with [`mux`](https://github.com/coder/mux)_ --- coderd/exp_chats_test.go | 60 +++++ coderd/x/chatd/chatd.go | 47 +++- coderd/x/chatd/forced_mcp_test.go | 302 ++++++++++++++++++++++++++ coderd/x/chatd/generation_preparer.go | 75 +++++-- 4 files changed, 465 insertions(+), 19 deletions(-) create mode 100644 coderd/x/chatd/forced_mcp_test.go diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index 4b6fac60101..7e61987d389 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -932,6 +932,66 @@ func TestPostChats(t *testing.T) { }) } +// TestChats_ForceOnMCPServerEnforced is the endpoint-level regression +// test for Cure53 CDM-02-010: a regular user who strips force_on MCP +// server IDs from mcp_server_ids when creating a chat or sending a +// message must not be able to exclude those servers. +func TestChats_ForceOnMCPServerEnforced(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + _ = createChatModelConfig(t, client) + + // An admin marks an MCP server as Force On. + forced, err := client.Client.CreateMCPServerConfig(ctx, codersdk.CreateMCPServerConfigRequest{ + DisplayName: "Forced Server", + Slug: "forced-server", + Transport: "streamable_http", + URL: "https://mcp.example.com/forced", + AuthType: "none", + Availability: "force_on", + Enabled: true, + ToolAllowList: []string{}, + ToolDenyList: []string{}, + }) + require.NoError(t, err) + + // A regular member tampers with the request by clearing + // mcp_server_ids (Cure53 CDM-02-010 reproduction). + memberClientRaw, _ := coderdtest.CreateAnotherUser(t, client.Client, firstUser.OrganizationID, rbac.ScopedRoleAgentsAccess(firstUser.OrganizationID)) + memberClient := codersdk.NewExperimentalClient(memberClientRaw) + + chat, err := memberClient.CreateChat(ctx, codersdk.CreateChatRequest{ + OrganizationID: firstUser.OrganizationID, + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "test message", + }}, + MCPServerIDs: []uuid.UUID{}, + }) + require.NoError(t, err) + require.Contains(t, chat.MCPServerIDs, forced.ID, + "force_on MCP server must be enforced on chat creation") + + // Sending a message with an emptied list must not remove the + // forced server either. + _, err = memberClient.CreateChatMessage(ctx, chat.ID, codersdk.CreateChatMessageRequest{ + Content: []codersdk.ChatInputPart{{ + Type: codersdk.ChatInputPartTypeText, + Text: "second message", + }}, + MCPServerIDs: &[]uuid.UUID{}, + }) + require.NoError(t, err) + + chatResult, err := memberClient.GetChat(ctx, chat.ID) + require.NoError(t, err) + require.Contains(t, chatResult.MCPServerIDs, forced.ID, + "force_on MCP server must survive a tampered mcp_server_ids update") +} + func TestPostChats_ClientType(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 9d37df09967..54f8a022b53 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -1202,6 +1202,36 @@ type PromoteQueuedResult struct { PromotedMessage database.ChatMessage } +// enforceForcedMCPServerIDs appends the ID of every enabled Force On +// MCP server config missing from ids. Force On availability is a +// server-side policy: callers must not be able to exclude such +// servers by stripping IDs from a request (Cure53 CDM-02-010). The +// forced set is read with daemon scope because regular users cannot +// read MCP server configs directly. +func enforceForcedMCPServerIDs(ctx context.Context, store database.Store, ids []uuid.UUID) ([]uuid.UUID, error) { + //nolint:gocritic // Non-admin users need chatd-scoped config reads here. + forced, err := store.GetForcedMCPServerConfigs(dbauthz.AsChatd(ctx)) + if err != nil { + // Fail closed: proceeding without the forced set would + // silently bypass a security policy. + return nil, xerrors.Errorf("get forced MCP server configs: %w", err) + } + merged := slices.Clone(ids) + if merged == nil { + merged = []uuid.UUID{} + } + seen := make(map[uuid.UUID]struct{}, len(merged)) + for _, id := range merged { + seen[id] = struct{}{} + } + for _, cfg := range forced { + if _, ok := seen[cfg.ID]; !ok { + merged = append(merged, cfg.ID) + } + } + return merged, nil +} + // CreateChat creates a chat with its initial history through // chatstate.CreateChat. The new chat starts in `running` status per // the chat execution state model. Ownership hints wake chat workers. @@ -1224,6 +1254,14 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C if opts.MCPServerIDs == nil { opts.MCPServerIDs = []uuid.UUID{} } + // Force On MCP servers are enforced server-side so a caller + // cannot exclude them by stripping IDs from the request + // (Cure53 CDM-02-010). + enforcedMCPServerIDs, err := enforceForcedMCPServerIDs(ctx, p.db, opts.MCPServerIDs) + if err != nil { + return database.Chat{}, err + } + opts.MCPServerIDs = enforcedMCPServerIDs if opts.Labels == nil { opts.Labels = database.StringMap{} } @@ -1473,9 +1511,16 @@ func (p *Server) SendMessage( slog.F("chat_id", opts.ChatID), ) } else { + // Force On MCP servers are enforced server-side so a + // caller cannot remove them by tampering with the + // update (Cure53 CDM-02-010). + enforcedIDs, enforceErr := enforceForcedMCPServerIDs(ctx, store, *requestedMCPServerIDs) + if enforceErr != nil { + return enforceErr + } lockedChat, err = store.UpdateChatMCPServerIDs(ctx, database.UpdateChatMCPServerIDsParams{ ID: opts.ChatID, - MCPServerIDs: *requestedMCPServerIDs, + MCPServerIDs: enforcedIDs, }) if err != nil { return xerrors.Errorf("update chat mcp server ids: %w", err) diff --git a/coderd/x/chatd/forced_mcp_test.go b/coderd/x/chatd/forced_mcp_test.go new file mode 100644 index 00000000000..c72e29f0a91 --- /dev/null +++ b/coderd/x/chatd/forced_mcp_test.go @@ -0,0 +1,302 @@ +package chatd_test + +// Regression tests for Cure53 CDM-02-010: the Force On MCP server +// availability policy must be enforced on the backend. A client that +// omits force_on entries from mcp_server_ids when creating a chat or +// sending a message must not be able to exclude those servers from +// the conversation. + +import ( + "context" + "net/http/httptest" + "sync" + "testing" + + "github.com/google/uuid" + mcpgo "github.com/mark3labs/mcp-go/mcp" + mcpserver "github.com/mark3labs/mcp-go/server" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/x/chatd" + "github.com/coder/coder/v2/coderd/x/chatd/chattest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +// newEchoMCPTestServer starts an MCP test server exposing an "echo" +// tool and returns its base URL. +func newEchoMCPTestServer(t *testing.T, name string) string { + t.Helper() + srv := mcpserver.NewMCPServer(name, "1.0.0") + srv.AddTools(mcpserver.ServerTool{ + Tool: mcpgo.NewTool("echo", + mcpgo.WithDescription("Echoes the input"), + mcpgo.WithString("input", + mcpgo.Description("The input string"), + mcpgo.Required(), + ), + ), + Handler: func(_ context.Context, req mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) { + input, _ := req.GetArguments()["input"].(string) + return mcpgo.NewToolResultText("echo: " + input), nil + }, + }) + ts := httptest.NewServer(mcpserver.NewStreamableHTTPServer(srv)) + t.Cleanup(ts.Close) + return ts.URL +} + +// newToolRecordingOpenAI returns a mock OpenAI URL that answers every +// streamed call with plain text and records the tool names offered on +// each streamed call, plus an accessor for the recorded calls. +func newToolRecordingOpenAI(t *testing.T) (string, func() [][]string) { + t.Helper() + var ( + mu sync.Mutex + calls [][]string + ) + url := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("title") + } + names := make([]string, 0, len(req.Tools)) + for _, tool := range req.Tools { + names = append(names, tool.Function.Name) + } + mu.Lock() + calls = append(calls, names) + mu.Unlock() + return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("ok")...) + }) + recorded := func() [][]string { + mu.Lock() + defer mu.Unlock() + out := make([][]string, len(calls)) + copy(out, calls) + return out + } + return url, recorded +} + +// TestCreateChat_ForceOnMCPServerEnforced reproduces CDM-02-010 for +// chat creation: stripping mcp_server_ids from the create request must +// not exclude force_on MCP servers. +func TestCreateChat_ForceOnMCPServerEnforced(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + + forcedURL := newEchoMCPTestServer(t, "forced-mcp") + openAIURL, recordedCalls := newToolRecordingOpenAI(t) + + user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) + + forcedConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + DisplayName: "Forced MCP", + Slug: "forced-mcp", + Url: forcedURL, + Availability: "force_on", + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + }) + + server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) + }) + + // The attacker strips every MCP server ID from the request. + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + Title: "forced-mcp-create", + ModelConfigID: model.ID, + MCPServerIDs: []uuid.UUID{}, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("hello"), + }, + }) + require.NoError(t, err) + + // The force_on server must be persisted despite the empty list. + dbChat, err := db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + require.ElementsMatch(t, []uuid.UUID{forcedConfig.ID}, dbChat.MCPServerIDs, + "force_on MCP server must be enforced on chat creation") + + waitForChatProcessed(ctx, t, db, chat.ID, server) + + chatResult, err := db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + if chatResult.Status == database.ChatStatusError { + require.FailNowf(t, "chat failed", "last_error=%q", chatLastErrorMessage(chatResult.LastError)) + } + + // The forced server's tool must be offered to the LLM. + calls := recordedCalls() + require.NotEmpty(t, calls) + require.Contains(t, calls[0], "forced-mcp__echo", + "force_on MCP tools must be offered to the LLM despite a stripped mcp_server_ids list") +} + +// TestSendMessage_ForceOnMCPServerEnforced reproduces CDM-02-010 for +// message sends: a tampered mcp_server_ids update must not remove +// force_on MCP servers from the chat. +func TestSendMessage_ForceOnMCPServerEnforced(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + + forcedURL := newEchoMCPTestServer(t, "forced-mcp") + optionalURL := newEchoMCPTestServer(t, "optional-mcp") + openAIURL, _ := newToolRecordingOpenAI(t) + + user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) + + forcedConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + DisplayName: "Forced MCP", + Slug: "forced-mcp", + Url: forcedURL, + Availability: "force_on", + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + }) + optionalConfig := dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + DisplayName: "Optional MCP", + Slug: "optional-mcp", + Url: optionalURL, + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + }) + + server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) + }) + + // Creation with a tampered list that omits the forced server. + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + Title: "forced-mcp-send", + ModelConfigID: model.ID, + MCPServerIDs: []uuid.UUID{optionalConfig.ID}, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("hello"), + }, + }) + require.NoError(t, err) + + dbChat, err := db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + require.ElementsMatch(t, []uuid.UUID{optionalConfig.ID, forcedConfig.ID}, dbChat.MCPServerIDs, + "force_on MCP server must be appended to a tampered create list") + + waitForChatProcessed(ctx, t, db, chat.ID, server) + + // The attacker clears the MCP server list on a message send. + _, err = server.SendMessage(ctx, chatd.SendMessageOptions{ + ChatID: chat.ID, + CreatedBy: user.ID, + Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("clear the list")}, + MCPServerIDs: &[]uuid.UUID{}, + }) + require.NoError(t, err) + + dbChat, err = db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + require.ElementsMatch(t, []uuid.UUID{forcedConfig.ID}, dbChat.MCPServerIDs, + "force_on MCP server must survive an emptied mcp_server_ids update") + + waitForChatProcessed(ctx, t, db, chat.ID, server) + + // A legitimate update keeps both the selection and the forced server. + _, err = server.SendMessage(ctx, chatd.SendMessageOptions{ + ChatID: chat.ID, + CreatedBy: user.ID, + Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("select optional")}, + MCPServerIDs: &[]uuid.UUID{optionalConfig.ID}, + }) + require.NoError(t, err) + + dbChat, err = db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + require.ElementsMatch(t, []uuid.UUID{optionalConfig.ID, forcedConfig.ID}, dbChat.MCPServerIDs, + "force_on MCP server must be appended to a tampered update list") + + waitForChatProcessed(ctx, t, db, chat.ID, server) +} + +// TestGeneration_ForceOnMCPServerEnforcedForExistingChats covers chats +// whose stored mcp_server_ids predates the force_on policy (or was +// tampered before enforcement existed): generation must still include +// force_on servers without relying on the stored list. +func TestGeneration_ForceOnMCPServerEnforcedForExistingChats(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + + forcedURL := newEchoMCPTestServer(t, "forced-mcp") + openAIURL, recordedCalls := newToolRecordingOpenAI(t) + + user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) + + server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + cfg.AIBridgeTransportFactory = chatAIGatewayTransportFactoryPointer(chattest.NewMockAIBridgeTransport(t, openAIURL)) + }) + + // The chat is created before any force_on MCP server exists, so + // its stored mcp_server_ids is empty. + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + Title: "forced-mcp-existing", + ModelConfigID: model.ID, + MCPServerIDs: []uuid.UUID{}, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("hello"), + }, + }) + require.NoError(t, err) + waitForChatProcessed(ctx, t, db, chat.ID, server) + + // An admin marks a server force_on after the chat already exists. + dbgen.MCPServerConfig(t, db, database.MCPServerConfig{ + DisplayName: "Forced MCP", + Slug: "forced-mcp", + Url: forcedURL, + Availability: "force_on", + CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true}, + }) + + // A send that does not touch mcp_server_ids must still pick up + // the force_on server at generation time. + _, err = server.SendMessage(ctx, chatd.SendMessageOptions{ + ChatID: chat.ID, + CreatedBy: user.ID, + Content: []codersdk.ChatMessagePart{codersdk.ChatMessageText("again")}, + }) + require.NoError(t, err) + waitForChatProcessed(ctx, t, db, chat.ID, server) + + chatResult, err := db.GetChatByID(ctx, chat.ID) + require.NoError(t, err) + if chatResult.Status == database.ChatStatusError { + require.FailNowf(t, "chat failed", "last_error=%q", chatLastErrorMessage(chatResult.LastError)) + } + + // nil MCPServerIDs must keep the stored list untouched. + require.Empty(t, chatResult.MCPServerIDs) + + calls := recordedCalls() + require.GreaterOrEqual(t, len(calls), 2) + require.NotContains(t, calls[0], "forced-mcp__echo", + "no force_on server existed during the first turn") + require.Contains(t, calls[len(calls)-1], "forced-mcp__echo", + "force_on MCP tools must reach generation for chats created before the policy") +} diff --git a/coderd/x/chatd/generation_preparer.go b/coderd/x/chatd/generation_preparer.go index 9acf7f38237..957228d5300 100644 --- a/coderd/x/chatd/generation_preparer.go +++ b/coderd/x/chatd/generation_preparer.go @@ -25,6 +25,53 @@ import ( "github.com/coder/coder/v2/codersdk" ) +// effectiveMCPServerConfigs loads the MCP server configs for a turn: +// the chat's stored selection plus every enabled Force On config. +// Force On inclusion is enforced at generation time, not just at +// write time, so chats persisted before enforcement existed (or +// before an admin marked a server Force On) cannot dodge the policy +// (Cure53 CDM-02-010). Explore chats are exempt: their spawn-time +// snapshot is immutable by design and must never widen after spawn; +// Force On servers reach the snapshot through the parent chat's +// enforced ID list. +func (server *Server) effectiveMCPServerConfigs( + ctx context.Context, + logger slog.Logger, + chat database.Chat, +) ([]database.MCPServerConfig, error) { + var configs []database.MCPServerConfig + if len(chat.MCPServerIDs) > 0 { + var err error + configs, err = server.db.GetMCPServerConfigsByIDs(ctx, chat.MCPServerIDs) + if err != nil { + // Best-effort for the user-selected set, matching prior + // behavior: a load failure degrades the turn rather than + // failing it. + logger.Warn(ctx, "failed to load MCP server configs", slog.Error(err)) + configs = nil + } + } + if isExploreSubagentMode(chat.Mode) { + return configs, nil + } + forced, err := server.db.GetForcedMCPServerConfigs(ctx) + if err != nil { + // Fail closed: running the turn without the forced set would + // silently bypass a security policy. + return nil, xerrors.Errorf("get forced MCP server configs: %w", err) + } + seen := make(map[uuid.UUID]struct{}, len(configs)) + for _, cfg := range configs { + seen[cfg.ID] = struct{}{} + } + for _, cfg := range forced { + if _, ok := seen[cfg.ID]; !ok { + configs = append(configs, cfg) + } + } + return configs, nil +} + func (server *Server) prepareGeneration( ctx context.Context, input generationPrepareInput, @@ -58,24 +105,11 @@ func (server *Server) prepareGeneration( } return nil }) - if len(chat.MCPServerIDs) > 0 { - g.Go(func() error { - var err error - mcpConfigs, err = server.db.GetMCPServerConfigsByIDs(ctx, chat.MCPServerIDs) - if err != nil { - logger.Warn(ctx, "failed to load MCP server configs", slog.Error(err)) - } - return nil - }) - g.Go(func() error { - var err error - mcpTokens, err = server.db.GetMCPServerUserTokensByUserID(ctx, chat.OwnerID) - if err != nil { - logger.Warn(ctx, "failed to load MCP user tokens", slog.Error(err)) - } - return nil - }) - } + g.Go(func() error { + var err error + mcpConfigs, err = server.effectiveMCPServerConfigs(ctx, logger, chat) + return err + }) if err := g.Wait(); err != nil { return generationPrepared{}, err } @@ -311,6 +345,11 @@ func (server *Server) prepareGeneration( }) if len(mcpConnectConfigs) > 0 { g2.Go(func() error { + var tokenErr error + mcpTokens, tokenErr = server.db.GetMCPServerUserTokensByUserID(ctx, chat.OwnerID) + if tokenErr != nil { + logger.Warn(ctx, "failed to load MCP user tokens", slog.Error(tokenErr)) + } mcpTokens = server.refreshExpiredMCPTokens(ctx, logger, mcpConnectConfigs, mcpTokens) mcpTools, mcpCleanup = mcpclient.ConnectAll( ctx,