Skip to content

Commit 6c58592

Browse files
authored
fix: Limit HTTP error response body reads to prevent OOM (#4191)
1 parent 6b805b3 commit 6c58592

2 files changed

Lines changed: 69 additions & 2 deletions

File tree

github/github.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -920,6 +920,11 @@ const (
920920
SleepUntilPrimaryRateLimitResetWhenRateLimited
921921
)
922922

923+
// maxErrorBodySize is the maximum number of bytes read from an HTTP error
924+
// response body. Limits memory allocation when a server returns an
925+
// unexpectedly large error body.
926+
const maxErrorBodySize = 1 * 1024 * 1024 // 1 MiB
927+
923928
// bareDo sends an API request using `caller` http.Client passed in the parameters
924929
// and lets you handle the api response. If an error or API Error occurs, the error
925930
// will contain more information. Otherwise, you are supposed to read and close the
@@ -997,7 +1002,7 @@ func (c *Client) bareDo(caller *http.Client, req *http.Request) (*Response, erro
9971002
// Issue #1022
9981003
var aerr *AcceptedError
9991004
if errors.As(err, &aerr) {
1000-
b, readErr := io.ReadAll(resp.Body)
1005+
b, readErr := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodySize))
10011006
if readErr != nil {
10021007
return response, readErr
10031008
}
@@ -1502,7 +1507,7 @@ func CheckResponse(r *http.Response) error {
15021507
}
15031508

15041509
errorResponse := &ErrorResponse{Response: r}
1505-
data, err := io.ReadAll(r.Body)
1510+
data, err := io.ReadAll(io.LimitReader(r.Body, maxErrorBodySize))
15061511
if err == nil && data != nil {
15071512
err = json.Unmarshal(data, errorResponse)
15081513
if err != nil {

github/github_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1400,6 +1400,36 @@ func TestDo_preservesResponseInHTTPError(t *testing.T) {
14001400
}
14011401
}
14021402

1403+
// TestDo_AcceptedError_LargeBodyTruncated verifies that when the API returns a
1404+
// 202 Accepted with a body larger than maxErrorBodySize, the client reads at
1405+
// most maxErrorBodySize bytes into AcceptedError.Raw and does not allocate
1406+
// unbounded memory.
1407+
func TestDo_AcceptedError_LargeBodyTruncated(t *testing.T) {
1408+
t.Parallel()
1409+
client, mux, _ := setup(t)
1410+
1411+
// Serve a 202 response whose body exceeds the cap by one byte.
1412+
mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
1413+
w.WriteHeader(http.StatusAccepted)
1414+
fmt.Fprint(w, strings.Repeat("x", maxErrorBodySize+1))
1415+
})
1416+
1417+
req, _ := client.NewRequest(t.Context(), "GET", ".", nil)
1418+
_, err := client.Do(req, nil)
1419+
if err == nil {
1420+
t.Fatal("Expected AcceptedError, got nil")
1421+
}
1422+
1423+
var aerr *AcceptedError
1424+
if !errors.As(err, &aerr) {
1425+
t.Fatalf("Expected *AcceptedError, got %T: %v", err, err)
1426+
}
1427+
1428+
if got, want := len(aerr.Raw), maxErrorBodySize; got != want {
1429+
t.Errorf("AcceptedError.Raw length = %v, want %v (maxErrorBodySize)", got, want)
1430+
}
1431+
}
1432+
14031433
// Test that an error caused by the internal http client's Do() function
14041434
// does not leak the client secret.
14051435
func TestDo_sanitizeURL(t *testing.T) {
@@ -2982,6 +3012,38 @@ func TestCheckResponse_unexpectedErrorStructure(t *testing.T) {
29823012
}
29833013
}
29843014

3015+
// TestCheckResponse_LargeBodyTruncated verifies that CheckResponse reads at
3016+
// most maxErrorBodySize bytes from an error response body, so that a
3017+
// malicious or buggy server cannot cause the client to allocate unbounded
3018+
// memory.
3019+
func TestCheckResponse_LargeBodyTruncated(t *testing.T) {
3020+
t.Parallel()
3021+
// Build a body that is one byte larger than the cap.
3022+
body := strings.Repeat("x", maxErrorBodySize+1)
3023+
res := &http.Response{
3024+
Request: &http.Request{},
3025+
StatusCode: http.StatusBadRequest,
3026+
Body: io.NopCloser(strings.NewReader(body)),
3027+
}
3028+
3029+
// CheckResponse should not return an error from the read itself; the HTTP
3030+
// error status is the expected error.
3031+
if err := CheckResponse(res); err == nil {
3032+
t.Fatal("Expected error from CheckResponse, got nil")
3033+
}
3034+
3035+
// After CheckResponse, the body is restored with the (truncated) bytes that
3036+
// were actually read. Verify the restored body is exactly maxErrorBodySize
3037+
// bytes — not the full maxErrorBodySize+1 that the server sent.
3038+
restored, err := io.ReadAll(res.Body)
3039+
if err != nil {
3040+
t.Fatalf("io.ReadAll on restored body: %v", err)
3041+
}
3042+
if got, want := len(restored), maxErrorBodySize; got != want {
3043+
t.Errorf("restored body length = %v, want %v (maxErrorBodySize)", got, want)
3044+
}
3045+
}
3046+
29853047
func TestParseBooleanResponse_true(t *testing.T) {
29863048
t.Parallel()
29873049
result, err := parseBoolResponse(nil)

0 commit comments

Comments
 (0)