@marcel91200 / caddy Mirror · read-only / commits / f4d9826b
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 10, 23:45 UTC

reverseproxy: simplify WebSocket header handling (#8365)

Zen Dodd committed Nov 5, 2026 at 19:45 UTC f4d9826bf2a10a83c3c27223eb0df3dc63c1a51b
4 files changed +64 -5
.golangci.yml
-1
@@ -52,7 +52,6 @@ linters:
52 # Match net/http's canonical MIME map keys to avoid re-canonicalisation.
53 exclusions:
54 - Etag
55 - - Sec-Websocket-Key
55 - Te
56 - Www-Authenticate
57 staticcheck:
modules/caddyhttp/reverseproxy/extended_connect_test.go
+61
@@ -5,6 +5,7 @@ import (
5 "io"
6 "net/http"
7 "net/http/httptest"
8 + "strconv"
9 "strings"
10 "sync"
11 "testing"
@@ -265,6 +266,9 @@ func TestServeHTTPRewritesExtendedConnectWebsocketSecWebSocketKeyMatrix(t *testi
266 var keyValues []string
267 for k, v := range captured.headers {
268 if strings.EqualFold(k, "Sec-WebSocket-Key") {
269 + if k != "Sec-WebSocket-Key" {
270 + t.Fatalf("upstream WebSocket key spelling = %q, want Sec-WebSocket-Key", k)
271 + }
272 keyValues = append(keyValues, v...)
273 }
274 }
@@ -302,3 +306,60 @@ func TestServeHTTPRewritesExtendedConnectWebsocketSecWebSocketKeyMatrix(t *testi
306 }
307 }
308 }
309 +
310 +func TestUpgradeResponseWebsocketHeaders(t *testing.T) {
311 + for _, proto := range []int{1, 2, 3} {
312 + t.Run("h"+strconv.Itoa(proto), func(t *testing.T) {
313 + h := &Handler{logger: zap.NewNop()}
314 + req := prepareTestRequest(httptest.NewRequest(http.MethodGet, "http://example.test/upgrade", nil))
315 + req.ProtoMajor = proto
316 + req.Header.Set("Connection", "Upgrade")
317 + req.Header.Set("Upgrade", "websocket")
318 + wantStatus := http.StatusSwitchingProtocols
319 + if proto > 1 {
320 + caddyhttp.SetVar(req.Context(), "extended_connect_websocket_body", http.NoBody)
321 + wantStatus = http.StatusOK
322 + }
323 + res := &http.Response{
324 + StatusCode: http.StatusSwitchingProtocols,
325 + Header: req.Header.Clone(),
326 + Body: fakeRWC{strings.NewReader("")},
327 + }
328 + res.Header.Set("Sec-WebSocket-Accept", "backend-accept")
329 + res.Header.Set("Sec-WebSocket-Protocol", "chat")
330 + rw := upgradeHeaderRecorder{httptest.NewRecorder()}
331 + h.handleUpgradeResponse(h.logger, rw, req, res, "test-upstream")
332 + response := rw.Result()
333 + defer response.Body.Close()
334 + if response.StatusCode != wantStatus {
335 + t.Fatalf("status = %d, want %d", response.StatusCode, wantStatus)
336 + }
337 + var accept, protocol string
338 + for key, values := range response.Header {
339 + if strings.EqualFold(key, "Sec-WebSocket-Accept") {
340 + if proto > 1 {
341 + t.Fatalf("extended CONNECT response contains %s", key)
342 + }
343 + if key != "Sec-WebSocket-Accept" {
344 + t.Fatalf("WebSocket accept spelling = %q, want Sec-WebSocket-Accept", key)
345 + }
346 + accept = strings.Join(values, ",")
347 + }
348 + if strings.EqualFold(key, "Sec-WebSocket-Protocol") {
349 + protocol = strings.Join(values, ",")
350 + }
351 + }
352 + if proto == 1 && accept != "backend-accept" {
353 + t.Fatalf("accept = %q, want backend-accept", accept)
354 + }
355 + if protocol != "chat" {
356 + t.Fatalf("subprotocol = %q, want chat", protocol)
357 + }
358 + })
359 + }
360 +}
361 +
362 +type upgradeHeaderRecorder struct{ *httptest.ResponseRecorder }
363 +
364 +// Stop after the headers are committed, before the tunnel starts.
365 +func (upgradeHeaderRecorder) FlushError() error { return io.ErrClosedPipe }
modules/caddyhttp/reverseproxy/reverseproxy.go
+1 -2
@@ -655,8 +655,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht
655 if randErr != nil {
656 return randErr
657 }
658 - clonedReq.Header.Del("Sec-WebSocket-Key") //nolint:canonicalheader // RFC 6455 casing, see normalizeWebsocketHeaders
659 - clonedReq.Header["Sec-WebSocket-Key"] = []string{base64.StdEncoding.EncodeToString(key)} //nolint:canonicalheader // RFC 6455 casing, see normalizeWebsocketHeaders
658 + clonedReq.Header.Set("Sec-WebSocket-Key", base64.StdEncoding.EncodeToString(key))
659 }
660
661 // we will need the original headers and Host value if
modules/caddyhttp/reverseproxy/streaming.go
+2 -2
@@ -103,7 +103,6 @@ func (h *Handler) handleUpgradeResponse(logger *zap.Logger, rw http.ResponseWrit
103 // write header first, response headers should not be counted in size
104 // like the rest of handler chain.
105 copyHeader(rw.Header(), res.Header)
106 - normalizeWebsocketHeaders(rw.Header())
106
107 // Capture all h fields needed by the tunnel now, so that the Handler (h)
108 // is not referenced after this function returns (for HTTP/1.1 hijacked
@@ -127,7 +126,7 @@ func (h *Handler) handleUpgradeResponse(logger *zap.Logger, rw http.ResponseWrit
126 req.Body = body
127 rw.Header().Del("Upgrade")
128 rw.Header().Del("Connection")
130 - delete(rw.Header(), "Sec-WebSocket-Accept") //nolint:canonicalheader // set by normalizeWebsocketHeaders above
129 + rw.Header().Del("Sec-WebSocket-Accept")
130 rw.WriteHeader(http.StatusOK)
131
132 if c := logger.Check(zap.DebugLevel, "upgrading connection"); c != nil {
@@ -146,6 +145,7 @@ func (h *Handler) handleUpgradeResponse(logger *zap.Logger, rw http.ResponseWrit
145 // bufio is not needed, use minimal buffer
146 brw = bufio.NewReadWriter(bufio.NewReaderSize(conn, 1), bufio.NewWriterSize(conn, 1))
147 } else {
148 + normalizeWebsocketHeaders(rw.Header())
149 if h.StreamDetached {
150 detached = caddyhttp.DetachResponseWriterAfterHijack(rw, req, true)
151 if !detached {