From 6edcbdba7ffadb59cb26be2f6de4811f4ef6768d Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 20 Mar 2026 11:24:45 +0200 Subject: [PATCH] fix(agent/agentproc): enforce chat ID isolation on output and signal endpoints (#23316) handleProcessOutput and handleSignalProcess did not check the chat ID from the request. Any caller that knew a process ID could read output or signal processes belonging to other chats. handleListProcesses already filtered by chat ID. Apply the same check to the output and signal handlers. Non-chat callers (no Coder-Chat-Id header) are allowed through for backwards compatibility. --- agent/agentproc/api.go | 23 +++++++++++++++++++ agent/agentproc/api_test.go | 44 +++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/agent/agentproc/api.go b/agent/agentproc/api.go index 0db5bb0ac8..f784d0e898 100644 --- a/agent/agentproc/api.go +++ b/agent/agentproc/api.go @@ -151,6 +151,18 @@ func (api *API) handleProcessOutput(rw http.ResponseWriter, r *http.Request) { return } + // Enforce chat ID isolation. If the request carries + // a chat context, only allow access to processes + // belonging to that chat. + if chatID, _, ok := agentgit.ExtractChatContext(r); ok { + if proc.chatID != "" && proc.chatID != chatID.String() { + httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ + Message: fmt.Sprintf("Process %q not found.", id), + }) + return + } + } + output, truncated := proc.output() info := proc.info() @@ -168,6 +180,17 @@ func (api *API) handleSignalProcess(rw http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") + // Enforce chat ID isolation. + if chatID, _, ok := agentgit.ExtractChatContext(r); ok { + proc, procOK := api.manager.get(id) + if procOK && proc.chatID != "" && proc.chatID != chatID.String() { + httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ + Message: fmt.Sprintf("Process %q not found.", id), + }) + return + } + } + var req workspacesdk.SignalProcessRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ diff --git a/agent/agentproc/api_test.go b/agent/agentproc/api_test.go index 7e7640de04..c7157c1730 100644 --- a/agent/agentproc/api_test.go +++ b/agent/agentproc/api_test.go @@ -77,6 +77,22 @@ func getOutput(t *testing.T, handler http.Handler, id string) *httptest.Response return w } +// getOutputWithHeaders sends a GET /{id}/output request with +// custom headers and returns the recorder. +func getOutputWithHeaders(t *testing.T, handler http.Handler, id string, headers http.Header) *httptest.ResponseRecorder { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + path := fmt.Sprintf("/%s/output", id) + req := httptest.NewRequestWithContext(ctx, http.MethodGet, path, nil) + for k, v := range headers { + req.Header[k] = v + } + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + return w +} + // postSignal sends a POST /{id}/signal request and returns // the recorder. func postSignal(t *testing.T, handler http.Handler, id string, req workspacesdk.SignalProcessRequest) *httptest.ResponseRecorder { @@ -739,6 +755,34 @@ func TestProcessOutput(t *testing.T) { require.NoError(t, err) require.Contains(t, resp.Message, "not found") }) + + t.Run("ChatIDEnforcement", func(t *testing.T) { + t.Parallel() + + handler := newTestAPI(t) + + // Start a process with chat-a. + chatA := uuid.New() + id := startAndGetID(t, handler, workspacesdk.StartProcessRequest{ + Command: "echo secret", + Background: true, + }, http.Header{ + workspacesdk.CoderChatIDHeader: {chatA.String()}, + }) + waitForExit(t, handler, id) + + // Chat-b should NOT see this process. + chatB := uuid.New() + w1 := getOutputWithHeaders(t, handler, id, http.Header{ + workspacesdk.CoderChatIDHeader: {chatB.String()}, + }) + require.Equal(t, http.StatusNotFound, w1.Code) + + // Without any chat ID header, should return 200 + // (backwards compatible). + w2 := getOutput(t, handler, id) + require.Equal(t, http.StatusOK, w2.Code) + }) } func TestSignalProcess(t *testing.T) {