Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions dial.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,10 @@ func (opts *DialOptions) cloneWithDefaults(ctx context.Context) (context.Context
// The response is the WebSocket handshake response from the server.
// You never need to close resp.Body yourself.
//
// If an error occurs, the returned response may be non nil.
// However, you can only read the first 1024 bytes of the body.
// If an error occurs, the returned response may be non nil. If the server
// answers with a status other than 101 Switching Protocols, err wraps
// ErrBadStatus, the response is non nil, and resp.Body holds up to the
// first 1024 bytes of the response body.
//
// This function requires at least Go 1.12 as it uses a new feature
// in net/http to perform WebSocket handshakes.
Expand Down Expand Up @@ -241,7 +243,7 @@ func secWebSocketKey(rr io.Reader) (string, error) {

func verifyServerResponse(opts *DialOptions, copts *compressionOptions, secWebSocketKey string, resp *http.Response) (*compressionOptions, error) {
if resp.StatusCode != http.StatusSwitchingProtocols {
return nil, fmt.Errorf("expected handshake response status code %v but got %v", http.StatusSwitchingProtocols, resp.StatusCode)
return nil, badStatusError{statusCode: resp.StatusCode}
}

if !headerContainsTokenIgnoreCase(resp.Header, "Connection", "Upgrade") {
Expand All @@ -267,6 +269,20 @@ func verifyServerResponse(opts *DialOptions, copts *compressionOptions, secWebSo
return verifyServerExtensions(copts, resp.Header)
}

// badStatusError wraps ErrBadStatus without adding its text, because
// callers match on the Dial error string from before ErrBadStatus existed.
type badStatusError struct {
statusCode int
}

func (e badStatusError) Error() string {
return fmt.Sprintf("expected handshake response status code %v but got %v", http.StatusSwitchingProtocols, e.statusCode)
}

func (badStatusError) Unwrap() error {
return ErrBadStatus
}

func verifySubprotocol(subprotos []string, resp *http.Response) error {
proto := resp.Header.Get("Sec-WebSocket-Protocol")
if proto == "" {
Expand Down
75 changes: 75 additions & 0 deletions dial_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"bytes"
"context"
"crypto/rand"
"errors"
"io"
"maps"
"net/http"
Expand Down Expand Up @@ -123,6 +124,80 @@ func TestBadDials(t *testing.T) {
})
}

func TestDialErrBadStatus(t *testing.T) {
t.Parallel()

t.Run("nonSwitchingStatus", func(t *testing.T) {
t.Parallel()

body := strings.Repeat("a", 1024) + "b"
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
io.WriteString(w, body)
}))
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, "error", "failed to WebSocket dial: expected handshake response status code 101 but got 403", err.Error())
assert.ErrorIs(t, websocket.ErrBadStatus, err)
if resp == nil {
t.Fatal("expected non-nil response")
}
assert.Equal(t, "status code", http.StatusForbidden, resp.StatusCode)
b, err := io.ReadAll(resp.Body)
assert.Success(t, err)
assert.Equal(t, "body", body[:1024], string(b))
})

t.Run("invalidAccept", func(t *testing.T) {
t.Parallel()

s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, brw, err := w.(http.Hijacker).Hijack()
if err != nil {
return
}
defer conn.Close()
brw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Accept: invalid\r\n\r\n")
brw.Flush()
}))
defer s.Close()

ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()

_, resp, err := websocket.Dial(ctx, s.URL, nil)
assert.Contains(t, err, "invalid Sec-WebSocket-Accept")
assert.Equal(t, "errors.Is(err, ErrBadStatus)", false, errors.Is(err, websocket.ErrBadStatus))
if resp == nil {
t.Fatal("expected non-nil response")
}
assert.Equal(t, "status code", http.StatusSwitchingProtocols, resp.StatusCode)
})

t.Run("networkError", func(t *testing.T) {
t.Parallel()

s := httptest.NewServer(http.NotFoundHandler())
u := s.URL
s.Close()

ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()

_, resp, err := websocket.Dial(ctx, u, nil)
assert.Error(t, err)
assert.Equal(t, "errors.Is(err, ErrBadStatus)", false, errors.Is(err, websocket.ErrBadStatus))
if resp != nil {
t.Fatalf("expected nil response, got status %d", resp.StatusCode)
}
})
}

func Test_verifyHostOverride(t *testing.T) {
testCases := []struct {
name string
Expand Down
8 changes: 8 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,13 @@ import (
"errors"
)

// ErrBadStatus is wrapped by the error from Dial when the server answers
// the opening handshake with an HTTP status other than 101 Switching
// Protocols. See Dial for the response returned with it.
//
// On js/wasm, Dial never returns an error wrapping ErrBadStatus because
// the browser does not expose the handshake response.
var ErrBadStatus = errors.New("websocket: bad status")

// ErrMessageTooBig is returned when a message exceeds the read limit.
var ErrMessageTooBig = errors.New("websocket: message too big")
Loading