From 57598cbf3ef8eae78cb7a1c71631d0bf04a130f9 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:18:30 +0100 Subject: [PATCH] Bound agent capability HTTP responses Contract-Neutral: Enforces client-side response resource limits without changing the agent wire contract. --- internal/agentcapabilities/http.go | 20 +++++- internal/agentcapabilities/http_test.go | 84 +++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/internal/agentcapabilities/http.go b/internal/agentcapabilities/http.go index c3094394d..cd6c5f94c 100644 --- a/internal/agentcapabilities/http.go +++ b/internal/agentcapabilities/http.go @@ -9,6 +9,8 @@ import ( "net/http" "net/url" "strings" + + "github.com/rcourtman/pulse-go-rewrite/internal/securityutil" ) const ( @@ -18,6 +20,9 @@ const ( AgentAPITokenHeader = "X-API-Token" AgentSurfaceHeader = "X-Pulse-Agent-Surface" AgentSurfacePulseMCP = "pulse_mcp" + + maxManifestResponseBodyBytes int64 = 1 << 20 // 1 MiB + maxCapabilityResponseBodyBytes int64 = 16 << 20 // 16 MiB ) // HTTPDoer is the shared minimum interface for agent-surface HTTP clients. @@ -130,8 +135,12 @@ func FetchManifest(ctx context.Context, client HTTPDoer, baseURL string) (*Manif if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("GET %s: status %d", AgentCapabilitiesPath, resp.StatusCode) } + body, err := readBoundedResponseBody(resp, maxManifestResponseBodyBytes) + if err != nil { + return nil, fmt.Errorf("GET %s response: %w", AgentCapabilitiesPath, err) + } var manifest Manifest - if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil { + if err := json.Unmarshal(body, &manifest); err != nil { return nil, fmt.Errorf("decode manifest: %w", err) } return &manifest, nil @@ -174,7 +183,7 @@ func CallCapabilityHTTP(ctx context.Context, client HTTPDoer, baseURL, token str return HTTPCallResponse{}, fmt.Errorf("%s %s: %w", cap.Method, projected.Path, err) } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + body, err := readBoundedResponseBody(resp, maxCapabilityResponseBodyBytes) if err != nil { return HTTPCallResponse{}, fmt.Errorf("read %s %s response: %w", cap.Method, projected.Path, err) } @@ -187,6 +196,13 @@ func CallCapabilityHTTP(ctx context.Context, client HTTPDoer, baseURL, token str }, nil } +func readBoundedResponseBody(resp *http.Response, limit int64) ([]byte, error) { + if err := securityutil.LimitResponseBody(resp, limit); err != nil { + return nil, err + } + return io.ReadAll(resp.Body) +} + // CallCapabilityHTTPByName resolves and executes a named manifest capability // through the shared discovery/projection/auth header path. func CallCapabilityHTTPByName(ctx context.Context, client HTTPDoer, baseURL, token string, capabilities []Capability, name string, args map[string]any) (HTTPCallResponse, error) { diff --git a/internal/agentcapabilities/http_test.go b/internal/agentcapabilities/http_test.go index 2667699df..fdcb6cd9a 100644 --- a/internal/agentcapabilities/http_test.go +++ b/internal/agentcapabilities/http_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "net/http" "net/http/httptest" @@ -39,6 +40,27 @@ func TestFetchManifestUsesSharedDiscoveryPath(t *testing.T) { } } +func TestFetchManifestRejectsDeclaredOversizedResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Length", fmt.Sprint(maxManifestResponseBodyBytes+1)) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + _, err := FetchManifest(context.Background(), server.Client(), server.URL) + assertResponseSizeLimitError(t, err, maxManifestResponseBodyBytes) +} + +func TestFetchManifestRejectsUndeclaredOversizedResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeUndeclaredOversizedResponse(w, maxManifestResponseBodyBytes) + })) + defer server.Close() + + _, err := FetchManifest(context.Background(), server.Client(), server.URL) + assertResponseSizeLimitError(t, err, maxManifestResponseBodyBytes) +} + func TestBuildCapabilityHTTPRequestProjectsPathBodyAndAuth(t *testing.T) { cap := Capability{ Name: SetOperatorStateCapabilityName, @@ -197,6 +219,68 @@ func TestCallCapabilityHTTPExecutesManifestProjection(t *testing.T) { } } +func TestCallCapabilityHTTPRejectsDeclaredOversizedResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Length", fmt.Sprint(maxCapabilityResponseBodyBytes+1)) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + _, err := CallCapabilityHTTP(context.Background(), server.Client(), server.URL, "token", Capability{ + Name: FleetContextCapabilityName, + Method: http.MethodGet, + Path: FleetContextCapabilityPath, + }, nil) + assertResponseSizeLimitError(t, err, maxCapabilityResponseBodyBytes) +} + +func TestCallCapabilityHTTPRejectsUndeclaredOversizedResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeUndeclaredOversizedResponse(w, maxCapabilityResponseBodyBytes) + })) + defer server.Close() + + _, err := CallCapabilityHTTP(context.Background(), server.Client(), server.URL, "token", Capability{ + Name: FleetContextCapabilityName, + Method: http.MethodGet, + Path: FleetContextCapabilityPath, + }, nil) + assertResponseSizeLimitError(t, err, maxCapabilityResponseBodyBytes) +} + +func assertResponseSizeLimitError(t *testing.T, err error, limit int64) { + t.Helper() + want := fmt.Sprintf("response body exceeds %d bytes", limit) + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("error = %v, want %q", err, want) + } +} + +func writeUndeclaredOversizedResponse(w http.ResponseWriter, limit int64) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + prefix := []byte(`{"padding":"`) + if _, err := w.Write(prefix); err != nil { + return + } + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + + chunk := strings.Repeat("x", 32*1024) + remaining := limit + 1 - int64(len(prefix)) + for remaining > 0 { + writeSize := int64(len(chunk)) + if writeSize > remaining { + writeSize = remaining + } + if _, err := io.WriteString(w, chunk[:int(writeSize)]); err != nil { + return + } + remaining -= writeSize + } +} + func TestCallCapabilityHTTPByNameResolvesAndExecutesManifestProjection(t *testing.T) { var got struct { Method string