@marcel91200 / caddy Mirror · read-only / commits / 258863f6
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, 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 +}