Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
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
60 changes: 60 additions & 0 deletions coderd/exp_chats_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
47 changes: 46 additions & 1 deletion coderd/x/chatd/chatd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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{}
}
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading