@marcel91200 / caddy Mirror · read-only / commits / dcad5b23
Fork 0

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