From b92729a76fa171c9c4e4dfa2cb9faa86754710a2 Mon Sep 17 00:00:00 2001 From: kartik Date: Fri, 3 Apr 2026 09:13:51 +0530 Subject: [PATCH] feat(httpclient): add response body size limit to prevent unbounded reads --- CHANGELOG.md | 7 +++++++ httpclient/client.go | 42 +++++++++++++++++++++++++++--------------- httpclient/config.go | 6 ++++++ 3 files changed, 40 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e225cf..02a2c1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.14.0] - 2026-04-03 + +### Added + +- **httpclient** — `WithMaxResponseBody(n int64)` option to configure the maximum allowed response body size (default 10 MB via `DefaultMaxResponseBody`) +- **httpclient** — Response reads in `executeRequest` are now capped with `io.LimitReader`; responses exceeding the limit return an error instead of allocating unbounded memory + ## [0.13.1] - 2026-04-02 ### Fixed diff --git a/httpclient/client.go b/httpclient/client.go index 2a54561..d4e933d 100644 --- a/httpclient/client.go +++ b/httpclient/client.go @@ -37,25 +37,30 @@ type Client struct { mu sync.RWMutex // config fields - timeout time.Duration - maxRetries int - retryDelay time.Duration - maxRetryDelay time.Duration - logger *slog.Logger - cb *CircuitBreaker - transport http.RoundTripper + timeout time.Duration + maxRetries int + retryDelay time.Duration + maxRetryDelay time.Duration + maxResponseBody int64 + logger *slog.Logger + cb *CircuitBreaker + transport http.RoundTripper } +// DefaultMaxResponseBody is the default maximum response body size (10 MB). +const DefaultMaxResponseBody = 10 << 20 + // New creates a new HTTP client with the given base URL and options. func New(baseURL string, opts ...Option) *Client { c := &Client{ - baseURL: baseURL, - headers: make(map[string]string), - timeout: 30 * time.Second, - maxRetries: 3, - retryDelay: time.Second, - maxRetryDelay: 10 * time.Second, - logger: slog.Default(), + baseURL: baseURL, + headers: make(map[string]string), + timeout: 30 * time.Second, + maxRetries: 3, + retryDelay: time.Second, + maxRetryDelay: 10 * time.Second, + maxResponseBody: DefaultMaxResponseBody, + logger: slog.Default(), } for _, opt := range opts { @@ -239,10 +244,17 @@ func (c *Client) executeRequest(ctx context.Context, method, path string, body a } defer func() { _ = resp.Body.Close() }() - responseBody, err := io.ReadAll(resp.Body) + maxBody := c.maxResponseBody + if maxBody <= 0 { + maxBody = DefaultMaxResponseBody + } + responseBody, err := io.ReadAll(io.LimitReader(resp.Body, maxBody+1)) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } + if int64(len(responseBody)) > maxBody { + return nil, fmt.Errorf("response body exceeds maximum size of %d bytes", maxBody) + } response := &Response{ StatusCode: resp.StatusCode, diff --git a/httpclient/config.go b/httpclient/config.go index 2702ac5..85b8ba6 100644 --- a/httpclient/config.go +++ b/httpclient/config.go @@ -41,6 +41,12 @@ func WithCircuitBreaker(threshold int, timeout time.Duration) Option { } } +// WithMaxResponseBody sets the maximum response body size in bytes. +// Responses larger than this are rejected. Default: 10 MB. +func WithMaxResponseBody(n int64) Option { + return func(c *Client) { c.maxResponseBody = n } +} + // WithTransport sets the HTTP transport. func WithTransport(t http.RoundTripper) Option { return func(c *Client) { c.transport = t }