Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions modules/caddyhttp/reverseproxy/reverseproxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment about why this is needed should be added. Otherwise it's not intuitive why websocket headers need to be normalized for non websockets requests.

}

// proxy the request to that upstream
Expand Down
109 changes: 109 additions & 0 deletions modules/caddyhttp/reverseproxy/websocket_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
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 (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")
}
}
Loading