@marcel91200 / caddy Mirror · read-only / commits / fbfc061e
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

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 }