detached 7a412ec

fix(security): cap error response body reads in remote client

Use io.LimitReader(resp.Body, 1024) for all error-path reads in the
remote client (parseResponse, requestWithRetry, streaming setup).
Previously, unbounded io.ReadAll on error responses could cause OOM
if the server returned very large error bodies.
commit
7a412ec4de340ad2ab2ff2f3b05db20972dabf1a
author
brice · · 2026-03-14 22:18 UTC
parent
ff97ba9
signature
unsigned
2 files · +62 -3
M internal/adapters/in/cli/remote/client.go +5 -3
@@ -33,6 +33,8 @@ var (
3333 retryBaseDelay = 250 * time.Millisecond
3434 )
3535
36+const maxErrorBodySize int64 = 1024 // cap error response reads at 1 KB
37+
3638 // ClientOption configures the Client.
3739 type ClientOption func(*Client)
3840
@@ -188,7 +190,7 @@ func parseResponse(resp *http.Response, target any) error {
188190 defer resp.Body.Close()
189191
190192 if resp.StatusCode >= 400 {
191- body, _ := io.ReadAll(resp.Body)
193+ body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
192194 return parseErrorResponse(resp, body)
193195 }
194196
@@ -245,7 +247,7 @@ func (c *Client) requestWithRetry(ctx context.Context, method, path string, body
245247 return resp, nil
246248 }
247249
248- respBody, _ := io.ReadAll(resp.Body)
250+ respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
249251 _ = resp.Body.Close()
250252 lastErr = parseErrorResponse(resp, respBody)
251253 if attempt == retryMaxAttempts {
@@ -972,7 +974,7 @@ func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, er
972974 c.observeTokenRotation(resp)
973975
974976 if resp.StatusCode >= 400 {
975- body, _ := io.ReadAll(resp.Body)
977+ body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
976978 resp.Body.Close()
977979 return nil, fmt.Errorf("%s: %s", resp.Status, string(body))
978980 }
@@ -33,6 +33,8 @@ var (
33 retryBaseDelay = 250 * time.Millisecond33 retryBaseDelay = 250 * time.Millisecond
34 )34 )
35 35
36+const maxErrorBodySize int64 = 1024 // cap error response reads at 1 KB
37+
36 // ClientOption configures the Client.38 // ClientOption configures the Client.
37 type ClientOption func(*Client)39 type ClientOption func(*Client)
38 40
@@ -188,7 +190,7 @@ func parseResponse(resp *http.Response, target any) error {
188 defer resp.Body.Close()190 defer resp.Body.Close()
189 191
190 if resp.StatusCode >= 400 {192 if resp.StatusCode >= 400 {
191- body, _ := io.ReadAll(resp.Body)193+ body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
192 return parseErrorResponse(resp, body)194 return parseErrorResponse(resp, body)
193 }195 }
194 196
@@ -245,7 +247,7 @@ func (c *Client) requestWithRetry(ctx context.Context, method, path string, body
245 return resp, nil247 return resp, nil
246 }248 }
247 249
248- respBody, _ := io.ReadAll(resp.Body)250+ respBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
249 _ = resp.Body.Close()251 _ = resp.Body.Close()
250 lastErr = parseErrorResponse(resp, respBody)252 lastErr = parseErrorResponse(resp, respBody)
251 if attempt == retryMaxAttempts {253 if attempt == retryMaxAttempts {
@@ -972,7 +974,7 @@ func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, er
972 c.observeTokenRotation(resp)974 c.observeTokenRotation(resp)
973 975
974 if resp.StatusCode >= 400 {976 if resp.StatusCode >= 400 {
975- body, _ := io.ReadAll(resp.Body)977+ body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
976 resp.Body.Close()978 resp.Body.Close()
977 return nil, fmt.Errorf("%s: %s", resp.Status, string(body))979 return nil, fmt.Errorf("%s: %s", resp.Status, string(body))
978 }980 }
M internal/adapters/in/cli/remote/client_test.go +57 -0
@@ -3,8 +3,10 @@ package remote
33 import (
44 "context"
55 "encoding/json"
6+ "io"
67 "net/http"
78 "net/http/httptest"
9+ "strings"
810 "sync/atomic"
911 "testing"
1012 "time"
@@ -193,3 +195,58 @@ func TestClientFindAttachmentTargetsByImage_WithSlashContainingImage(t *testing.
193195 require.NoError(t, err)
194196 assert.Equal(t, []string{"workers"}, targets)
195197 }
198+
199+func TestParseResponse_CapsErrorBodySize(t *testing.T) {
200+ largeBody := strings.Repeat("x", 10*1024*1024)
201+ resp := &http.Response{
202+ StatusCode: 500,
203+ Status: "500 Internal Server Error",
204+ Body: io.NopCloser(strings.NewReader(largeBody)),
205+ Header: make(http.Header),
206+ }
207+
208+ err := parseResponse(resp, nil)
209+
210+ require.Error(t, err)
211+ assert.Less(t, len(err.Error()), 2*1024, "error body should be capped to ~1KB")
212+}
213+
214+func TestStreamLogs_CapsErrorBodySize(t *testing.T) {
215+ largeBody := strings.Repeat("x", 10*1024*1024)
216+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
217+ w.WriteHeader(http.StatusInternalServerError)
218+ _, _ = w.Write([]byte(largeBody))
219+ }))
220+ defer srv.Close()
221+
222+ c := &Client{
223+ baseURL: srv.URL,
224+ httpClient: srv.Client(),
225+ }
226+
227+ _, err := c.StreamProcessLogs(context.Background(), 100)
228+
229+ require.Error(t, err)
230+ assert.Less(t, len(err.Error()), 2*1024, "streaming error body should be capped to ~1KB")
231+}
232+
233+func TestRequestWithRetry_CapsErrorBodySize(t *testing.T) {
234+ withFastRetry(t)
235+
236+ largeBody := strings.Repeat("x", 10*1024*1024)
237+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
238+ w.WriteHeader(http.StatusBadGateway)
239+ _, _ = w.Write([]byte(largeBody))
240+ }))
241+ defer srv.Close()
242+
243+ c := &Client{
244+ baseURL: srv.URL,
245+ httpClient: srv.Client(),
246+ }
247+
248+ _, err := c.requestWithRetry(context.Background(), http.MethodPost, "/test", nil)
249+
250+ require.Error(t, err)
251+ assert.Less(t, len(err.Error()), 2*1024, "retry error body should be capped to ~1KB")
252+}
@@ -3,8 +3,10 @@ package remote
3 import (3 import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6+ "io"
6 "net/http"7 "net/http"
7 "net/http/httptest"8 "net/http/httptest"
9+ "strings"
8 "sync/atomic"10 "sync/atomic"
9 "testing"11 "testing"
10 "time"12 "time"
@@ -193,3 +195,58 @@ func TestClientFindAttachmentTargetsByImage_WithSlashContainingImage(t *testing.
193 require.NoError(t, err)195 require.NoError(t, err)
194 assert.Equal(t, []string{"workers"}, targets)196 assert.Equal(t, []string{"workers"}, targets)
195 }197 }
198+
199+func TestParseResponse_CapsErrorBodySize(t *testing.T) {
200+ largeBody := strings.Repeat("x", 10*1024*1024)
201+ resp := &http.Response{
202+ StatusCode: 500,
203+ Status: "500 Internal Server Error",
204+ Body: io.NopCloser(strings.NewReader(largeBody)),
205+ Header: make(http.Header),
206+ }
207+
208+ err := parseResponse(resp, nil)
209+
210+ require.Error(t, err)
211+ assert.Less(t, len(err.Error()), 2*1024, "error body should be capped to ~1KB")
212+}
213+
214+func TestStreamLogs_CapsErrorBodySize(t *testing.T) {
215+ largeBody := strings.Repeat("x", 10*1024*1024)
216+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
217+ w.WriteHeader(http.StatusInternalServerError)
218+ _, _ = w.Write([]byte(largeBody))
219+ }))
220+ defer srv.Close()
221+
222+ c := &Client{
223+ baseURL: srv.URL,
224+ httpClient: srv.Client(),
225+ }
226+
227+ _, err := c.StreamProcessLogs(context.Background(), 100)
228+
229+ require.Error(t, err)
230+ assert.Less(t, len(err.Error()), 2*1024, "streaming error body should be capped to ~1KB")
231+}
232+
233+func TestRequestWithRetry_CapsErrorBodySize(t *testing.T) {
234+ withFastRetry(t)
235+
236+ largeBody := strings.Repeat("x", 10*1024*1024)
237+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
238+ w.WriteHeader(http.StatusBadGateway)
239+ _, _ = w.Write([]byte(largeBody))
240+ }))
241+ defer srv.Close()
242+
243+ c := &Client{
244+ baseURL: srv.URL,
245+ httpClient: srv.Client(),
246+ }
247+
248+ _, err := c.requestWithRetry(context.Background(), http.MethodPost, "/test", nil)
249+
250+ require.Error(t, err)
251+ assert.Less(t, len(err.Error()), 2*1024, "retry error body should be capped to ~1KB")
252+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close