This is an automated email from the ASF dual-hosted git repository.

zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git


The following commit(s) were added to refs/heads/main by this push:
     new 297fb2c1 fix(catalog/rest): do not leak response bodies (#655)
297fb2c1 is described below

commit 297fb2c1e1527c3a106b774e4e6269f40fda3f5d
Author: ferhat elmas <[email protected]>
AuthorDate: Tue Dec 23 16:55:11 2025 +0100

    fix(catalog/rest): do not leak response bodies (#655)
    
    In error case, body isn't closed so
    underlying TCP connection might not be used.
    Defer closing and draining unconditionally
    for safer and more idiomatic handling.
    
    Signed-off-by: ferhat elmas <[email protected]>
---
 catalog/rest/rest.go               | 10 ++++-
 catalog/rest/rest_internal_test.go | 79 ++++++++++++++++++++++++++++++++++++++
 2 files changed, 87 insertions(+), 2 deletions(-)

diff --git a/catalog/rest/rest.go b/catalog/rest/rest.go
index 1633ffb7..6c1f92b9 100644
--- a/catalog/rest/rest.go
+++ b/catalog/rest/rest.go
@@ -266,6 +266,10 @@ func do[T any](ctx context.Context, method string, baseURI 
*url.URL, path []stri
        if rsp, err = cl.Do(req); err != nil {
                return ret, err
        }
+       defer func() {
+               _, _ = io.Copy(io.Discard, rsp.Body)
+               _ = rsp.Body.Close()
+       }()
 
        if allowNoContent && rsp.StatusCode == http.StatusNoContent {
                return ret, err
@@ -279,7 +283,6 @@ func do[T any](ctx context.Context, method string, baseURI 
*url.URL, path []stri
                return ret, err
        }
 
-       defer rsp.Body.Close()
        if err = json.NewDecoder(rsp.Body).Decode(&ret); err != nil {
                return ret, fmt.Errorf("%w: error decoding json payload: `%s`", 
ErrRESTError, err.Error())
        }
@@ -327,6 +330,10 @@ func doPostAllowNoContent[Payload, Result any](ctx 
context.Context, baseURI *url
        if err != nil {
                return ret, err
        }
+       defer func() {
+               _, _ = io.Copy(io.Discard, rsp.Body)
+               _ = rsp.Body.Close()
+       }()
 
        if allowNoContent && rsp.StatusCode == http.StatusNoContent {
                return ret, err
@@ -340,7 +347,6 @@ func doPostAllowNoContent[Payload, Result any](ctx 
context.Context, baseURI *url
                return ret, err
        }
 
-       defer rsp.Body.Close()
        if err = json.NewDecoder(rsp.Body).Decode(&ret); err != nil {
                return ret, fmt.Errorf("%w: error decoding json payload: `%s`", 
ErrRESTError, err.Error())
        }
diff --git a/catalog/rest/rest_internal_test.go 
b/catalog/rest/rest_internal_test.go
index 62135aca..6141ce53 100644
--- a/catalog/rest/rest_internal_test.go
+++ b/catalog/rest/rest_internal_test.go
@@ -465,3 +465,82 @@ func TestSigv4ConcurrentSigners(t *testing.T) {
        require.NoError(t, grp.Wait())
        t.Logf("issued %d requests", count.Load())
 }
+
+// trackingReadCloser wraps an io.ReadCloser to track if Close() was called
+type trackingReadCloser struct {
+       io.ReadCloser
+       closed bool
+}
+
+func (t *trackingReadCloser) Close() error {
+       t.closed = true
+
+       return t.ReadCloser.Close()
+}
+
+// trackingTransport wraps http.RoundTripper to track response bodies
+type trackingTransport struct {
+       transport http.RoundTripper
+       body      *trackingReadCloser
+}
+
+func (t *trackingTransport) RoundTrip(req *http.Request) (*http.Response, 
error) {
+       resp, err := t.transport.RoundTrip(req)
+       if err != nil {
+               return nil, err
+       }
+
+       t.body = &trackingReadCloser{ReadCloser: resp.Body}
+       resp.Body = t.body
+
+       return resp, nil
+}
+
+// TestResponseBodyLeak checks if response body is closed properly.
+func TestResponseBodyLeak(t *testing.T) {
+       t.Parallel()
+
+       mux := http.NewServeMux()
+       srv := httptest.NewServer(mux)
+       defer srv.Close()
+
+       mux.HandleFunc("/test", func(w http.ResponseWriter, r *http.Request) {
+               w.WriteHeader(http.StatusNotFound)
+               json.NewEncoder(w).Encode(map[string]any{
+                       "error": errorResponse{
+                               Message: "not found",
+                               Type:    "NoSuchTableException",
+                               Code:    404,
+                       },
+               })
+       })
+
+       t.Run("do", func(t *testing.T) {
+               tracker := &trackingTransport{transport: http.DefaultTransport}
+               client := &http.Client{Transport: tracker}
+
+               baseURI, err := url.Parse(srv.URL)
+               require.NoError(t, err)
+
+               _, err = do[struct{}](context.Background(), http.MethodGet, 
baseURI, []string{"test"}, client, nil, false)
+               require.Error(t, err)
+
+               assert.True(t, tracker.body.closed,
+                       "response body should be closed on non-200 status")
+       })
+
+       t.Run("doPostAllowNoContent", func(t *testing.T) {
+               tracker := &trackingTransport{transport: http.DefaultTransport}
+               client := &http.Client{Transport: tracker}
+
+               baseURI, err := url.Parse(srv.URL)
+               require.NoError(t, err)
+
+               _, err = doPostAllowNoContent[map[string]string, struct{}](
+                       context.Background(), baseURI, []string{"test"}, 
map[string]string{"key": "value"}, client, nil, false)
+               require.Error(t, err)
+
+               assert.True(t, tracker.body.closed,
+                       "response body should be closed on non-200 status")
+       })
+}

Reply via email to