@marcel91200 / caddy Mirror · read-only / commits / e91867d3
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, 02:15 UTC

reverseproxy: preserve request trailer values (#8246)

Krish Singh Hura committed Oct 19, 2026 at 14:46 UTC e91867d35c672f9d21d1541589e8c0dd30effd34
3 files changed +747
modules/caddyhttp/reverseproxy/fastcgi/request_trailers_test.go new
+89
@@ -0,0 +1,89 @@
1 +package fastcgi
2 +
3 +import (
4 + "encoding/json"
5 + "fmt"
6 + "io"
7 + "net"
8 + "net/http"
9 + "net/http/fcgi"
10 + "strings"
11 + "testing"
12 + "time"
13 +
14 + "github.com/caddyserver/caddy/v2"
15 +)
16 +
17 +func TestRequestTrailersFastCGI(t *testing.T) {
18 + backend, err := net.Listen("tcp", "127.0.0.1:0")
19 + if err != nil {
20 + t.Fatal(err)
21 + }
22 + done := make(chan error, 1)
23 + go func() {
24 + done <- fcgi.Serve(backend, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25 + body, err := io.ReadAll(r.Body)
26 + if err != nil {
27 + t.Error(err)
28 + http.Error(w, err.Error(), 500)
29 + return
30 + }
31 + // FastCGI carries a length and stdin bytes, not HTTP trailers.
32 + if len(r.Trailer) != 0 || len(r.TransferEncoding) != 0 {
33 + t.Errorf("unexpected HTTP framing: %v %v", r.Trailer, r.TransferEncoding)
34 + }
35 + w.Header().Set("X-Body-Length", fmt.Sprint(r.ContentLength))
36 + _, _ = w.Write(body)
37 + }))
38 + }()
39 + defer func() { backend.Close(); <-done }()
40 + reserve, err := net.Listen("tcp", "127.0.0.1:0")
41 + if err != nil {
42 + t.Fatal(err)
43 + }
44 + addr := reserve.Addr().String()
45 + reserve.Close()
46 + config, err := json.Marshal(map[string]any{
47 + "admin": map[string]any{"disabled": true},
48 + "apps": map[string]any{"http": map[string]any{"servers": map[string]any{"test": map[string]any{
49 + "listen": []string{addr}, "automatic_https": map[string]any{"disable": true},
50 + "routes": []any{map[string]any{"handle": []any{map[string]any{
51 + "handler": "reverse_proxy", "upstreams": []any{map[string]any{"dial": backend.Addr().String()}},
52 + "transport": map[string]any{"protocol": "fastcgi", "root": "/", "split_path": []string{".php"}},
53 + }}}},
54 + }}}},
55 + })
56 + if err != nil {
57 + t.Fatal(err)
58 + }
59 + // Omit request_buffers so provisioning applies FastCGI's default 4096-byte buffer.
60 + if err := caddy.Load(config, true); err != nil {
61 + t.Fatal(err)
62 + }
63 + defer caddy.Stop()
64 + client := &http.Client{Transport: &http.Transport{}, Timeout: 5 * time.Second}
65 + defer client.CloseIdleConnections()
66 + for _, body := range []string{"", "hello", strings.Repeat("x", 4095)} {
67 + for _, trailers := range []bool{false, true} {
68 + t.Run(fmt.Sprintf("length=%d/trailers=%t", len(body), trailers), func(t *testing.T) {
69 + req, err := http.NewRequest(http.MethodPost, "http://"+addr+"/index.php", io.NopCloser(strings.NewReader(body)))
70 + if err != nil {
71 + t.Fatal(err)
72 + }
73 + req.ContentLength = -1
74 + if trailers {
75 + req.Trailer = http.Header{"Content-Digest": {"digest"}}
76 + }
77 + res, err := client.Do(req)
78 + if err != nil {
79 + t.Fatal(err)
80 + }
81 + got, err := io.ReadAll(res.Body)
82 + res.Body.Close()
83 + if err != nil || res.StatusCode != 200 || string(got) != body || res.Header.Get("X-Body-Length") != fmt.Sprint(len(body)) {
84 + t.Fatalf("status=%d length=%q body bytes=%d (want %d), error=%v", res.StatusCode, res.Header.Get("X-Body-Length"), len(got), len(body), err)
85 + }
86 + })
87 + }
88 + }
89 +}
modules/caddyhttp/reverseproxy/request_trailers_test.go new
+617
@@ -0,0 +1,617 @@
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 + "errors"
21 + "fmt"
22 + "io"
23 + "net"
24 + "net/http"
25 + "net/http/httptest"
26 + "os"
27 + "path/filepath"
28 + "reflect"
29 + "runtime"
30 + "strings"
31 + "sync"
32 + "testing"
33 + "time"
34 +
35 + "github.com/caddyserver/caddy/v2"
36 +)
37 +
38 +// trailerTestBody models net/http populating the original request's trailers
39 +// during Read, including readers which return final bytes together with EOF.
40 +type trailerTestBody struct {
41 + io.Reader
42 + finish func()
43 + err error
44 + closed bool
45 +}
46 +
47 +func (b *trailerTestBody) Read(p []byte) (int, error) {
48 + if b.closed {
49 + return 0, errors.New("body closed")
50 + }
51 + n, err := b.Reader.Read(p)
52 + if err == io.EOF {
53 + if b.finish != nil {
54 + b.finish()
55 + b.finish = nil
56 + }
57 + if b.err != nil {
58 + err = b.err
59 + }
60 + }
61 + return n, err
62 +}
63 +
64 +func (b *trailerTestBody) Close() error { b.closed = true; return nil }
65 +
66 +type finalBytesReader struct{ *strings.Reader }
67 +
68 +func (r finalBytesReader) Read(p []byte) (int, error) {
69 + n, err := r.Reader.Read(p)
70 + if r.Len() == 0 {
71 + err = io.EOF
72 + }
73 + return n, err
74 +}
75 +
76 +func TestRequestTrailersEOF(t *testing.T) {
77 + for _, limit := range []int64{0, 2, 32, -1} {
78 + for _, declared := range []bool{false, true} {
79 + for _, finalBytes := range []bool{false, true} {
80 + t.Run(fmt.Sprintf("buffer=%d/declared=%t/final-bytes=%t", limit, declared, finalBytes), func(t *testing.T) {
81 + req := prepareTestRequest(httptest.NewRequest(http.MethodPut, "http://example.com/", nil))
82 + req.ContentLength = -1
83 + req.ProtoMajor = 2
84 + if declared {
85 + req.Trailer = http.Header{"Content-Digest": nil, "X-Checksum": nil, "X-Absent": nil}
86 + }
87 + want := http.Header{"Content-Digest": {"digest-one", "digest-two"}, "X-Checksum": {"checksum"}}
88 + if declared {
89 + want["X-Absent"] = nil
90 + }
91 + var reader io.Reader = strings.NewReader("hello")
92 + if finalBytes {
93 + reader = finalBytesReader{strings.NewReader("hello")}
94 + }
95 + body := &trailerTestBody{Reader: reader, finish: func() { req.Trailer = want.Clone() }}
96 + req.Body = body
97 + h := Handler{RequestBuffers: limit}
98 + out, err := h.prepareRequest(req, caddy.NewReplacer())
99 + if err != nil {
100 + t.Fatal(err)
101 + }
102 + defer out.Body.Close()
103 + if limit == 0 && body.finish == nil {
104 + t.Fatal("prepareRequest consumed streaming body")
105 + }
106 + got, err := io.ReadAll(out.Body)
107 + if err != nil || string(got) != "hello" {
108 + t.Fatalf("body=%q err=%v", got, err)
109 + }
110 + for range 2 {
111 + n, err := out.Body.Read(make([]byte, 8))
112 + if n != 0 || err != io.EOF {
113 + t.Fatalf("repeated read: n=%d err=%v", n, err)
114 + }
115 + }
116 + if !reflect.DeepEqual(out.Trailer, want) {
117 + t.Fatalf("trailers=%v, want %v", out.Trailer, want)
118 + }
119 + out.Trailer["Content-Digest"][0] = "outbound change"
120 + out.Trailer.Set("X-New", "outbound only")
121 + if !reflect.DeepEqual(req.Trailer, want) {
122 + t.Fatalf("outbound changes mutated incoming trailers: %v", req.Trailer)
123 + }
124 + })
125 + }
126 + }
127 + }
128 +}
129 +
130 +func TestRequestTrailersReadError(t *testing.T) {
131 + for _, readErr := range []error{io.ErrUnexpectedEOF, context.Canceled} {
132 + for _, limit := range []int64{0, 2, 32, -1} {
133 + t.Run(fmt.Sprintf("%v/buffer=%d", readErr, limit), func(t *testing.T) {
134 + req := prepareTestRequest(httptest.NewRequest(http.MethodPut, "http://example.com/", nil))
135 + req.ContentLength = -1
136 + req.Trailer = http.Header{"Content-Digest": nil}
137 + req.Body = &trailerTestBody{Reader: strings.NewReader("partial"), err: readErr, finish: func() {
138 + req.Trailer.Set("Content-Digest", "must not forward without EOF")
139 + }}
140 + out, err := (Handler{RequestBuffers: limit}).prepareRequest(req, caddy.NewReplacer())
141 + if err != nil {
142 + t.Fatal(err)
143 + }
144 + defer out.Body.Close()
145 + _, err = io.ReadAll(out.Body)
146 + // Unlimited buffering currently discards read errors; do not change
147 + // that existing policy here. It must still not publish trailers.
148 + if limit >= 0 && !errors.Is(err, readErr) {
149 + t.Fatalf("read error=%v, want %v", err, readErr)
150 + }
151 + if len(out.Trailer.Values("Content-Digest")) != 0 {
152 + t.Fatalf("published trailers after failed read: %v", out.Trailer)
153 + }
154 + })
155 + }
156 + }
157 +}
158 +
159 +type trailerObservation struct {
160 + Body string
161 + Trailer http.Header
162 + Header string
163 + Proto string
164 +}
165 +
166 +func TestRequestTrailersNetwork(t *testing.T) {
167 + for _, network := range []string{"tcp", "unix"} {
168 + t.Run(network, func(t *testing.T) {
169 + if network == "unix" && runtime.GOOS == "windows" {
170 + t.Skip("Unix sockets are not supported by this test on Windows")
171 + }
172 + backend := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
173 + body, err := io.ReadAll(r.Body)
174 + if err != nil {
175 + http.Error(w, err.Error(), 500)
176 + return
177 + }
178 + _ = json.NewEncoder(w).Encode(trailerObservation{string(body), r.Trailer, r.Header.Get("X-Control"), r.Proto})
179 + }))
180 + dial := backend.Listener.Addr().String()
181 + if network == "unix" {
182 + _ = backend.Listener.Close()
183 + // Keep the socket path below the platform's sockaddr_un limit.
184 + dir, err := os.MkdirTemp("", "caddy-trailers-")
185 + if err != nil {
186 + t.Fatal(err)
187 + }
188 + defer os.RemoveAll(dir)
189 + backend.Listener, err = net.Listen("unix", filepath.Join(dir, "upstream.sock"))
190 + if err != nil {
191 + t.Fatal(err)
192 + }
193 + dial = "unix/" + backend.Listener.Addr().String()
194 + }
195 + backend.Start()
196 + defer backend.Close()
197 + for _, limit := range []int64{0, 2, 1 << 20, -1} {
198 + t.Run(fmt.Sprintf("buffer=%d", limit), func(t *testing.T) {
199 + ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
200 + defer cancel()
201 + transport := &HTTPTransport{}
202 + var err error
203 + transport.Transport, err = transport.NewTransport(ctx)
204 + if err != nil {
205 + t.Fatal(err)
206 + }
207 + defer transport.Transport.CloseIdleConnections()
208 + h := minimalHandler(0, &Upstream{Host: new(Host), Dial: dial})
209 + h.Transport = transport
210 + h.RequestBuffers = limit
211 + front := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
212 + w.Header().Set("X-Client-Protocol", r.Proto)
213 + if r.ProtoMajor == 1 && len(r.TransferEncoding) != 1 {
214 + t.Errorf("HTTP/1 request is not chunked: %v", r.TransferEncoding)
215 + }
216 + if err := h.ServeHTTP(w, prepareTestRequest(r), nil); err != nil {
217 + http.Error(w, err.Error(), 502)
218 + }
219 + }))
220 + front.EnableHTTP2 = true
221 + front.StartTLS()
222 + defer front.Close()
223 + for _, proto := range []int{1, 2} {
224 + tr := front.Client().Transport.(*http.Transport).Clone()
225 + tr.ForceAttemptHTTP2 = proto == 2
226 + tr.Protocols = new(http.Protocols)
227 + tr.Protocols.SetHTTP1(proto == 1)
228 + tr.Protocols.SetHTTP2(proto == 2)
229 + tr.TLSClientConfig.NextProtos = nil
230 + client := &http.Client{Transport: tr, Timeout: 5 * time.Second}
231 + defer tr.CloseIdleConnections()
232 + for _, body := range []string{"", "hello, trailers"} {
233 + for _, trailers := range []http.Header{
234 + nil,
235 + {"Content-Digest": {"first", "second"}, "X-Checksum": {"checksum"}, "X-Absent": nil},
236 + } {
237 + req, err := http.NewRequest(http.MethodPut, front.URL, io.NopCloser(strings.NewReader(body)))
238 + if err != nil {
239 + t.Fatal(err)
240 + }
241 + req.ContentLength = -1
242 + req.Trailer = trailers.Clone()
243 + req.Header.Set("X-Control", "ordinary header")
244 + res, err := client.Do(req)
245 + if err != nil {
246 + t.Fatal(err)
247 + }
248 + var got trailerObservation
249 + err = json.NewDecoder(res.Body).Decode(&got)
250 + _ = res.Body.Close()
251 + if err != nil {
252 + t.Fatalf("decode status=%d: %v", res.StatusCode, err)
253 + }
254 + wantProto := fmt.Sprintf("HTTP/%d.%d", proto, 1)
255 + if proto == 2 {
256 + wantProto = "HTTP/2.0"
257 + }
258 + if res.Proto != wantProto || res.Header.Get("X-Client-Protocol") != wantProto {
259 + t.Fatalf("negotiated protocol: response=%s request=%s want=%s", res.Proto, res.Header.Get("X-Client-Protocol"), wantProto)
260 + }
261 + if res.StatusCode != 200 || got.Body != body || got.Header != "ordinary header" || got.Proto != "HTTP/1.1" {
262 + t.Fatalf("status=%d observation=%+v", res.StatusCode, got)
263 + }
264 + // An announced but absent field can be represented as nil
265 + // or one empty value by the HTTP/1 transport.
266 + if len(trailers) == 0 {
267 + if len(got.Trailer) != 0 {
268 + t.Fatalf("invented trailers: %v", got.Trailer)
269 + }
270 + } else {
271 + want := trailers.Clone()
272 + delete(want, "X-Absent")
273 + if got.Trailer.Get("X-Absent") != "" {
274 + t.Fatalf("absent trailer has value: %v", got.Trailer)
275 + }
276 + delete(got.Trailer, "X-Absent")
277 + if !reflect.DeepEqual(got.Trailer, want) {
278 + t.Fatalf("protocol=%s body=%q trailers=%v want=%v", wantProto, body, got.Trailer, want)
279 + }
280 + }
281 + }
282 + }
283 + }
284 + })
285 + }
286 + })
287 + }
288 +}
289 +
290 +// trailerRoundTripper makes retry failures deterministic without relying on
291 +// racing the operating system to reserve an unused port.
292 +type trailerRoundTripper func(*http.Request) (*http.Response, error)
293 +
294 +func (f trailerRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
295 +
296 +func TestRequestTrailersRetry(t *testing.T) {
297 + for _, tc := range []struct {
298 + name string
299 + limit int64
300 + dialFailure bool
301 + partial bool
302 + }{
303 + {name: "dial failure", dialFailure: true},
304 + {name: "dial failure with partial buffering", limit: 2, dialFailure: true},
305 + {name: "fully buffered replay", limit: 32},
306 + {name: "unlimited buffered replay", limit: -1},
307 + {name: "consumed stream cannot replay", partial: true},
308 + {name: "partially buffered stream cannot replay", limit: 2, partial: true},
309 + } {
310 + t.Run(tc.name, func(t *testing.T) {
311 + const payload = "hello, retry trailers"
312 + want := http.Header{"Content-Digest": {"first", "second"}, "X-Checksum": {"checksum"}}
313 + observed := make(chan trailerObservation, 1)
314 + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
315 + body, err := io.ReadAll(r.Body)
316 + if err != nil {
317 + http.Error(w, err.Error(), 500)
318 + return
319 + }
320 + observed <- trailerObservation{Body: string(body), Trailer: r.Trailer}
321 + w.WriteHeader(http.StatusOK)
322 + }))
323 + defer backend.Close()
324 + h := minimalHandler(1, &Upstream{Host: new(Host), Dial: backend.Listener.Addr().String()})
325 + h.RequestBuffers = tc.limit
326 + transport := testTransport{&http.Transport{}}
327 + defer transport.CloseIdleConnections()
328 + attempts := 0
329 + h.Transport = trailerRoundTripper(func(r *http.Request) (*http.Response, error) {
330 + attempts++
331 + if attempts == 1 {
332 + if !tc.dialFailure {
333 + if tc.partial {
334 + if _, err := io.ReadFull(r.Body, make([]byte, 3)); err != nil {
335 + t.Errorf("initial partial read: %v", err)
336 + }
337 + } else {
338 + got, err := io.ReadAll(r.Body)
339 + if err != nil || string(got) != payload || !reflect.DeepEqual(r.Trailer, want) {
340 + t.Errorf("initial attempt: body=%q trailers=%v err=%v", got, r.Trailer, err)
341 + }
342 + }
343 + }
344 + _ = r.Body.Close()
345 + err := errors.New("controlled first-attempt failure")
346 + if tc.dialFailure {
347 + return nil, DialError{err}
348 + }
349 + return nil, err
350 + }
351 + if tc.partial {
352 + _, err := io.ReadAll(r.Body)
353 + if err == nil {
354 + t.Error("partially consumed request body became replayable")
355 + }
356 + return nil, errors.New("consumed body is not replayable")
357 + }
358 + return transport.RoundTrip(r)
359 + })
360 + req := prepareTestRequest(httptest.NewRequest(http.MethodGet, "http://example.com/", nil))
361 + req.ContentLength = -1
362 + req.Trailer = http.Header{"Content-Digest": nil, "X-Checksum": nil}
363 + req.Body = &trailerTestBody{Reader: strings.NewReader(payload), finish: func() { req.Trailer = want.Clone() }}
364 + defer req.Body.Close()
365 + ctx, cancel := context.WithTimeout(req.Context(), 5*time.Second)
366 + defer cancel()
367 + req = req.WithContext(ctx)
368 + rec := httptest.NewRecorder()
369 + err := h.ServeHTTP(rec, req, nil)
370 + if attempts != 2 {
371 + t.Fatalf("attempts=%d, want 2", attempts)
372 + }
373 + if tc.partial {
374 + if err == nil {
375 + t.Fatal("partially consumed body was accepted for replay")
376 + }
377 + select {
378 + case got := <-observed:
379 + t.Fatalf("unexpected successful request: %+v", got)
380 + default:
381 + }
382 + return
383 + }
384 + if err != nil {
385 + t.Fatal(err)
386 + }
387 + select {
388 + case got := <-observed:
389 + if got.Body != payload || !reflect.DeepEqual(got.Trailer, want) {
390 + t.Fatalf("replay=%+v, want body=%q trailers=%v", got, payload, want)
391 + }
392 + default:
393 + t.Fatal("upstream did not receive the replay")
394 + }
395 + })
396 + }
397 +}
398 +
399 +func TestRequestTrailersStreaming(t *testing.T) {
400 + for _, limit := range []int64{0, 2} {
401 + t.Run(fmt.Sprintf("buffer=%d", limit), func(t *testing.T) {
402 + prefixRead := make(chan struct{})
403 + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
404 + prefix := make([]byte, 5)
405 + if _, err := io.ReadFull(r.Body, prefix); err != nil {
406 + http.Error(w, err.Error(), 500)
407 + return
408 + }
409 + close(prefixRead)
410 + rest, err := io.ReadAll(r.Body)
411 + if err != nil {
412 + http.Error(w, err.Error(), 500)
413 + return
414 + }
415 + _ = json.NewEncoder(w).Encode(trailerObservation{Body: string(prefix) + string(rest), Trailer: r.Trailer})
416 + }))
417 + defer backend.Close()
418 + h := minimalHandler(0, &Upstream{Host: new(Host), Dial: backend.Listener.Addr().String()})
419 + defer h.Transport.(testTransport).CloseIdleConnections()
420 + h.RequestBuffers = limit
421 + front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
422 + if err := h.ServeHTTP(w, prepareTestRequest(r), nil); err != nil {
423 + http.Error(w, err.Error(), 502)
424 + }
425 + }))
426 + defer front.Close()
427 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
428 + defer cancel()
429 + reader, writer := io.Pipe()
430 + defer reader.Close()
431 + defer writer.Close()
432 + writeDone := make(chan error, 1)
433 + go func() {
434 + _, err := io.WriteString(writer, "hello")
435 + if err == nil {
436 + select {
437 + case <-prefixRead:
438 + _, err = io.WriteString(writer, ", streamed")
439 + case <-ctx.Done():
440 + err = ctx.Err()
441 + }
442 + }
443 + _ = writer.CloseWithError(err)
444 + writeDone <- err
445 + }()
446 + req, err := http.NewRequestWithContext(ctx, http.MethodPut, front.URL, reader)
447 + if err != nil {
448 + t.Fatal(err)
449 + }
450 + req.Trailer = http.Header{"Content-Digest": {"stream digest"}}
451 + client := front.Client()
452 + client.Timeout = 5 * time.Second
453 + res, err := client.Do(req)
454 + if err != nil {
455 + t.Fatal(err)
456 + }
457 + defer res.Body.Close()
458 + var got trailerObservation
459 + if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
460 + t.Fatal(err)
461 + }
462 + if got.Body != "hello, streamed" || !reflect.DeepEqual(got.Trailer, req.Trailer) {
463 + t.Fatalf("streamed request=%+v", got)
464 + }
465 + if err := <-writeDone; err != nil {
466 + t.Fatal(err)
467 + }
468 + })
469 + }
470 +}
471 +
472 +// trailerCloseTracker lets tests wait for transport cleanup, including its
473 +// background request writer, without polling goroutine counts.
474 +type trailerCloseTracker struct {
475 + io.ReadCloser
476 + once sync.Once
477 + readOnce sync.Once
478 + closed chan struct{}
479 + readDone chan struct{}
480 +}
481 +
482 +func (b *trailerCloseTracker) Read(p []byte) (int, error) {
483 + n, err := b.ReadCloser.Read(p)
484 + if err != nil {
485 + b.readOnce.Do(func() { close(b.readDone) })
486 + }
487 + return n, err
488 +}
489 +
490 +func (b *trailerCloseTracker) Close() error {
491 + err := b.ReadCloser.Close()
492 + b.once.Do(func() { close(b.closed) })
493 + return err
494 +}
495 +
496 +func TestRequestTrailersEarlyResponse(t *testing.T) {
497 + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
498 + if err := http.NewResponseController(w).EnableFullDuplex(); err != nil {
499 + http.Error(w, err.Error(), 500)
500 + return
501 + }
502 + // Ensure the upload has started, then respond while the rest is blocked.
503 + if _, err := io.ReadFull(r.Body, make([]byte, 1)); err != nil {
504 + http.Error(w, err.Error(), 500)
505 + return
506 + }
507 + w.WriteHeader(http.StatusForbidden)
508 + _ = http.NewResponseController(w).Flush()
509 + }))
510 + defer backend.Close()
511 + h := minimalHandler(0, &Upstream{Host: new(Host), Dial: backend.Listener.Addr().String()})
512 + defer h.Transport.(testTransport).CloseIdleConnections()
513 + reader, writer := io.Pipe()
514 + defer reader.Close()
515 + defer writer.Close()
516 + req := prepareTestRequest(httptest.NewRequest(http.MethodPut, "http://example.com/", reader))
517 + body := &trailerCloseTracker{ReadCloser: reader, closed: make(chan struct{}), readDone: make(chan struct{})}
518 + req.Body = body
519 + req.ContentLength = -1
520 + req.Trailer = http.Header{"Content-Digest": nil}
521 + ctx, cancel := context.WithTimeout(req.Context(), 5*time.Second)
522 + defer cancel()
523 + req = req.WithContext(ctx)
524 + writeDone := make(chan error, 1)
525 + go func() {
526 + _, err := io.WriteString(writer, "x")
527 + writeDone <- err
528 + }()
529 + rec := httptest.NewRecorder()
530 + if err := h.ServeHTTP(rec, req, nil); err != nil {
531 + t.Fatal(err)
532 + }
533 + if rec.Code != http.StatusForbidden {
534 + t.Fatalf("status=%d", rec.Code)
535 + }
536 + if err := <-writeDone; err != nil {
537 + t.Fatal(err)
538 + }
539 + // The HTTP server owns the incoming body and closes it after the
540 + // proxy returns. Model that ownership here; the transport may still be
541 + // reading the unfinished upload when an early response arrives.
542 + _ = req.Body.Close()
543 + select {
544 + case <-body.readDone:
545 + case <-ctx.Done():
546 + t.Fatal("interrupted upload read did not stop")
547 + }
548 + if req.Trailer.Get("Content-Digest") != "" {
549 + t.Fatal("early response invented trailers")
550 + }
551 +}
552 +
553 +func TestRequestTrailersCancellation(t *testing.T) {
554 + started := make(chan struct{})
555 + backendDone := make(chan struct{})
556 + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
557 + defer close(backendDone)
558 + if _, err := io.ReadFull(r.Body, make([]byte, 1)); err != nil {
559 + return
560 + }
561 + close(started)
562 + _, err := io.Copy(io.Discard, r.Body)
563 + if err == nil {
564 + t.Error("canceled upstream body unexpectedly reached EOF")
565 + }
566 + if r.Trailer.Get("Content-Digest") != "" {
567 + t.Error("cancellation published trailer values")
568 + }
569 + }))
570 + defer backend.Close()
571 + h := minimalHandler(0, &Upstream{Host: new(Host), Dial: backend.Listener.Addr().String()})
572 + defer h.Transport.(testTransport).CloseIdleConnections()
573 + reader, writer := io.Pipe()
574 + defer reader.Close()
575 + defer writer.Close()
576 + body := &trailerCloseTracker{ReadCloser: reader, closed: make(chan struct{}), readDone: make(chan struct{})}
577 + req := prepareTestRequest(httptest.NewRequest(http.MethodPut, "http://example.com/", body))
578 + req.ContentLength = -1
579 + req.Trailer = http.Header{"Content-Digest": nil}
580 + timeout, stop := context.WithTimeout(req.Context(), 5*time.Second)
581 + defer stop()
582 + ctx, cancel := context.WithCancel(timeout)
583 + defer cancel()
584 + req = req.WithContext(ctx)
585 + writeDone := make(chan error, 1)
586 + go func() { _, err := io.WriteString(writer, "x"); writeDone <- err }()
587 + rec := httptest.NewRecorder()
588 + done := make(chan error, 1)
589 + go func() { done <- h.ServeHTTP(rec, req, nil) }()
590 + select {
591 + case <-started:
592 + case <-timeout.Done():
593 + t.Fatal("upstream never received upload prefix")
594 + }
595 + cancel()
596 + // A real client disconnect also breaks the incoming socket read. Canceling
597 + // only a synthetic request context cannot unblock an unrelated io.Pipe.
598 + _ = writer.CloseWithError(context.Canceled)
599 + select {
600 + case err := <-done:
601 + if err != nil || rec.Code != 499 {
602 + t.Fatalf("cancellation: status=%d error=%v", rec.Code, err)
603 + }
604 + case <-timeout.Done():
605 + t.Fatal("proxy did not stop after cancellation")
606 + }
607 + for _, ch := range []<-chan struct{}{body.closed, body.readDone, backendDone} {
608 + select {
609 + case <-ch:
610 + case <-timeout.Done():
611 + t.Fatal("upload cleanup did not complete")
612 + }
613 + }
614 + if err := <-writeDone; err != nil {
615 + t.Fatal(err)
616 + }
617 +}
modules/caddyhttp/reverseproxy/reverseproxy.go
+41
@@ -909,6 +909,7 @@ func normalizeWebsocketHeaders(header http.Header) {
909 // proxying) regardless of proxy retries. This assumes that no mutations
910 // of the cloned request are performed by h during or after proxying.
911 func (h Handler) prepareRequest(req *http.Request, repl *caddy.Replacer) (*http.Request, error) {
912 + origReq := req
913 req = cloneRequest(req)
914
915 // if enabled, perform rewrites on the cloned request; if
@@ -922,6 +923,17 @@ func (h Handler) prepareRequest(req *http.Request, repl *caddy.Replacer) (*http.
923 }
924 }
925
926 + if req.Body != nil {
927 + // net/http fills in the incoming trailers only when Body.Read reaches
928 + // EOF. Keep an independent map, but update it in that same Read before
929 + // the transport writes the trailers. Install this before buffering,
930 + // which may itself consume the body to EOF before the round trip.
931 + if req.Trailer == nil {
932 + req.Trailer = make(http.Header)
933 + }
934 + req.Body = &requestBodyWithTrailers{ReadCloser: req.Body, original: origReq, trailer: req.Trailer}
935 + }
936 +
937 // Fully buffering the request is incompatible with incremental forwarding.
938 // A negative length is treated as potential content because determining
939 // whether it is empty would itself require waiting for the stream to end.
@@ -948,6 +960,15 @@ func (h Handler) prepareRequest(req *http.Request, repl *caddy.Replacer) (*http.
960 }
961 }
962
963 + // HTTP/1 upstreams require chunked framing to carry trailers, including
964 + // when buffering has made the body length known (or the body is empty).
965 + // Length-required transports such as FastCGI need the buffered length and
966 + // do not carry HTTP trailers.
967 + if len(req.Trailer) > 0 && req.Body != nil && !h.transportRequiresContentLength() {
968 + req.ContentLength = -1
969 + req.Header.Del("Content-Length")
970 + }
971 +
972 if req.ContentLength == 0 {
973 req.Body = nil // Issue golang/go#16036: nil Body for http.Transport retries
974 }
@@ -1686,6 +1707,26 @@ func (h Handler) bufferedBody(originalBody io.ReadCloser, limit int64) (io.ReadC
1707 }, written
1708 }
1709
1710 +// requestBodyWithTrailers publishes a snapshot of the incoming trailers at EOF.
1711 +// Close only closes the underlying body; it must not read or publish trailers.
1712 +type requestBodyWithTrailers struct {
1713 + io.ReadCloser
1714 + original *http.Request
1715 + trailer http.Header
1716 +}
1717 +
1718 +func (b *requestBodyWithTrailers) Read(p []byte) (int, error) {
1719 + n, err := b.ReadCloser.Read(p)
1720 + if err == io.EOF && b.original != nil {
1721 + clear(b.trailer)
1722 + for k, vv := range b.original.Trailer {
1723 + b.trailer[k] = slices.Clone(vv)
1724 + }
1725 + b.original = nil // repeated EOF reads must not recopy or append values
1726 + }
1727 + return n, err
1728 +}
1729 +
1730 // cloneRequest makes a semi-deep clone of origReq.
1731 //
1732 // Most of this code is borrowed from the Go stdlib reverse proxy,