From 8ccfcbc5718e73f0284362ec068903ca480b6e2f Mon Sep 17 00:00:00 2001 From: SapirBaruch Date: Mon, 1 Jun 2026 21:33:30 +0300 Subject: [PATCH 1/3] reverseproxy: re-apply WebSocket header normalization after header ops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When transport or user header ops are configured, proxyLoopIteration rebuilds r.Header from scratch using copyHeader. copyHeader calls http.Header.Add internally which canonicalizes header names via CanonicalHeaderKey — this lowercases the 'S' in WebSocket, turning "Sec-WebSocket-Key" into "Sec-Websocket-Key". normalizeWebsocketHeaders was already called in prepareRequest to fix this, but the subsequent header rebuild in proxyLoopIteration undoes it. Calling normalizeWebsocketHeaders again after the rebuild restores the RFC 6455-compliant casing for all Sec-WebSocket-* headers. Fixes #7784 --- modules/caddyhttp/reverseproxy/reverseproxy.go | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/caddyhttp/reverseproxy/reverseproxy.go b/modules/caddyhttp/reverseproxy/reverseproxy.go index f062ef5988b..91c651bd17b 100644 --- a/modules/caddyhttp/reverseproxy/reverseproxy.go +++ b/modules/caddyhttp/reverseproxy/reverseproxy.go @@ -695,6 +695,7 @@ func (h *Handler) proxyLoopIteration(r *http.Request, origReq *http.Request, w h if userOps != nil { userOps.ApplyToRequest(r) } + normalizeWebsocketHeaders(r.Header) } // proxy the request to that upstream From 5e44b98a7b4509cb6e84c6d64d3c691a6833a9e9 Mon Sep 17 00:00:00 2001 From: SapirBaruch Date: Tue, 2 Jun 2026 20:52:51 +0300 Subject: [PATCH 2/3] test(reverseproxy): add tests for normalizeWebsocketHeaders Add TestNormalizeWebsocketHeaders covering the four cases: - canonical (lowercased) header names are renamed to RFC 6455 form - headers already in the correct form are left unchanged - non-WebSocket headers are untouched - empty header map is a no-op Add TestNormalizeWebsocketHeadersSurvivesCopyHeader as a targeted regression test for #7784: simulates the header-rebuild that proxyLoopIteration performs when transport or header ops are configured, verifies that calling normalizeWebsocketHeaders afterwards restores Sec-WebSocket-* to the RFC 6455 casing. --- .../caddyhttp/reverseproxy/websocket_test.go | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 modules/caddyhttp/reverseproxy/websocket_test.go diff --git a/modules/caddyhttp/reverseproxy/websocket_test.go b/modules/caddyhttp/reverseproxy/websocket_test.go new file mode 100644 index 00000000000..ef2b9e3984a --- /dev/null +++ b/modules/caddyhttp/reverseproxy/websocket_test.go @@ -0,0 +1,108 @@ +package reverseproxy + +import ( + "net/http" + "testing" +) + +func TestNormalizeWebsocketHeaders(t *testing.T) { + tests := []struct { + name string + input http.Header + want http.Header + }{ + { + name: "canonicalized headers are renamed to RFC 6455 form", + input: http.Header{ + // Go's http.CanonicalHeaderKey lowercases the 'S' in WebSocket: + // "Sec-WebSocket-Key" -> "Sec-Websocket-Key" + "Sec-Websocket-Key": {"dGhlIHNhbXBsZSBub25jZQ=="}, + "Sec-Websocket-Version": {"13"}, + "Sec-Websocket-Protocol": {"chat"}, + "Sec-Websocket-Extensions": {"permessage-deflate"}, + }, + want: http.Header{ + "Sec-WebSocket-Key": {"dGhlIHNhbXBsZSBub25jZQ=="}, + "Sec-WebSocket-Version": {"13"}, + "Sec-WebSocket-Protocol": {"chat"}, + "Sec-WebSocket-Extensions": {"permessage-deflate"}, + }, + }, + { + name: "already-correct headers are left unchanged", + input: http.Header{ + "Sec-WebSocket-Key": {"abc123"}, + "Sec-WebSocket-Version": {"13"}, + }, + want: http.Header{ + "Sec-WebSocket-Key": {"abc123"}, + "Sec-WebSocket-Version": {"13"}, + }, + }, + { + name: "non-WebSocket headers are untouched", + input: http.Header{"Content-Type": {"text/plain"}, "X-Foo": {"bar"}}, + want: http.Header{"Content-Type": {"text/plain"}, "X-Foo": {"bar"}}, + }, + { + name: "empty header map is a no-op", + input: http.Header{}, + want: http.Header{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + normalizeWebsocketHeaders(tt.input) + for k, wantV := range tt.want { + gotV, ok := tt.input[k] + if !ok { + t.Errorf("missing header %q", k) + continue + } + if len(gotV) != len(wantV) || gotV[0] != wantV[0] { + t.Errorf("header %q: got %v, want %v", k, gotV, wantV) + } + } + // Ensure no extra keys remain (old canonical forms must be deleted). + for k := range tt.input { + if _, ok := tt.want[k]; !ok { + t.Errorf("unexpected header key left in map: %q", k) + } + } + }) + } +} + +// TestNormalizeWebsocketHeadersSurvivesCopyHeader is a regression test for +// https://github.com/caddyserver/caddy/issues/7784. +// +// proxyLoopIteration rebuilds r.Header with copyHeader when transport or header +// ops are configured. copyHeader uses http.Header.Add internally, which calls +// http.CanonicalHeaderKey and lowercases the 'S' in "WebSocket" to produce +// "Sec-Websocket-*". The fix calls normalizeWebsocketHeaders after the rebuild +// so the RFC 6455 casing is restored before the request is forwarded. +func TestNormalizeWebsocketHeadersSurvivesCopyHeader(t *testing.T) { + // Simulate the state of r.Header after copyHeader re-canonicalizes it. + rebuilt := make(http.Header) + // http.Header.Add canonicalizes to "Sec-Websocket-Key" (lowercase 's'). + rebuilt.Add("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==") + rebuilt.Add("Sec-WebSocket-Version", "13") + + // At this point the map contains the lowercase form. + if _, ok := rebuilt["Sec-Websocket-Key"]; !ok { + t.Fatal("test setup: expected canonical (lowercase) key to be present after Add") + } + + // The fix: call normalizeWebsocketHeaders after the rebuild. + normalizeWebsocketHeaders(rebuilt) + + // RFC 6455 form must be present. + if v := rebuilt.Get("Sec-WebSocket-Key"); v == "" { + t.Error("Sec-WebSocket-Key missing after normalize; WebSocket upgrade will fail") + } + // Lowercase form must be gone. + if _, ok := rebuilt["Sec-Websocket-Key"]; ok { + t.Error("canonical (lowercase) Sec-Websocket-Key still present after normalize") + } +} From 04c494cb00cb97bc742d6f1caecfcb41e78f17b8 Mon Sep 17 00:00:00 2001 From: SapirBaruch Date: Tue, 2 Jun 2026 21:09:56 +0300 Subject: [PATCH 3/3] test: fix TestNormalizeWebsocketHeadersSurvivesCopyHeader assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit http.Header.Get re-canonicalizes its argument via CanonicalHeaderKey, so rebuilt.Get("Sec-WebSocket-Key") looks up the Go-canonical form "Sec-Websocket-Key" — the key normalizeWebsocketHeaders just deleted. Use a direct map lookup instead to assert the RFC 6455 key is present. --- modules/caddyhttp/reverseproxy/websocket_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/caddyhttp/reverseproxy/websocket_test.go b/modules/caddyhttp/reverseproxy/websocket_test.go index ef2b9e3984a..fc626c2eebd 100644 --- a/modules/caddyhttp/reverseproxy/websocket_test.go +++ b/modules/caddyhttp/reverseproxy/websocket_test.go @@ -97,8 +97,9 @@ func TestNormalizeWebsocketHeadersSurvivesCopyHeader(t *testing.T) { // The fix: call normalizeWebsocketHeaders after the rebuild. normalizeWebsocketHeaders(rebuilt) - // RFC 6455 form must be present. - if v := rebuilt.Get("Sec-WebSocket-Key"); v == "" { + // RFC 6455 form must be present (direct map lookup — .Get() re-canonicalizes + // to "Sec-Websocket-Key" and would miss the corrected key). + if _, ok := rebuilt["Sec-WebSocket-Key"]; !ok { t.Error("Sec-WebSocket-Key missing after normalize; WebSocket upgrade will fail") } // Lowercase form must be gone.