Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS
Mirrored from https://github.com/Slazee/caddy.git
Synced Nov 11, 01:00 UTC
reverseproxy: remove parsed client key during extended CONNECT downgrade (#8297) (#8360)
When downgrading an HTTP/2 or HTTP/3 extended CONNECT WebSocket request to HTTP/1.1, delete any parsed client-supplied Sec-WebSocket-Key header prior to installing Caddy's generated 16-byte random key. This prevents request header operations from canonicalizing and combining both keys into duplicate Sec-WebSocket-Key headers upstream. Signed-off-by: duongynhi000005-oss <duongynhi000005@gmail.com>
duongynhi000005-oss committed
Nov 5, 2026 at 14:50 UTC
dcad5b235919f129bfa81ed47c588fd5f097ee35
2 files changed
+159
modules/caddyhttp/reverseproxy/extended_connect_test.go
+158
@@ -1,6 +1,7 @@
1
package reverseproxy
2
3
import (
4
+ "encoding/base64"
5
"io"
6
"net/http"
7
"net/http/httptest"
@@ -11,6 +12,7 @@ import (
12
"go.uber.org/zap"
13
14
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
15
+ "github.com/caddyserver/caddy/v2/modules/caddyhttp/headers"
16
)
17
18
type extendedConnectCapture struct {
@@ -144,3 +146,159 @@ func TestServeHTTPRewritesExtendedConnectWebsocketRequest(t *testing.T) {
146
})
147
}
148
}
149
+
150
+func TestServeHTTPRewritesExtendedConnectWebsocketSecWebSocketKeyMatrix(t *testing.T) {
151
+ const clientKey = "dGhlIHNhbXBsZSBub25jZQ=="
152
+
153
+ protoCases := []struct {
154
+ protoName string
155
+ protoMajor int
156
+ proto string
157
+ headers map[string]string
158
+ }{
159
+ {
160
+ protoName: "h2",
161
+ protoMajor: 2,
162
+ proto: "HTTP/2.0",
163
+ headers: map[string]string{
164
+ ":protocol": "websocket",
165
+ },
166
+ },
167
+ {
168
+ protoName: "h3",
169
+ protoMajor: 3,
170
+ proto: "websocket",
171
+ headers: map[string]string{},
172
+ },
173
+ }
174
+
175
+ headerOpCases := []struct {
176
+ opName string
177
+ userOps *headers.HeaderOps
178
+ transportOps *headers.HeaderOps
179
+ }{
180
+ {
181
+ opName: "no header ops",
182
+ },
183
+ {
184
+ opName: "user ops",
185
+ userOps: &headers.HeaderOps{
186
+ Set: http.Header{"X-User-Op": []string{"yes"}},
187
+ },
188
+ },
189
+ {
190
+ opName: "transport ops",
191
+ transportOps: &headers.HeaderOps{
192
+ Set: http.Header{"X-Transport-Op": []string{"yes"}},
193
+ },
194
+ },
195
+ {
196
+ opName: "both user and transport ops",
197
+ userOps: &headers.HeaderOps{
198
+ Set: http.Header{"X-User-Op": []string{"yes"}},
199
+ },
200
+ transportOps: &headers.HeaderOps{
201
+ Set: http.Header{"X-Transport-Op": []string{"yes"}},
202
+ },
203
+ },
204
+ }
205
+
206
+ clientKeyCases := []struct {
207
+ clientKeyName string
208
+ sendClientKey bool
209
+ }{
210
+ {
211
+ clientKeyName: "client sent key",
212
+ sendClientKey: true,
213
+ },
214
+ {
215
+ clientKeyName: "client without key",
216
+ sendClientKey: false,
217
+ },
218
+ }
219
+
220
+ for _, pc := range protoCases {
221
+ for _, hoc := range headerOpCases {
222
+ for _, ckc := range clientKeyCases {
223
+ testName := pc.protoName + "/" + hoc.opName + "/" + ckc.clientKeyName
224
+ t.Run(testName, func(t *testing.T) {
225
+ transport := new(extendedConnectCaptureTransport)
226
+ h := &Handler{
227
+ logger: zap.NewNop(),
228
+ Transport: transport,
229
+ Upstreams: UpstreamPool{
230
+ &Upstream{Host: new(Host), Dial: "127.0.0.1:8443"},
231
+ },
232
+ LoadBalancing: &LoadBalancing{
233
+ SelectionPolicy: &RoundRobinSelection{},
234
+ },
235
+ transportHeaderOps: hoc.transportOps,
236
+ }
237
+ if hoc.userOps != nil {
238
+ h.Headers = &headers.Handler{
239
+ Request: hoc.userOps,
240
+ }
241
+ }
242
+
243
+ req := httptest.NewRequest(http.MethodConnect, "http://example.test/upgrade", strings.NewReader("ws-data"))
244
+ req.ProtoMajor = pc.protoMajor
245
+ req.Proto = pc.proto
246
+ for k, v := range pc.headers {
247
+ req.Header.Set(k, v)
248
+ }
249
+ if ckc.sendClientKey {
250
+ req.Header.Set("Sec-WebSocket-Key", clientKey)
251
+ }
252
+ req = prepareTestRequest(req)
253
+
254
+ rr := httptest.NewRecorder()
255
+ err := h.ServeHTTP(rr, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
256
+ return nil
257
+ }))
258
+ if err != nil {
259
+ t.Fatalf("ServeHTTP() error = %v", err)
260
+ }
261
+
262
+ captured := transport.Snapshot()
263
+
264
+ // Collect all Sec-WebSocket-Key values across canonical and non-canonical headers
265
+ var keyValues []string
266
+ for k, v := range captured.headers {
267
+ if strings.EqualFold(k, "Sec-WebSocket-Key") {
268
+ keyValues = append(keyValues, v...)
269
+ }
270
+ }
271
+
272
+ if len(keyValues) != 1 {
273
+ t.Fatalf("expected exactly 1 Sec-WebSocket-Key value, got %d: %v (headers: %#v)",
274
+ len(keyValues), keyValues, captured.headers)
275
+ }
276
+
277
+ upstreamKey := keyValues[0]
278
+ if ckc.sendClientKey && upstreamKey == clientKey {
279
+ t.Fatalf("upstream Sec-WebSocket-Key was not replaced by generated key; still got client key %q", clientKey)
280
+ }
281
+
282
+ decoded, err := base64.StdEncoding.DecodeString(upstreamKey)
283
+ if err != nil {
284
+ t.Fatalf("upstream Sec-WebSocket-Key %q is not valid base64: %v", upstreamKey, err)
285
+ }
286
+ if len(decoded) != 16 {
287
+ t.Fatalf("upstream Sec-WebSocket-Key decoded length = %d, want 16 bytes", len(decoded))
288
+ }
289
+
290
+ if hoc.userOps != nil {
291
+ if got := captured.headers.Get("X-User-Op"); got != "yes" {
292
+ t.Fatalf("X-User-Op header = %q, want 'yes'", got)
293
+ }
294
+ }
295
+ if hoc.transportOps != nil {
296
+ if got := captured.headers.Get("X-Transport-Op"); got != "yes" {
297
+ t.Fatalf("X-Transport-Op header = %q, want 'yes'", got)
298
+ }
299
+ }
300
+ })
301
+ }
302
+ }
303
+ }
304
+}
modules/caddyhttp/reverseproxy/reverseproxy.go
+1
@@ -655,6 +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")
659
clonedReq.Header["Sec-WebSocket-Key"] = []string{base64.StdEncoding.EncodeToString(key)} //nolint:canonicalheader // RFC 6455 casing, see normalizeWebsocketHeaders
660
}
661