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,