@marcel91200 / caddy Mirror · read-only / commits / 25b3eab6
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, 09:45 UTC

Merge commit from fork

* fix(reverseproxy): hop-by-hop header fix * test(headers): Added unit tests for hop-by-hope header behavior

Dionis Ramadani committed Jun 12, 2026 at 20:39 UTC 25b3eab6dbd8d2d2670711f26b774e0fd43e1179
2 files changed +207 -6
modules/caddyhttp/reverseproxy/hopheaders_test.go new
+191
@@ -0,0 +1,191 @@
1 +package reverseproxy
2 +
3 +import (
4 + "context"
5 + "io"
6 + "net/http"
7 + "net/http/httptest"
8 + "strings"
9 + "testing"
10 + "time"
11 +
12 + "github.com/caddyserver/caddy/v2"
13 + "github.com/caddyserver/caddy/v2/modules/caddyhttp"
14 +)
15 +
16 +// 101 responses strip hop-by-hop headers (Alt-Svc, Keep-Alive, etc.) but preserve Upgrade and Connection
17 +func TestFinalizeResponse_101_StripsHopByHopHeaders(t *testing.T) {
18 + h := &Handler{logger: caddy.Log()}
19 +
20 + req := httptest.NewRequest(http.MethodGet, "/ws", nil)
21 + req.Header.Set("Upgrade", "websocket")
22 + req.Header.Set("Connection", "Upgrade")
23 + req.Header.Set("Sec-WebSocket-Version", "13")
24 + req.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
25 +
26 + vars := map[string]any{}
27 + ctx := context.WithValue(req.Context(), caddyhttp.VarsCtxKey, vars)
28 + req = req.WithContext(ctx)
29 +
30 + res := &http.Response{
31 + StatusCode: http.StatusSwitchingProtocols,
32 + ProtoMajor: 1,
33 + ProtoMinor: 1,
34 + Header: http.Header{
35 + "Upgrade": {"websocket"},
36 + "Connection": {"Upgrade"},
37 + "Sec-Websocket-Accept": {"s3pPLMBiTxaQ9kYGzzhZRbK+xOo="},
38 + "Alt-Svc": {"h2=\"evil.com:443\"; ma=86400"},
39 + "Keep-Alive": {"timeout=999"},
40 + "Proxy-Authenticate": {"Basic realm=\"phish\""},
41 + },
42 + Body: fakeRWC{strings.NewReader("")},
43 + }
44 +
45 + repl := caddy.NewReplacer()
46 + rw := httptest.NewRecorder()
47 +
48 + err := h.finalizeResponse(rw, req, res, repl, fakeStart, caddy.Log())
49 + if err != nil {
50 + t.Logf("finalizeResponse returned error (expected, no real conn): %v", err)
51 + }
52 +
53 + if got := rw.Header().Get("Upgrade"); got != "websocket" {
54 + t.Errorf("Upgrade = %q, want %q", got, "websocket")
55 + }
56 + if got := rw.Header().Get("Connection"); got != "Upgrade" {
57 + t.Errorf("Connection = %q, want %q", got, "Upgrade")
58 + }
59 + for _, hdr := range []string{"Alt-Svc", "Keep-Alive", "Proxy-Authenticate"} {
60 + if got := rw.Header().Get(hdr); got != "" {
61 + t.Errorf("%s = %q, want empty (should be stripped)", hdr, got)
62 + }
63 + }
64 +}
65 +
66 +// Headers named in the upstream Connection value are stripped
67 +func TestFinalizeResponse_101_StripsConnectionNamedHeaders(t *testing.T) {
68 + h := &Handler{logger: caddy.Log()}
69 +
70 + req := httptest.NewRequest(http.MethodGet, "/ws", nil)
71 + req.Header.Set("Upgrade", "websocket")
72 + req.Header.Set("Connection", "Upgrade")
73 + req.Header.Set("Sec-WebSocket-Version", "13")
74 + req.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
75 +
76 + vars := map[string]any{}
77 + ctx := context.WithValue(req.Context(), caddyhttp.VarsCtxKey, vars)
78 + req = req.WithContext(ctx)
79 +
80 + res := &http.Response{
81 + StatusCode: http.StatusSwitchingProtocols,
82 + ProtoMajor: 1,
83 + ProtoMinor: 1,
84 + Header: http.Header{
85 + "Upgrade": {"websocket"},
86 + "Connection": {"Upgrade, X-Custom-ID"},
87 + "Sec-Websocket-Accept": {"s3pPLMBiTxaQ9kYGzzhZRbK+xOo="},
88 + "X-Custom-Id": {"should-be-stripped"},
89 + },
90 + Body: fakeRWC{strings.NewReader("")},
91 + }
92 +
93 + repl := caddy.NewReplacer()
94 + rw := httptest.NewRecorder()
95 +
96 + err := h.finalizeResponse(rw, req, res, repl, fakeStart, caddy.Log())
97 + if err != nil {
98 + t.Logf("finalizeResponse returned error (no real connection): %v", err)
99 + }
100 +
101 + if got := rw.Header().Get("Upgrade"); got != "websocket" {
102 + t.Errorf("Upgrade = %q, want %q", got, "websocket")
103 + }
104 + if got := rw.Header().Get("X-Custom-Id"); got != "" {
105 + t.Errorf("X-Custom-Id = %q, want empty (named in Connection, should be stripped)", got)
106 + }
107 + if got := rw.Header().Get("Connection"); got != "Upgrade" {
108 + t.Errorf("Connection = %q, want %q", got, "Upgrade")
109 + }
110 +}
111 +
112 +// Normal 200 responses strip hop-by-hop headers
113 +func TestFinalizeResponse_200_StillStripsHopByHop(t *testing.T) {
114 + h := &Handler{logger: caddy.Log()}
115 +
116 + req := httptest.NewRequest(http.MethodGet, "/", nil)
117 + vars := map[string]any{}
118 + ctx := context.WithValue(req.Context(), caddyhttp.VarsCtxKey, vars)
119 + req = req.WithContext(ctx)
120 +
121 + res := &http.Response{
122 + StatusCode: http.StatusOK,
123 + ProtoMajor: 1,
124 + ProtoMinor: 1,
125 + Header: http.Header{
126 + "Content-Type": {"text/plain"},
127 + "Alt-Svc": {"h2=\"evil.com:443\""},
128 + "Keep-Alive": {"timeout=999"},
129 + },
130 + Body: io.NopCloser(strings.NewReader("ok")),
131 + }
132 +
133 + repl := caddy.NewReplacer()
134 + rw := httptest.NewRecorder()
135 +
136 + err := h.finalizeResponse(rw, req, res, repl, fakeStart, caddy.Log())
137 + if err != nil {
138 + t.Fatalf("finalizeResponse returned error: %v", err)
139 + }
140 +
141 + for _, hdr := range []string{"Alt-Svc", "Keep-Alive"} {
142 + if got := rw.Header().Get(hdr); got != "" {
143 + t.Errorf("%s = %q on 200 response, want empty (stripped)", hdr, got)
144 + }
145 + }
146 +}
147 +
148 +// Stripping still runs when the client didn't request an upgrade
149 +func TestFinalizeResponse_101_NoUpgradeRequest(t *testing.T) {
150 + h := &Handler{logger: caddy.Log()}
151 +
152 + req := httptest.NewRequest(http.MethodGet, "/ws", nil)
153 +
154 + vars := map[string]any{}
155 + ctx := context.WithValue(req.Context(), caddyhttp.VarsCtxKey, vars)
156 + req = req.WithContext(ctx)
157 +
158 + res := &http.Response{
159 + StatusCode: http.StatusSwitchingProtocols,
160 + ProtoMajor: 1,
161 + ProtoMinor: 1,
162 + Header: http.Header{
163 + "Upgrade": {"websocket"},
164 + "Connection": {"Upgrade"},
165 + "Alt-Svc": {"h2=\"evil.com:443\""},
166 + },
167 + Body: io.NopCloser(strings.NewReader("")),
168 + }
169 +
170 + repl := caddy.NewReplacer()
171 + rw := httptest.NewRecorder()
172 +
173 + err := h.finalizeResponse(rw, req, res, repl, fakeStart, caddy.Log())
174 + if err != nil {
175 + t.Fatalf("finalizeResponse returned error: %v", err)
176 + }
177 +
178 + if got := rw.Header().Get("Alt-Svc"); got != "" {
179 + t.Errorf("Alt-Svc = %q, want empty (stripped)", got)
180 + }
181 +}
182 +
183 +// fakeRWC lets handleUpgradeResponse proceed past the body type assertion in tests
184 +type fakeRWC struct {
185 + io.Reader
186 +}
187 +
188 +func (f fakeRWC) Write(p []byte) (n int, err error) { return len(p), nil }
189 +func (f fakeRWC) Close() error { return nil }
190 +
191 +var fakeStart = time.Time{}
modules/caddyhttp/reverseproxy/reverseproxy.go
+16 -6
@@ -1215,6 +1215,22 @@ func (h *Handler) finalizeResponse(
1215 start time.Time,
1216 logger *zap.Logger,
1217 ) error {
1218 + // Strip hop-by-hop headers from the upstream response.
1219 + // For 101 Switching Protocols, save the Upgrade value
1220 + // first (handleUpgradeResponse needs it for validation)
1221 + // and restore it with a canonical Connection header.
1222 + upgradeVal := res.Header.Get("Upgrade")
1223 +
1224 + removeConnectionHeaders(res.Header)
1225 + for _, h := range hopHeaders {
1226 + res.Header.Del(h)
1227 + }
1228 +
1229 + if res.StatusCode == http.StatusSwitchingProtocols && upgradeVal != "" {
1230 + res.Header.Set("Upgrade", upgradeVal)
1231 + res.Header.Set("Connection", "Upgrade")
1232 + }
1233 +
1234 // deal with 101 Switching Protocols responses: (WebSocket, h2c, etc)
1235 if res.StatusCode == http.StatusSwitchingProtocols {
1236 var wg sync.WaitGroup
@@ -1223,12 +1239,6 @@ func (h *Handler) finalizeResponse(
1239 return nil
1240 }
1241
1226 - removeConnectionHeaders(res.Header)
1227 -
1228 - for _, h := range hopHeaders {
1229 - res.Header.Del(h)
1230 - }
1231 -
1242 // delete our Server header and use Via instead (see #6275)
1243 rw.Header().Del("Server")
1244 var protoPrefix string