From 8ccfcbc5718e73f0284362ec068903ca480b6e2f Mon Sep 17 00:00:00 2001 From: SapirBaruch Date: Mon, 1 Jun 2026 21:33:30 +0300 Subject: [PATCH 1/6] 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 f062ef598..91c651bd1 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/6] 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 000000000..ef2b9e398 --- /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/6] 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 ef2b9e398..fc626c2ee 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. From 9a9078519b9f48a59b8587db7d6305da97c749ce Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Sat, 13 Jun 2026 22:22:48 +1000 Subject: [PATCH 4/6] reverseproxy: test websocket header rebuild normalisation --- .../caddyhttp/reverseproxy/reverseproxy.go | 48 ++++---- .../caddyhttp/reverseproxy/websocket_test.go | 113 ++++++++++++++---- 2 files changed, 115 insertions(+), 46 deletions(-) diff --git a/modules/caddyhttp/reverseproxy/reverseproxy.go b/modules/caddyhttp/reverseproxy/reverseproxy.go index 91c651bd1..5c75b47b6 100644 --- a/modules/caddyhttp/reverseproxy/reverseproxy.go +++ b/modules/caddyhttp/reverseproxy/reverseproxy.go @@ -677,26 +677,8 @@ func (h *Handler) proxyLoopIteration(r *http.Request, origReq *http.Request, w h // mutate request headers according to this upstream; // because we're in a retry loop, we have to copy headers // (and the r.Host value) from the original so that each - // retry is identical to the first. If either transport or - // user ops exist, apply them in order (transport first, - // then user, so user's config wins). - var userOps *headers.HeaderOps - if h.Headers != nil { - userOps = h.Headers.Request - } - transportOps := h.transportHeaderOps - if transportOps != nil || userOps != nil { - r.Header = make(http.Header) - copyHeader(r.Header, reqHeader) - r.Host = reqHost - if transportOps != nil { - transportOps.ApplyToRequest(r) - } - if userOps != nil { - userOps.ApplyToRequest(r) - } - normalizeWebsocketHeaders(r.Header) - } + // retry is identical to the first. + h.rebuildRequestHeaders(r, reqHeader, reqHost) // proxy the request to that upstream proxyErr = h.reverseProxy(w, r, origReq, repl, dialInfo, next) @@ -729,6 +711,32 @@ func (h *Handler) proxyLoopIteration(r *http.Request, origReq *http.Request, w h return false, proxyErr } +// rebuildRequestHeaders rebuilds r.Header from reqHeader and applies any +// transport- and user-configured request header operations, so that each +// iteration of the proxy loop starts from the same base. If neither set of +// operations is configured, r.Header is left unchanged. Transport operations +// are applied before user operations, so the user's config wins. +func (h *Handler) rebuildRequestHeaders(r *http.Request, reqHeader http.Header, reqHost string) { + var userOps *headers.HeaderOps + if h.Headers != nil { + userOps = h.Headers.Request + } + transportOps := h.transportHeaderOps + if transportOps == nil && userOps == nil { + return + } + r.Header = make(http.Header) + copyHeader(r.Header, reqHeader) + r.Host = reqHost + if transportOps != nil { + transportOps.ApplyToRequest(r) + } + if userOps != nil { + userOps.ApplyToRequest(r) + } + normalizeWebsocketHeaders(r.Header) +} + // Mapping of the canonical form of the headers, to the RFC 6455 form, // i.e. `WebSocket` with uppercase 'S'. var websocketHeaderMapping = map[string]string{ diff --git a/modules/caddyhttp/reverseproxy/websocket_test.go b/modules/caddyhttp/reverseproxy/websocket_test.go index fc626c2ee..2028b6e51 100644 --- a/modules/caddyhttp/reverseproxy/websocket_test.go +++ b/modules/caddyhttp/reverseproxy/websocket_test.go @@ -1,15 +1,20 @@ package reverseproxy import ( + "context" "net/http" + "net/http/httptest" "testing" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/modules/caddyhttp/headers" ) func TestNormalizeWebsocketHeaders(t *testing.T) { tests := []struct { - name string - input http.Header - want http.Header + name string + input http.Header + want http.Header }{ { name: "canonicalized headers are renamed to RFC 6455 form", @@ -74,36 +79,92 @@ func TestNormalizeWebsocketHeaders(t *testing.T) { } } -// TestNormalizeWebsocketHeadersSurvivesCopyHeader is a regression test for +// TestRebuildRequestHeadersPreservesWebsocketCasing 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") +// "Sec-Websocket-*". The rebuild path must restore the RFC 6455 casing before +// the request is forwarded. +func TestRebuildRequestHeadersPreservesWebsocketCasing(t *testing.T) { + for _, tc := range []struct { + name string + handler Handler + }{ + { + name: "user header_ops only", + handler: Handler{ + Headers: &headers.Handler{ + Request: &headers.HeaderOps{ + Add: http.Header{"X-Custom": {"v"}}, + }, + }, + }, + }, + { + name: "transport-injected Host op only", + handler: Handler{ + transportHeaderOps: &headers.HeaderOps{ + Set: http.Header{"Host": {"upstream.example.com"}}, + }, + }, + }, + { + name: "transport and user ops together", + handler: Handler{ + transportHeaderOps: &headers.HeaderOps{ + Set: http.Header{"Host": {"upstream.example.com"}}, + }, + Headers: &headers.Handler{ + Request: &headers.HeaderOps{ + Add: http.Header{"X-Custom": {"v"}}, + }, + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + reqHeader := http.Header{} + reqHeader["Sec-WebSocket-Key"] = []string{"dGhlIHNhbXBsZSBub25jZQ=="} + reqHeader["Sec-WebSocket-Version"] = []string{"13"} + reqHeader.Set("Connection", "Upgrade") + reqHeader.Set("Upgrade", "websocket") - // 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") - } + req := httptest.NewRequest("GET", "http://example.com/", nil) + ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, caddy.NewReplacer()) + req = req.WithContext(ctx) - // The fix: call normalizeWebsocketHeaders after the rebuild. - normalizeWebsocketHeaders(rebuilt) + tc.handler.rebuildRequestHeaders(req, reqHeader, "upstream.example.com") - // 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. - if _, ok := rebuilt["Sec-Websocket-Key"]; ok { - t.Error("canonical (lowercase) Sec-Websocket-Key still present after normalize") + for _, key := range []string{"Sec-WebSocket-Key", "Sec-WebSocket-Version"} { + if _, ok := req.Header[key]; !ok { + t.Errorf("%q missing after rebuild; header = %v", key, req.Header) + } + canonical := http.CanonicalHeaderKey(key) + if canonical == key { + continue + } + if _, ok := req.Header[canonical]; ok { + t.Errorf("%q leaked after rebuild; header = %v", canonical, req.Header) + } + } + }) + } +} + +func TestRebuildRequestHeadersIsNoOpWithoutOps(t *testing.T) { + h := Handler{} + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Original", "stays") + otherHeader := http.Header{"Different": {"should-not-appear"}} + + h.rebuildRequestHeaders(req, otherHeader, "ignored") + + if got := req.Header.Get("Original"); got != "stays" { + t.Errorf("header rebuilt despite no ops; Original = %q, want %q", got, "stays") + } + if got := req.Header.Get("Different"); got != "" { + t.Errorf("reqHeader leaked despite no ops; Different = %q, want empty", got) } } From 1a4b26bd1795c3a8f5aa7542d7da56f48303cfea Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Sat, 13 Jun 2026 22:30:49 +1000 Subject: [PATCH 5/6] chore: explainer comment for normalizeWebsocketHeaders --- modules/caddyhttp/reverseproxy/reverseproxy.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/modules/caddyhttp/reverseproxy/reverseproxy.go b/modules/caddyhttp/reverseproxy/reverseproxy.go index 90eb365bb..0f792ab9e 100644 --- a/modules/caddyhttp/reverseproxy/reverseproxy.go +++ b/modules/caddyhttp/reverseproxy/reverseproxy.go @@ -716,6 +716,8 @@ func (h *Handler) proxyLoopIteration(r *http.Request, origReq *http.Request, w h // iteration of the proxy loop starts from the same base. If neither set of // operations is configured, r.Header is left unchanged. Transport operations // are applied before user operations, so the user's config wins. +// normalizeWebsocketHeaders is a no-op unless Go-canonical WebSocket header +// names are present, so it is safe to call for ordinary non-WebSocket requests. func (h *Handler) rebuildRequestHeaders(r *http.Request, reqHeader http.Header, reqHost string) { var userOps *headers.HeaderOps if h.Headers != nil { From 9987783370e695becf1b29a4167ab8b1c236cd0c Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Tue, 16 Jun 2026 01:10:34 +1000 Subject: [PATCH 6/6] chore: clarify websocket header rebuild normalisation --- modules/caddyhttp/reverseproxy/reverseproxy.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/modules/caddyhttp/reverseproxy/reverseproxy.go b/modules/caddyhttp/reverseproxy/reverseproxy.go index 0f792ab9e..8e84dc2ac 100644 --- a/modules/caddyhttp/reverseproxy/reverseproxy.go +++ b/modules/caddyhttp/reverseproxy/reverseproxy.go @@ -716,8 +716,11 @@ func (h *Handler) proxyLoopIteration(r *http.Request, origReq *http.Request, w h // iteration of the proxy loop starts from the same base. If neither set of // operations is configured, r.Header is left unchanged. Transport operations // are applied before user operations, so the user's config wins. -// normalizeWebsocketHeaders is a no-op unless Go-canonical WebSocket header -// names are present, so it is safe to call for ordinary non-WebSocket requests. +// +// Any configured header operation causes the full header map to be rebuilt. +// That rebuild can Go-canonicalize pre-existing WebSocket headers even when the +// configured operation does not touch them, so restore RFC 6455 casing after +// all operations have run. func (h *Handler) rebuildRequestHeaders(r *http.Request, reqHeader http.Header, reqHost string) { var userOps *headers.HeaderOps if h.Headers != nil {