Skip to content

Reconsider and document redirect policy #518

Description

@WofWca

The problem

It looks like that by default the library uses the default HTTPClient:

websocket/dial.go

Lines 76 to 103 in d1468a7

if o.HTTPClient == nil {
o.HTTPClient = http.DefaultClient
}
if o.HTTPClient.Timeout > 0 {
ctx, cancel = context.WithTimeout(ctx, o.HTTPClient.Timeout)
newClient := *o.HTTPClient
newClient.Timeout = 0
o.HTTPClient = &newClient
}
if o.HTTPHeader == nil {
o.HTTPHeader = http.Header{}
}
newClient := *o.HTTPClient
oldCheckRedirect := o.HTTPClient.CheckRedirect
newClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
switch req.URL.Scheme {
case "ws":
req.URL.Scheme = "http"
case "wss":
req.URL.Scheme = "https"
}
if oldCheckRedirect != nil {
return oldCheckRedirect(req, via)
}
return nil
}
o.HTTPClient = &newClient

And the default HTTP client does follow redirects:

// If CheckRedirect is nil, the Client uses its default policy,
// which is to stop after 10 consecutive requests.

Edit: actually, doesn't the fact that we do specify a CheckRedirect function mean that we do not stop after 10 consecutive requests?

I am aware that it is possible to provide your own HTTPClient struct to the Dial() function, and it is nice to rely on built-in defaults, I think a WebSocket library should be more concrete in this regard. Especially given the fact that this particular one claims to be able to target WASM. And, according to the browser WebSocket spec, redirects are not followed:

redirect mode is "error"

The reason redirects are not followed and this handshake is generally restricted is because it could introduce serious security problems in a web browser context. For example, consider a host with a WebSocket server at one path and an open HTTP redirector at another. Suddenly, any script that can be given a particular WebSocket URL can be tricked into communicating to (and potentially sharing secrets with) any host on the internet, even if the script checks that the URL has the right hostname.

This IMO can be considered an inconsistency between different targets.
Note that changing redirect policy for the WASM version is not possible, according to this library's docs:

HTTPClient, HTTPHeader and CompressionMode in DialOptions are no-op


For reference, the WebSocket spec itself states:

the server might redirect the client using a 3xx status code (but clients are not required to follow them)

Also for reference, the Gorilla WebSocket library IMU does not follow redirects, see this issue: gorilla/websocket#965 and the code.

Suggested solution

  1. Document that the library follows redirects, but not in the WASM version.
  2. For the next version: Do not follow redirects by default, and document this.
    This can either be considered a breaking change (so it goes to v2.x.x), or a bug fix.

Related issue: #333

Activity

  1. thc1006 commented on Jul 13, 2026

    @thc1006

    Confirming the "Edit" in the issue body: yes — specifying a non-nil CheckRedirect removes Go's default 10-redirect cap, so Dial currently follows redirects with no limit.

    net/http applies the 10-cap only through defaultCheckRedirect, which it uses solely when Client.CheckRedirect == nil:

    func defaultCheckRedirect(req *Request, via []*Request) error {
        if len(via) >= 10 {
            return errors.New("stopped after 10 redirects")
        }
        return nil
    }

    Dial always installs a non-nil wrapper (v1.8.15, dial.go), and when the caller's CheckRedirect is nil the wrapper returns nil (follow) with no count check:

    newClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
        switch req.URL.Scheme {
        case "ws":  req.URL.Scheme = "http"
        case "wss": req.URL.Scheme = "https"
        }
        if oldCheckRedirect != nil {
            return oldCheckRedirect(req, via)
        }
        return nil
    }

    So a plain &http.Client{} (the common case) follows redirects unbounded — limited only by a context deadline, if the caller set one. And because net/http accumulates every prior request in via, a redirect loop also grows memory for its duration.

    Repro (v1.8.15, go1.26.5, linux/amd64):

    • A server that 302s 30 times then 404s → the client makes 31 requests and fails with expected handshake response status code 101 but got 404 (the final 404), not stopped after 10 redirects.
    • A server that always 302s to itself, with the dial bounded by a 400 ms context → several thousand requests (~5k) in 400 ms, stopped only by context deadline exceeded.
    const chainLen = 30 // > Go's default cap of 10
    var hits int32
    var srv *httptest.Server
    srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if int(atomic.AddInt32(&hits, 1)) <= chainLen {
            http.Redirect(w, r, srv.URL+"/r", http.StatusFound)
            return
        }
        http.Error(w, "end", http.StatusNotFound)
    }))
    defer srv.Close()
    
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    _, _, _ = websocket.Dial(ctx, strings.Replace(srv.URL, "http://", "ws://", 1),
        &websocket.DialOptions{HTTPClient: &http.Client{}}) // nil CheckRedirect
    // atomic.LoadInt32(&hits) == 31, not ~11

    Alongside the "don't follow redirects by default" direction already proposed here, it may be worth having the wrapper preserve a bound when it wraps a nil caller callback — e.g. mirror defaultCheckRedirect's len(via) >= 10 — so the count limit isn't silently lost even if following-by-default is kept for compatibility.

    (Workaround for callers today: pass HTTPClient: &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} to refuse redirects at the handshake.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions