diff --git a/packages/sandbox/daemon-go/internal/toolscatalog/mcp.go b/packages/sandbox/daemon-go/internal/toolscatalog/mcp.go index 5f92a83db8..d40a19275f 100644 --- a/packages/sandbox/daemon-go/internal/toolscatalog/mcp.go +++ b/packages/sandbox/daemon-go/internal/toolscatalog/mcp.go @@ -29,6 +29,11 @@ const clientName = "@decocms/sandbox-tools-catalog" // hang FetchCatalog indefinitely and grow `out` without bound. const maxCatalogPages = 1000 +// maxRPCResponseBytes bounds a single JSON-RPC response body: a misbehaving or +// malicious endpoint could otherwise stream an unbounded body into memory. +// Matches the SSE branch's per-frame buffer cap below. +const maxRPCResponseBytes = 8 * 1024 * 1024 + type mcpClient struct { http *http.Client url string @@ -198,7 +203,14 @@ func (c *mcpClient) call(ctx context.Context, method string, params any) (json.R // first `data:` frame. func readRPCBody(res *http.Response) (json.RawMessage, error) { if !strings.Contains(res.Header.Get("Content-Type"), "text/event-stream") { - return io.ReadAll(res.Body) + body, err := io.ReadAll(io.LimitReader(res.Body, maxRPCResponseBytes+1)) + if err != nil { + return nil, err + } + if len(body) > maxRPCResponseBytes { + return nil, fmt.Errorf("response exceeded %d bytes", maxRPCResponseBytes) + } + return body, nil } sc := bufio.NewScanner(res.Body) sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) diff --git a/packages/sandbox/daemon-go/internal/toolscatalog/mcp_test.go b/packages/sandbox/daemon-go/internal/toolscatalog/mcp_test.go index 678589ca33..e2fc090556 100644 --- a/packages/sandbox/daemon-go/internal/toolscatalog/mcp_test.go +++ b/packages/sandbox/daemon-go/internal/toolscatalog/mcp_test.go @@ -63,3 +63,28 @@ func TestFetchCatalogBoundsPagination(t *testing.T) { t.Fatalf("expected a page-limit error, got: %v", err) } } + +// TestReadRPCBodyBoundsPlainJSON guards against a misbehaving or malicious +// endpoint streaming an unbounded plain-JSON response body: without a cap, +// readRPCBody would buffer it entirely into memory via io.ReadAll. +func TestReadRPCBodyBoundsPlainJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(make([]byte, maxRPCResponseBytes+1)) + })) + defer srv.Close() + + res, err := http.Get(srv.URL) + if err != nil { + t.Fatalf("http.Get: %v", err) + } + defer res.Body.Close() + + _, err = readRPCBody(res) + if err == nil { + t.Fatal("expected readRPCBody to reject an oversized body, got nil error") + } + if !strings.Contains(err.Error(), "exceeded") { + t.Fatalf("expected a size-limit error, got: %v", err) + } +}