Repository navigation
Reconsider and document redirect policy #518
Description
Activity
Confirming the "Edit" in the issue body: yes — specifying a non-nil
CheckRedirectremoves Go's default 10-redirect cap, soDialcurrently follows redirects with no limit.net/httpapplies the 10-cap only throughdefaultCheckRedirect, which it uses solely whenClient.CheckRedirect == nil:func defaultCheckRedirect(req *Request, via []*Request) error { if len(via) >= 10 { return errors.New("stopped after 10 redirects") } return nil }
Dialalways installs a non-nil wrapper (v1.8.15,dial.go), and when the caller'sCheckRedirectis 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 becausenet/httpaccumulates every prior request invia, a redirect loop also grows memory for its duration.Repro (v1.8.15, go1.26.5, linux/amd64):
- A server that
302s 30 times then404s → the client makes 31 requests and fails withexpected handshake response status code 101 but got 404(the final 404), notstopped 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 bycontext 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'slen(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.)- A server that
The problem
It looks like that by default the library uses the default HTTPClient:
websocket/dial.go
Lines 76 to 103 in d1468a7
And the default HTTP client does follow redirects:
Edit: actually, doesn't the fact that we do specify a
CheckRedirectfunction mean that we do not stop after 10 consecutive requests?I am aware that it is possible to provide your own
HTTPClientstruct to theDial()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: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:
For reference, the WebSocket spec itself states:
Also for reference, the Gorilla WebSocket library IMU does not follow redirects, see this issue: gorilla/websocket#965 and the code.
Suggested solution
This can either be considered a breaking change (so it goes to v2.x.x), or a bug fix.
Related issue: #333