From 469d346dc70f9f62a0e55c3d32eaf3b03a42ef6f Mon Sep 17 00:00:00 2001 From: pedrofrxncx Date: Tue, 18 Aug 2026 01:19:05 -0300 Subject: [PATCH] fix(sandbox): cap the Go daemon's MCP JSON-RPC response body The plain-JSON branch of readRPCBody read a downstream MCP endpoint's response with unbounded io.ReadAll, while the SSE branch next to it already caps the frame at 8MB via the scanner buffer. A misbehaving or malicious MCP connection returning application/json could stream an arbitrarily large body straight into daemon memory during catalog fetch. Cap the plain-JSON path at the same 8MB via LimitReader, matching the file's existing threat model (see maxCatalogPages above it). --- .../daemon-go/internal/toolscatalog/mcp.go | 14 ++++++++++- .../internal/toolscatalog/mcp_test.go | 25 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) 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) + } +}