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
caddyhttp: detach streams at the server writer boundary (#8346)
Zen Dodd committed
Nov 4, 2026 at 17:36 UTC
fbfc061e70d51f17b8004a71aed88dbd2a41f70e
5 files changed
+96
-21
modules/caddyhttp/responsewriter.go
+36
-15
@@ -410,32 +410,53 @@ func (hc *hijackedConn) ReadFrom(r io.Reader) (int64, error) {
410
}
411
412
// DetachResponseWriterAfterHijack configures w and its wrapped response
413
-// writers to detach when hijacked. It returns true if at least one writer
414
-// supports detachment and all such writers accept it. When detached,
415
-// bytes read or written stats will not be recorded for the hijacked
416
-// connection, and it's safe to use the connection after HTTP middleware returns.
417
-func DetachResponseWriterAfterHijack(w http.ResponseWriter, detached bool) bool {
418
- found := false
419
- for w != nil {
413
+// writers to detach when hijacked. It succeeds only if every detaching writer
414
+// accepts and the unwrap chain reaches the server-owned writer recorded on r.
415
+// When detached, bytes read or written stats will not be recorded for the
416
+// hijacked connection, and it's safe to use the connection after HTTP
417
+// middleware returns.
418
+func DetachResponseWriterAfterHijack(w http.ResponseWriter, r *http.Request, detached bool) bool {
419
+ if r == nil {
420
+ return false
421
+ }
422
+ boundaryWriter, _ := r.Context().Value(responseWriterBoundaryCtxKey).(http.ResponseWriter)
423
+ boundary := responseWriterPointer(boundaryWriter)
424
+ if boundary == 0 {
425
+ return false
426
+ }
427
+ seen := make(map[uintptr]struct{})
428
+ for range 64 {
429
+ if w == nil {
430
+ return false
431
+ }
432
+ pointer := responseWriterPointer(w)
433
+ if pointer != 0 {
434
+ if _, duplicate := seen[pointer]; duplicate {
435
+ return false
436
+ }
437
+ seen[pointer] = struct{}{}
438
+ }
439
if detacher, ok := w.(interface{ DetachAfterHijack(bool) bool }); ok {
440
if !detacher.DetachAfterHijack(detached) {
441
return false
442
}
424
- found = true
443
+ }
444
+ if pointer == boundary {
445
+ return true
446
}
447
unwrapper, ok := w.(interface{ Unwrap() http.ResponseWriter })
448
if !ok {
428
- return found
449
+ return false
450
}
430
- next := unwrapper.Unwrap()
431
- if next == w {
432
- return found
433
- }
434
- w = next
451
+ w = unwrapper.Unwrap()
452
}
436
- return found
453
+ return false
454
}
455
456
+type responseWriterBoundaryCtxKeyType struct{}
457
+
458
+var responseWriterBoundaryCtxKey = responseWriterBoundaryCtxKeyType{}
459
+
460
// ResponseRecorder is a http.ResponseWriter that records
461
// responses instead of writing them to the client. See
462
// docs for NewResponseRecorder for proper usage.
modules/caddyhttp/responsewriter_test.go
+54
-3
@@ -3,9 +3,11 @@ package caddyhttp
3
import (
4
"bufio"
5
"bytes"
6
+ "context"
7
"io"
8
"net"
9
"net/http"
10
+ "net/http/httptest"
11
"strings"
12
"testing"
13
"time"
@@ -249,7 +251,8 @@ func TestResponseRecorderSwitchingProtocolsIsHijackAware(t *testing.T) {
251
if rr.DetachAfterHijack(true) {
252
t.Fatal("response recorder should report hijacked state by returning false")
253
}
252
- if DetachResponseWriterAfterHijack(rr, true) {
254
+ req := requestWithResponseWriterBoundary(w)
255
+ if DetachResponseWriterAfterHijack(rr, req, true) {
256
t.Fatal("DetachResponseWriterAfterHijack() should report false after hijack")
257
}
258
if err := rr.WriteResponse(); err != nil {
@@ -274,7 +277,8 @@ func TestResponseRecorderNestedDetach(t *testing.T) {
277
// Include an intervening middleware wrapper in the recorder chain.
278
outer := NewResponseRecorder(&ResponseWriterWrapper{ResponseWriter: inner}, nil, nil)
279
outer.WriteHeader(http.StatusSwitchingProtocols)
277
- if !DetachResponseWriterAfterHijack(outer, detached) {
280
+ req := requestWithResponseWriterBoundary(inner)
281
+ if !DetachResponseWriterAfterHijack(outer, req, detached) {
282
t.Fatal("detach configuration failed")
283
}
284
conn, brw, err := http.NewResponseController(outer).Hijack()
@@ -301,13 +305,60 @@ func TestResponseRecorderNestedDetach(t *testing.T) {
305
t.Errorf("%s recorder size = %d, want %d", name, rr.Size(), want)
306
}
307
}
304
- if DetachResponseWriterAfterHijack(outer, true) {
308
+ if DetachResponseWriterAfterHijack(outer, req, true) {
309
t.Error("detach should fail after hijack")
310
}
311
})
312
}
313
}
314
315
+func TestDetachResponseWriterAfterHijackRejectsOpaqueInnerWriter(t *testing.T) {
316
+ raw := newHijackRespWriter()
317
+ opaque := struct{ http.ResponseWriter }{raw}
318
+ recorder := NewResponseRecorder(opaque, nil, nil)
319
+ req := requestWithResponseWriterBoundary(raw)
320
+
321
+ if DetachResponseWriterAfterHijack(recorder, req, true) {
322
+ t.Fatal("detach succeeded through a writer without Unwrap")
323
+ }
324
+}
325
+
326
+func TestDetachResponseWriterAfterHijackAtBoundary(t *testing.T) {
327
+ w := newHijackRespWriter()
328
+ req := requestWithResponseWriterBoundary(w)
329
+ if !DetachResponseWriterAfterHijack(w, req, true) {
330
+ t.Fatal("detach failed at the server-owned writer")
331
+ }
332
+ if DetachResponseWriterAfterHijack(w, nil, true) {
333
+ t.Fatal("detach succeeded without a request")
334
+ }
335
+}
336
+
337
+func TestDetachResponseWriterAfterHijackRejectsCycles(t *testing.T) {
338
+ raw := newHijackRespWriter()
339
+ req := requestWithResponseWriterBoundary(raw)
340
+ a := &cycleWriter{}
341
+ b := &cycleWriter{next: a}
342
+ a.next = b
343
+
344
+ for name, w := range map[string]http.ResponseWriter{
345
+ "uncomparable self-reference": uncomparableWriter{},
346
+ "indirect cycle": a,
347
+ } {
348
+ t.Run(name, func(t *testing.T) {
349
+ if DetachResponseWriterAfterHijack(w, req, true) {
350
+ t.Fatal("detach succeeded without reaching the server-owned writer")
351
+ }
352
+ })
353
+ }
354
+}
355
+
356
+func requestWithResponseWriterBoundary(w http.ResponseWriter) *http.Request {
357
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
358
+ ctx := context.WithValue(req.Context(), responseWriterBoundaryCtxKey, w)
359
+ return req.WithContext(ctx)
360
+}
361
+
362
// targetIface is an interface that only the innermost writer in the tests
363
// below implements; it's used to assert UnwrapResponseWriterAs walks past
364
// outer wrappers to find it.
modules/caddyhttp/reverseproxy/streaming.go
+2
-2
@@ -147,12 +147,12 @@ func (h *Handler) handleUpgradeResponse(logger *zap.Logger, rw http.ResponseWrit
147
brw = bufio.NewReadWriter(bufio.NewReaderSize(conn, 1), bufio.NewWriterSize(conn, 1))
148
} else {
149
if h.StreamDetached {
150
- detached = caddyhttp.DetachResponseWriterAfterHijack(rw, true)
150
+ detached = caddyhttp.DetachResponseWriterAfterHijack(rw, req, true)
151
if !detached {
152
// Some outer writers may have accepted detachment before an
153
// inner writer refused it. Restore attached byte accounting
154
// and keep the handler running for the lifetime of the tunnel.
155
- caddyhttp.DetachResponseWriterAfterHijack(rw, false)
155
+ caddyhttp.DetachResponseWriterAfterHijack(rw, req, false)
156
if c := logger.Check(zap.DebugLevel, "detaching connection failed; keeping stream attached"); c != nil {
157
c.Write(zap.String("tip", "check if your response writers have an Unwrap method or if already hijacked"))
158
}
modules/caddyhttp/reverseproxy/streaming_test.go
+1
-1
@@ -703,7 +703,7 @@ func TestUpgradeDetachmentRequiresWriterSupport(t *testing.T) {
703
initReverseProxyMetrics(h, prometheus.NewRegistry())
704
recorder := caddyhttp.NewResponseRecorder(pipeUpgradeWriter{httptest.NewRecorder(), front}, nil, nil)
705
writer := tc.wrap(recorder)
706
- req := prepareTestRequest(httptest.NewRequest(http.MethodGet, "/upgrade", nil))
706
+ req := caddyhttp.PrepareRequest(httptest.NewRequest(http.MethodGet, "/upgrade", nil), caddy.NewReplacer(), recorder, &caddyhttp.Server{})
707
req.Header.Set("Connection", "Upgrade")
708
req.Header.Set("Upgrade", "test-stream")
709
res := &http.Response{StatusCode: http.StatusSwitchingProtocols, Header: req.Header.Clone(), Body: back}
modules/caddyhttp/server.go
+3
@@ -1806,6 +1806,9 @@ func PrepareRequest(r *http.Request, repl *caddy.Replacer, w http.ResponseWriter
1806
func prepareRequest(r *http.Request, repl *caddy.Replacer, w http.ResponseWriter, s *Server, idle idleTimeouts) *http.Request {
1807
// set up the context for the request
1808
ctx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, repl)
1809
+ if w != nil {
1810
+ ctx = context.WithValue(ctx, responseWriterBoundaryCtxKey, w)
1811
+ }
1812
if idle.reader != nil || idle.writer != nil {
1813
ctx = context.WithValue(ctx, idleTimeoutsCtxKey, idle)
1814
}