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, 03:30 UTC
reverseproxy: strip hop-by-hop headers when copying response headers (#8270)
* reverseproxy: strip hop-by-hop headers when copying response headers copy_response_headers copied the upstream header map before finalizeResponse could strip hop-by-hop fields. Clone and strip first, so include and exclude cannot forward them, and leave the upstream response unchanged for later handlers in the route. Co-authored-by: Grok 4.7 <noreply@x.ai>
tomholford committed
Oct 26, 2026 at 18:21 UTC
258863f6f6485863f7a12bc9fa02a29472b29ed5
2 files changed
+191
-1
modules/caddyhttp/reverseproxy/copyresponse.go
+8
-1
@@ -151,7 +151,14 @@ func (h CopyResponseHeadersHandler) ServeHTTP(rw http.ResponseWriter, req *http.
151
fmt.Errorf("cannot use 'copy_response_headers' outside of reverse_proxy's handle_response routes"))
152
}
153
154
- for field, values := range hrc.response.Header {
154
+ // Hop-by-hop fields belong to the upstream connection. Strip them
155
+ // before include and exclude, so naming one in include does not
156
+ // copy it. Clone first: later handlers in this handle_response
157
+ // route still need the original upstream headers.
158
+ src := hrc.response.Header.Clone()
159
+ stripHopByHopHeaders(src)
160
+
161
+ for field, values := range src {
162
// Check the include list first, skip
163
// the header if it's _not_ in this list.
164
if len(h.includeMap) > 0 {
modules/caddyhttp/reverseproxy/copyresponse_test.go
new
+183
@@ -0,0 +1,183 @@
1
+// Copyright 2015 Matthew Holt and The Caddy Authors
2
+//
3
+// Licensed under the Apache License, Version 2.0 (the "License");
4
+// you may not use this file except in compliance with the License.
5
+// You may obtain a copy of the License at
6
+//
7
+// http://www.apache.org/licenses/LICENSE-2.0
8
+//
9
+// Unless required by applicable law or agreed to in writing, software
10
+// distributed under the License is distributed on an "AS IS" BASIS,
11
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+// See the License for the specific language governing permissions and
13
+// limitations under the License.
14
+
15
+package reverseproxy
16
+
17
+import (
18
+ "context"
19
+ "encoding/json"
20
+ "io"
21
+ "net/http"
22
+ "net/http/httptest"
23
+ "testing"
24
+
25
+ "github.com/caddyserver/caddy/v2"
26
+ "github.com/caddyserver/caddy/v2/modules/caddyhttp"
27
+)
28
+
29
+func TestCopyResponseHeadersStripsHopByHop(t *testing.T) {
30
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
31
+ w.Header().Set("Alt-Svc", `h2="evil.example:443"; ma=86400`)
32
+ w.Header().Set("Keep-Alive", "timeout=999")
33
+ w.Header().Set("Proxy-Authenticate", `Basic realm="phish"`)
34
+ w.Header().Set("X-Upstream", "kept")
35
+ w.WriteHeader(http.StatusOK)
36
+ _, _ = io.WriteString(w, "from-upstream")
37
+ }))
38
+ t.Cleanup(backend.Close)
39
+
40
+ ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
41
+ t.Cleanup(cancel)
42
+
43
+ newHandler := func(t *testing.T, handles ...json.RawMessage) *Handler {
44
+ t.Helper()
45
+ h := minimalHandler(0, &Upstream{Host: new(Host), Dial: backend.Listener.Addr().String()})
46
+ if len(handles) == 0 {
47
+ return h
48
+ }
49
+ route := caddyhttp.Route{HandlersRaw: handles}
50
+ if err := route.ProvisionHandlers(ctx, nil); err != nil {
51
+ t.Fatalf("provision handle_response: %v", err)
52
+ }
53
+ h.HandleResponse = []caddyhttp.ResponseHandler{{
54
+ Routes: caddyhttp.RouteList{route},
55
+ }}
56
+ return h
57
+ }
58
+
59
+ // The replacement body is the same length as the upstream body.
60
+ // copy_response_headers also copies Content-Length, and a shorter
61
+ // body makes the client report unexpected EOF. X-Replaced shows
62
+ // that static_response ran.
63
+ const replaced = `{"handler":"static_response","body":"from-upstream","headers":{"X-Replaced":["yes"]}}`
64
+ cases := []struct {
65
+ name string
66
+ handles []json.RawMessage
67
+ wantReplace bool
68
+ }{
69
+ {
70
+ name: "default finalize",
71
+ },
72
+ {
73
+ name: "copy_response",
74
+ handles: []json.RawMessage{
75
+ json.RawMessage(`{"handler":"copy_response"}`),
76
+ },
77
+ },
78
+ {
79
+ name: "copy_response_headers",
80
+ handles: []json.RawMessage{
81
+ json.RawMessage(`{"handler":"copy_response_headers"}`),
82
+ json.RawMessage(replaced),
83
+ },
84
+ wantReplace: true,
85
+ },
86
+ {
87
+ name: "include cannot copy hop-by-hop fields",
88
+ handles: []json.RawMessage{
89
+ json.RawMessage(`{"handler":"copy_response_headers","include":["Alt-Svc","X-Upstream","Keep-Alive"]}`),
90
+ json.RawMessage(replaced),
91
+ },
92
+ wantReplace: true,
93
+ },
94
+ }
95
+
96
+ for _, tc := range cases {
97
+ t.Run(tc.name, func(t *testing.T) {
98
+ h := newHandler(t, tc.handles...)
99
+ front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
100
+ err := h.ServeHTTP(w, prepareTestRequest(r), caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
101
+ return nil
102
+ }))
103
+ if err != nil {
104
+ http.Error(w, err.Error(), http.StatusBadGateway)
105
+ }
106
+ }))
107
+ t.Cleanup(front.Close)
108
+
109
+ resp, err := front.Client().Get(front.URL)
110
+ if err != nil {
111
+ t.Fatalf("GET: %v", err)
112
+ }
113
+ defer resp.Body.Close()
114
+ body, err := io.ReadAll(resp.Body)
115
+ if err != nil {
116
+ t.Fatalf("read body: %v", err)
117
+ }
118
+ if resp.StatusCode != http.StatusOK {
119
+ t.Fatalf("status = %d, body = %q", resp.StatusCode, body)
120
+ }
121
+ if string(body) != "from-upstream" {
122
+ t.Fatalf("body = %q", body)
123
+ }
124
+ if got := resp.Header.Get("X-Upstream"); got != "kept" {
125
+ t.Errorf("X-Upstream = %q, want kept", got)
126
+ }
127
+ if got, want := resp.Header.Get("X-Replaced") == "yes", tc.wantReplace; got != want {
128
+ t.Errorf("X-Replaced present = %v, want %v", got, want)
129
+ }
130
+ for _, field := range []string{"Alt-Svc", "Keep-Alive", "Proxy-Authenticate"} {
131
+ if got := resp.Header.Get(field); got != "" {
132
+ t.Errorf("%s = %q, want absent", field, got)
133
+ }
134
+ }
135
+ })
136
+ }
137
+}
138
+
139
+func TestCopyResponseHeadersStripLeavesUpstreamHeader(t *testing.T) {
140
+ ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
141
+ t.Cleanup(cancel)
142
+
143
+ h := &CopyResponseHeadersHandler{Include: []string{"Alt-Svc", "X-Upstream", "X-Hop"}}
144
+ if err := h.Provision(ctx); err != nil {
145
+ t.Fatal(err)
146
+ }
147
+
148
+ res := &http.Response{Header: make(http.Header), StatusCode: http.StatusOK}
149
+ res.Header.Set("Alt-Svc", `h2="evil.example:443"`)
150
+ res.Header.Set("X-Upstream", "kept")
151
+ res.Header.Set("X-Hop", "secret")
152
+ res.Header.Set("Connection", "close, X-Hop")
153
+
154
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
155
+ req = req.WithContext(context.WithValue(req.Context(), proxyHandleResponseContextCtxKey, &handleResponseContext{
156
+ response: res,
157
+ }))
158
+ rec := httptest.NewRecorder()
159
+ err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
160
+ return nil
161
+ }))
162
+ if err != nil {
163
+ t.Fatal(err)
164
+ }
165
+
166
+ if got := rec.Header().Get("X-Upstream"); got != "kept" {
167
+ t.Errorf("X-Upstream = %q, want kept", got)
168
+ }
169
+ for _, field := range []string{"Alt-Svc", "X-Hop", "Connection"} {
170
+ if got := rec.Header().Get(field); got != "" {
171
+ t.Errorf("copied %s = %q, want absent", field, got)
172
+ }
173
+ }
174
+ if got := res.Header.Get("Alt-Svc"); got != `h2="evil.example:443"` {
175
+ t.Errorf("upstream Alt-Svc = %q, want it unchanged", got)
176
+ }
177
+ if got := res.Header.Get("X-Hop"); got != "secret" {
178
+ t.Errorf("upstream X-Hop = %q, want it unchanged", got)
179
+ }
180
+ if got := res.Header.Get("Connection"); got != "close, X-Hop" {
181
+ t.Errorf("upstream Connection = %q, want it unchanged", got)
182
+ }
183
+}