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 {