From a2fa86fbcf48a6f219452e072f09de1e3712840b Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 27 Oct 2026 18:44:43 +0000 Subject: [PATCH] fix(dial): skip body read after a failed 101 handshake When the server answered 101 but a header check failed, Dial read up to 1024 bytes from the response body before returning. After a 101 the body is the upgraded connection, so a silent server made Dial wait for the 3 s timer. Close it instead and return an empty body; other statuses still return the first 1024 bytes. --- dial.go | 8 +++++++ dial_test.go | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/dial.go b/dial.go index f5e4544b..90832c4d 100644 --- a/dial.go +++ b/dial.go @@ -148,6 +148,14 @@ func dial(ctx context.Context, urls string, opts *DialOptions, rand io.Reader) ( resp.Body = nil defer func() { if err != nil { + if resp.StatusCode == http.StatusSwitchingProtocols { + // The body is the upgraded connection, not an HTTP body. + // Reading it waits for data the server may never send. + respBody.Close() + resp.Body = http.NoBody + return + } + // We read a bit of the body for easier debugging. r := io.LimitReader(respBody, 1024) diff --git a/dial_test.go b/dial_test.go index 492ac6b3..8b33109d 100644 --- a/dial_test.go +++ b/dial_test.go @@ -416,3 +416,62 @@ func TestDialViaProxy(t *testing.T) { assertEcho(t, ctx, c) assertClose(t, c) } + +func TestDialFailedHandshakeBody(t *testing.T) { + t.Parallel() + + t.Run("switchingProtocols", func(t *testing.T) { + t.Parallel() + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, brw, err := http.NewResponseController(w).Hijack() + if err != nil { + t.Errorf("failed to hijack: %v", err) + return + } + defer conn.Close() + brw.WriteString("HTTP/1.1 101 Switching Protocols\r\n" + + "Connection: Upgrade\r\n" + + "Upgrade: websocket\r\n" + + "Sec-WebSocket-Accept: xd\r\n\r\n") + brw.Flush() + // Send nothing more and wait for the client to close. + io.Copy(io.Discard, conn) + })) + defer s.Close() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + start := time.Now() + _, resp, err := websocket.Dial(ctx, s.URL, nil) + elapsed := time.Since(start) + assert.Contains(t, err, "invalid Sec-WebSocket-Accept") + assert.Equal(t, "StatusCode", http.StatusSwitchingProtocols, resp.StatusCode) + b, err := io.ReadAll(resp.Body) + assert.Success(t, err) + assert.Equal(t, "body", "", string(b)) + if elapsed > time.Second { + t.Fatalf("Dial took %v, want under 1s", elapsed) + } + }) + + t.Run("forbidden", func(t *testing.T) { + t.Parallel() + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "forbidden by test", http.StatusForbidden) + })) + defer s.Close() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + _, resp, err := websocket.Dial(ctx, s.URL, nil) + assert.Error(t, err) + assert.Equal(t, "StatusCode", http.StatusForbidden, resp.StatusCode) + b, err := io.ReadAll(resp.Body) + assert.Success(t, err) + assert.Equal(t, "body", "forbidden by test\n", string(b)) + }) +}