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.
This commit is contained in:
Mathias Fredriksson
2026-03-20 11:24:45 +02:00
committed by GitHub
parent abd7b7aeba
commit 6edcbdba7f
2 changed files with 67 additions and 0 deletions
+23
View File
@@ -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{
+44
View File
@@ -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) {