From 59e66ff289e2dc8d83dcb73584f371b9737a949c Mon Sep 17 00:00:00 2001 From: Steffen Busch Date: Tue, 19 May 2026 20:58:40 +0200 Subject: [PATCH 1/5] feat: implement Hijack, Flush, and ReadFrom methods for cookieInterceptResponseWriter --- cookiecrypt.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/cookiecrypt.go b/cookiecrypt.go index 3722624..f596c48 100644 --- a/cookiecrypt.go +++ b/cookiecrypt.go @@ -1,11 +1,14 @@ package cookiecrypt import ( + "bufio" "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/base64" "fmt" + "io" + "net" "net/http" "strings" @@ -196,6 +199,28 @@ func (w *cookieInterceptResponseWriter) WriteHeader(statusCode int) { w.ResponseWriter.WriteHeader(statusCode) } +func (w *cookieInterceptResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + hj, ok := w.ResponseWriter.(http.Hijacker) + if !ok { + return nil, nil, http.ErrNotSupported + } + + return hj.Hijack() +} + +func (w *cookieInterceptResponseWriter) Flush() { + if fl, ok := w.ResponseWriter.(http.Flusher); ok { + fl.Flush() + } +} + +func (w *cookieInterceptResponseWriter) ReadFrom(r io.Reader) (int64, error) { + if rf, ok := w.ResponseWriter.(io.ReaderFrom); ok { + return rf.ReadFrom(r) + } + return io.Copy(w, r) +} + func (cc CookieCrypt) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { for _, c := range r.Cookies() { if !strings.HasPrefix(c.Name, cc.Prefix) { @@ -232,4 +257,7 @@ var ( _ caddy.Validator = (*CookieCrypt)(nil) _ caddyhttp.MiddlewareHandler = (*CookieCrypt)(nil) _ caddyfile.Unmarshaler = (*CookieCrypt)(nil) + _ http.Hijacker = (*cookieInterceptResponseWriter)(nil) + _ http.Flusher = (*cookieInterceptResponseWriter)(nil) + _ io.ReaderFrom = (*cookieInterceptResponseWriter)(nil) ) From 4a0aece7cce125d300599b4cdfdc65d0b42b9d5d Mon Sep 17 00:00:00 2001 From: Steffen Busch Date: Wed, 20 May 2026 13:28:21 +0200 Subject: [PATCH 2/5] feat: add shouldBypass function to handle WebSocket and CONNECT requests in cookie processing --- cookiecrypt.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/cookiecrypt.go b/cookiecrypt.go index f596c48..9b0fa15 100644 --- a/cookiecrypt.go +++ b/cookiecrypt.go @@ -221,7 +221,35 @@ func (w *cookieInterceptResponseWriter) ReadFrom(r io.Reader) (int64, error) { return io.Copy(w, r) } +func shouldBypass(r *http.Request) bool { + // Do not intercept or modify cookies for protocol upgrade requests. + // These are typically WebSocket handshakes handled separately. + if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { + return true + } + + // Some WebSocket requests use the Sec-WebSocket-Key header without + // an Upgrade header in certain proxy scenarios. + if r.Header.Get("Sec-WebSocket-Key") != "" { + return true + } + + // CONNECT requests are tunneling protocols and should bypass cookie + // encryption/decryption logic entirely. + if r.Method == http.MethodConnect { + return true + } + + return false +} + func (cc CookieCrypt) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { + // Bypass cookie processing for WebSocket upgrade and CONNECT requests to avoid interference with protocol handshakes. + if shouldBypass(r) { + cc.logger.Info("bypassing cookiecrypt for websocket or CONNECT request") + return next.ServeHTTP(w, r) + } + for _, c := range r.Cookies() { if !strings.HasPrefix(c.Name, cc.Prefix) { continue From 02c94c1a5f27b27acf92ea0b816ca80667afc68d Mon Sep 17 00:00:00 2001 From: Steffen Busch Date: Wed, 20 May 2026 17:09:37 +0200 Subject: [PATCH 3/5] feat: enhance cookieInterceptResponseWriter with logging for Hijack support and remove shouldBypass function --- cookiecrypt.go | 40 +++++++++++++--------------------------- 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/cookiecrypt.go b/cookiecrypt.go index 9b0fa15..f773303 100644 --- a/cookiecrypt.go +++ b/cookiecrypt.go @@ -202,9 +202,13 @@ func (w *cookieInterceptResponseWriter) WriteHeader(statusCode int) { func (w *cookieInterceptResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { hj, ok := w.ResponseWriter.(http.Hijacker) if !ok { + w.logger.Error("underlying ResponseWriter does not support Hijack", + zap.String("underlying_type", fmt.Sprintf("%T", w.ResponseWriter))) return nil, nil, http.ErrNotSupported } + w.logger.Debug("delegating Hijack to underlying ResponseWriter", + zap.String("underlying_type", fmt.Sprintf("%T", w.ResponseWriter))) return hj.Hijack() } @@ -221,34 +225,16 @@ func (w *cookieInterceptResponseWriter) ReadFrom(r io.Reader) (int64, error) { return io.Copy(w, r) } -func shouldBypass(r *http.Request) bool { - // Do not intercept or modify cookies for protocol upgrade requests. - // These are typically WebSocket handshakes handled separately. - if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { - return true - } - - // Some WebSocket requests use the Sec-WebSocket-Key header without - // an Upgrade header in certain proxy scenarios. - if r.Header.Get("Sec-WebSocket-Key") != "" { - return true - } - - // CONNECT requests are tunneling protocols and should bypass cookie - // encryption/decryption logic entirely. - if r.Method == http.MethodConnect { - return true - } - - return false -} - func (cc CookieCrypt) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { - // Bypass cookie processing for WebSocket upgrade and CONNECT requests to avoid interference with protocol handshakes. - if shouldBypass(r) { - cc.logger.Info("bypassing cookiecrypt for websocket or CONNECT request") - return next.ServeHTTP(w, r) - } + _, supportsHijack := w.(http.Hijacker) + _, supportsFlusher := w.(http.Flusher) + _, supportsReaderFrom := w.(io.ReaderFrom) + cc.logger.Debug("cookiecrypt wrapping response writer", + zap.String("response_writer_type", fmt.Sprintf("%T", w)), + zap.Bool("supports_hijack", supportsHijack), + zap.Bool("supports_flusher", supportsFlusher), + zap.Bool("supports_reader_from", supportsReaderFrom), + ) for _, c := range r.Cookies() { if !strings.HasPrefix(c.Name, cc.Prefix) { From e725c68ad7588bb991c9ae96789f423ceb70a6aa Mon Sep 17 00:00:00 2001 From: Steffen Busch Date: Wed, 20 May 2026 17:38:56 +0200 Subject: [PATCH 4/5] feat: enhance Hijack support in cookieInterceptResponseWriter with detailed logging and response writer chain tracking --- cookiecrypt.go | 59 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/cookiecrypt.go b/cookiecrypt.go index f773303..14b0080 100644 --- a/cookiecrypt.go +++ b/cookiecrypt.go @@ -200,16 +200,53 @@ func (w *cookieInterceptResponseWriter) WriteHeader(statusCode int) { } func (w *cookieInterceptResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { - hj, ok := w.ResponseWriter.(http.Hijacker) - if !ok { - w.logger.Error("underlying ResponseWriter does not support Hijack", - zap.String("underlying_type", fmt.Sprintf("%T", w.ResponseWriter))) - return nil, nil, http.ErrNotSupported + return hijackResponseWriter(w.ResponseWriter, w.logger) +} + +func (w *cookieInterceptResponseWriter) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} + +func hijackResponseWriter(rw http.ResponseWriter, logger *zap.Logger) (net.Conn, *bufio.ReadWriter, error) { + chain := []string{} + for { + chain = append(chain, fmt.Sprintf("%T", rw)) + if hj, ok := rw.(http.Hijacker); ok { + logger.Debug("found hijack-capable writer in chain", + zap.String("writer_type", fmt.Sprintf("%T", rw)), + zap.Strings("writer_chain", chain), + ) + return hj.Hijack() + } + uw, ok := rw.(interface{ Unwrap() http.ResponseWriter }) + if !ok { + logger.Error("no hijack support found in response writer chain", + zap.Strings("writer_chain", chain), + ) + return nil, nil, http.ErrNotSupported + } + rw = uw.Unwrap() } +} - w.logger.Debug("delegating Hijack to underlying ResponseWriter", - zap.String("underlying_type", fmt.Sprintf("%T", w.ResponseWriter))) - return hj.Hijack() +func responseWriterChain(rw http.ResponseWriter) []string { + chain := []string{} + seen := map[http.ResponseWriter]struct{}{} + for { + typeName := fmt.Sprintf("%T", rw) + chain = append(chain, typeName) + if _, ok := seen[rw]; ok { + chain = append(chain, "") + return chain + } + seen[rw] = struct{}{} + + uw, ok := rw.(interface{ Unwrap() http.ResponseWriter }) + if !ok { + return chain + } + rw = uw.Unwrap() + } } func (w *cookieInterceptResponseWriter) Flush() { @@ -229,11 +266,17 @@ func (cc CookieCrypt) ServeHTTP(w http.ResponseWriter, r *http.Request, next cad _, supportsHijack := w.(http.Hijacker) _, supportsFlusher := w.(http.Flusher) _, supportsReaderFrom := w.(io.ReaderFrom) + _, supportsUnwrap := w.(interface{ Unwrap() http.ResponseWriter }) cc.logger.Debug("cookiecrypt wrapping response writer", zap.String("response_writer_type", fmt.Sprintf("%T", w)), zap.Bool("supports_hijack", supportsHijack), zap.Bool("supports_flusher", supportsFlusher), zap.Bool("supports_reader_from", supportsReaderFrom), + zap.Bool("supports_unwrap", supportsUnwrap), + ) + + cc.logger.Debug("cookiecrypt response writer chain", + zap.Strings("writer_chain", responseWriterChain(w)), ) for _, c := range r.Cookies() { From 96a0515de07fb203bce95706e8876285062172d5 Mon Sep 17 00:00:00 2001 From: Steffen Busch Date: Wed, 20 May 2026 18:00:07 +0200 Subject: [PATCH 5/5] feat: enhance cookieInterceptResponseWriter with Hijack and Unwrap methods for improved response handling --- cookiecrypt.go | 47 +++++++++++------------------------------------ 1 file changed, 11 insertions(+), 36 deletions(-) diff --git a/cookiecrypt.go b/cookiecrypt.go index 14b0080..0960589 100644 --- a/cookiecrypt.go +++ b/cookiecrypt.go @@ -136,6 +136,10 @@ func (cc *CookieCrypt) decrypt(ciphertext string) (string, error) { return string(plaintext), nil } +// cookieInterceptResponseWriter wraps the downstream ResponseWriter and +// preserves optional interfaces such as Hijacker, Flusher, and ReadFrom. +// The Unwrap/Hijack support is needed so Caddy's ResponseController can +// traverse wrapper layers and still upgrade WebSocket/CONNECT connections. type cookieInterceptResponseWriter struct { http.ResponseWriter logger *zap.Logger @@ -199,14 +203,21 @@ func (w *cookieInterceptResponseWriter) WriteHeader(statusCode int) { w.ResponseWriter.WriteHeader(statusCode) } +// Hijack delegates through wrapper layers to the first real http.Hijacker. +// This is required because Caddy may wrap the original ResponseWriter in +// layers such as headers.responseWriterWrapper and caddyhttp.responseRecorder. func (w *cookieInterceptResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { return hijackResponseWriter(w.ResponseWriter, w.logger) } +// Unwrap exposes the next underlying ResponseWriter so http.NewResponseController +// can traverse the wrapper chain and find optional interfaces like Hijacker. func (w *cookieInterceptResponseWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } +// hijackResponseWriter walks nested Unwrap() wrappers until it finds a Hijacker. +// If none is found, it returns http.ErrNotSupported to preserve standard Go semantics. func hijackResponseWriter(rw http.ResponseWriter, logger *zap.Logger) (net.Conn, *bufio.ReadWriter, error) { chain := []string{} for { @@ -229,26 +240,6 @@ func hijackResponseWriter(rw http.ResponseWriter, logger *zap.Logger) (net.Conn, } } -func responseWriterChain(rw http.ResponseWriter) []string { - chain := []string{} - seen := map[http.ResponseWriter]struct{}{} - for { - typeName := fmt.Sprintf("%T", rw) - chain = append(chain, typeName) - if _, ok := seen[rw]; ok { - chain = append(chain, "") - return chain - } - seen[rw] = struct{}{} - - uw, ok := rw.(interface{ Unwrap() http.ResponseWriter }) - if !ok { - return chain - } - rw = uw.Unwrap() - } -} - func (w *cookieInterceptResponseWriter) Flush() { if fl, ok := w.ResponseWriter.(http.Flusher); ok { fl.Flush() @@ -263,22 +254,6 @@ func (w *cookieInterceptResponseWriter) ReadFrom(r io.Reader) (int64, error) { } func (cc CookieCrypt) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { - _, supportsHijack := w.(http.Hijacker) - _, supportsFlusher := w.(http.Flusher) - _, supportsReaderFrom := w.(io.ReaderFrom) - _, supportsUnwrap := w.(interface{ Unwrap() http.ResponseWriter }) - cc.logger.Debug("cookiecrypt wrapping response writer", - zap.String("response_writer_type", fmt.Sprintf("%T", w)), - zap.Bool("supports_hijack", supportsHijack), - zap.Bool("supports_flusher", supportsFlusher), - zap.Bool("supports_reader_from", supportsReaderFrom), - zap.Bool("supports_unwrap", supportsUnwrap), - ) - - cc.logger.Debug("cookiecrypt response writer chain", - zap.Strings("writer_chain", responseWriterChain(w)), - ) - for _, c := range r.Cookies() { if !strings.HasPrefix(c.Name, cc.Prefix) { continue