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