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, 04:45 UTC
feat: implement the Incremental header field (RFC 10036) (#8020)
Co-authored-by: Zen Dodd <mail@steadytao.com>
Kévin Dunglas committed
Oct 3, 2026 at 02:59 UTC
2f34b936f5d1f5d19e5e1e0905032133bcbf9b05
14 files changed
+1411
-18
caddytest/integration/caddyfile_adapt/reverse_proxy_proxy_status_name.caddyfiletest
new
+56
@@ -0,0 +1,56 @@
1
+https://example.com {
2
+ reverse_proxy https://localhost:54321 {
3
+ proxy_status_name proxy-3.example.com
4
+ }
5
+}
6
+
7
+----------
8
+{
9
+ "apps": {
10
+ "http": {
11
+ "servers": {
12
+ "srv0": {
13
+ "listen": [
14
+ ":443"
15
+ ],
16
+ "routes": [
17
+ {
18
+ "match": [
19
+ {
20
+ "host": [
21
+ "example.com"
22
+ ]
23
+ }
24
+ ],
25
+ "handle": [
26
+ {
27
+ "handler": "subroute",
28
+ "routes": [
29
+ {
30
+ "handle": [
31
+ {
32
+ "handler": "reverse_proxy",
33
+ "proxy_status_name": "proxy-3.example.com",
34
+ "transport": {
35
+ "protocol": "http",
36
+ "tls": {}
37
+ },
38
+ "upstreams": [
39
+ {
40
+ "dial": "localhost:54321"
41
+ }
42
+ ]
43
+ }
44
+ ]
45
+ }
46
+ ]
47
+ }
48
+ ],
49
+ "terminal": true
50
+ }
51
+ ]
52
+ }
53
+ }
54
+ }
55
+ }
56
+}
go.mod
+1
@@ -14,6 +14,7 @@ require (
14
github.com/caddyserver/zerossl v0.1.6
15
github.com/cloudflare/circl v1.6.5
16
github.com/dunglas/go-urlpattern v1.0.0
17
+ github.com/dunglas/httpsfv v1.1.1
18
github.com/dustin/go-humanize v1.1.0
19
github.com/go-chi/chi/v5 v5.3.2
20
github.com/google/uuid v1.6.0
go.sum
+2
@@ -141,6 +141,8 @@ github.com/dlclark/regexp2/v2 v2.8.0 h1:CekDhPLGfm+GAJmPVFIG+5dqMIQPkyHJll7Bbdne
141
github.com/dlclark/regexp2/v2 v2.8.0/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
142
github.com/dunglas/go-urlpattern v1.0.0 h1:AmMwIDXWJFezeE1v38rxvQRGvvzTlityFuifLLVfoQQ=
143
github.com/dunglas/go-urlpattern v1.0.0/go.mod h1:9qyjDljBPOWyWCGz7vo3Ek7cdnoG/DVk0Ucle7gWVS8=
144
+github.com/dunglas/httpsfv v1.1.1 h1:HoSs101zIE9I23DlqlmljJ/OIi7ILwrH347pXhRZdxI=
145
+github.com/dunglas/httpsfv v1.1.1/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
146
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
147
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
148
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
modules/caddyhttp/encode/encode.go
+37
-5
@@ -252,6 +252,7 @@ type responseWriter struct {
252
config *Encode
253
statusCode int
254
wroteHeader bool
255
+ incremental bool
256
isConnect bool
257
disabled bool // disable encoding for this response
258
}
@@ -265,6 +266,10 @@ func (rw *responseWriter) WriteHeader(status int) {
266
}
267
268
h := rw.Header()
269
+ finalStatus := status < 100 || status > 199
270
+ if !rw.wroteHeader && finalStatus {
271
+ rw.incremental = caddyhttp.IsIncremental(h)
272
+ }
273
274
// See #5849 and RFC 9110 section 15.4.5 (https://www.rfc-editor.org/rfc/rfc9110.html#section-15.4.5) - 304
275
// Not Modified must have certain headers set as if it was a 200 response, and according to the issue
@@ -288,14 +293,21 @@ func (rw *responseWriter) WriteHeader(status int) {
293
rw.ResponseWriter.WriteHeader(status)
294
}
295
291
- // write header immediately for server-sent events responses, since the
296
+ // write header immediately for server-sent events responses and for
297
+ // responses that ask for incremental forwarding (RFC 10036), since the
298
// body may not be written for a while and the client needs the headers
293
- // to establish the event stream; see #6293
294
- if !rw.wroteHeader && (status < 100 || status > 199) && isSSE(h.Get("Content-Type")) {
299
+ // to establish the stream; see #6293
300
+ if !rw.wroteHeader && finalStatus &&
301
+ (rw.incremental || isSSE(h.Get("Content-Type"))) {
302
rw.init()
303
rw.ResponseWriter.WriteHeader(status)
304
rw.wroteHeader = true
305
}
306
+ if rw.incremental && rw.wroteHeader {
307
+ // WriteHeader alone may remain buffered by net/http. Incremental
308
+ // forwarding requires the header section to be sent downstream now.
309
+ _ = http.NewResponseController(rw.ResponseWriter).Flush()
310
+ }
311
}
312
313
func isSSE(contentType string) bool {
@@ -320,6 +332,10 @@ func (enc *Encode) Match(rw *responseWriter) bool {
332
// FlushError is an alternative Flush returning an error. It delays the actual Flush of the underlying
333
// ResponseWriterWrapper until headers were written.
334
func (rw *responseWriter) FlushError() error {
335
+ if !rw.wroteHeader && rw.statusCode == 0 && caddyhttp.IsIncremental(rw.Header()) {
336
+ rw.WriteHeader(http.StatusOK)
337
+ }
338
+
339
// WriteHeader wasn't called and is a CONNECT request, treat it as a success.
340
// otherwise, wait until header is written.
341
if rw.isConnect && !rw.wroteHeader && rw.statusCode == 0 {
@@ -359,6 +375,12 @@ func (rw *responseWriter) Write(p []byte) (int, error) {
375
if rw.isConnect && !rw.wroteHeader && rw.statusCode == 0 {
376
rw.WriteHeader(http.StatusOK)
377
}
378
+ if !rw.wroteHeader && rw.statusCode == 0 && caddyhttp.IsIncremental(rw.Header()) {
379
+ if rw.Header().Get("Content-Type") == "" {
380
+ rw.Header().Set("Content-Type", http.DetectContentType(p))
381
+ }
382
+ rw.WriteHeader(http.StatusOK)
383
+ }
384
385
// sniff content-type and determine content-length
386
if !rw.wroteHeader && rw.config.MinLength > 0 {
@@ -390,11 +412,17 @@ func (rw *responseWriter) Write(p []byte) (int, error) {
412
rw.wroteHeader = true
413
}
414
415
+ var n int
416
+ var err error
417
if rw.w != nil {
394
- return rw.w.Write(p)
418
+ n, err = rw.w.Write(p)
419
} else {
396
- return rw.ResponseWriter.Write(p)
420
+ n, err = rw.ResponseWriter.Write(p)
421
+ }
422
+ if err != nil || !rw.incremental {
423
+ return n, err
424
}
425
+ return n, rw.FlushError()
426
}
427
428
// used to mask ReadFrom method
@@ -410,6 +438,10 @@ const sniffLen = 512
438
// It's based on the standard library HTTP/1.1 response writer implementation.
439
// https://github.com/golang/go/blob/f4e3ec3dbe3b8e04a058d266adf8e048bab563f2/src/net/http/server.go#L586
440
func (rw *responseWriter) ReadFrom(r io.Reader) (int64, error) {
441
+ if rw.incremental || !rw.wroteHeader && caddyhttp.IsIncremental(rw.Header()) {
442
+ return io.Copy(writerOnly{rw}, r)
443
+ }
444
+
445
rf, ok := rw.ResponseWriter.(io.ReaderFrom)
446
// sendfile can't be used anyway
447
if !ok {
modules/caddyhttp/encode/sse_test.go
+116
-1
@@ -15,8 +15,10 @@
15
package encode_test
16
17
import (
18
+ "io"
19
"net/http"
20
"net/http/httptest"
21
+ "strings"
22
"testing"
23
24
"github.com/caddyserver/caddy/v2"
@@ -49,6 +51,14 @@ func (rw *recordingWriter) Flush() {
51
}
52
}
53
54
+func (rw *recordingWriter) ReadFrom(r io.Reader) (int64, error) {
55
+ return io.Copy(rw.ResponseWriter, r)
56
+}
57
+
58
+type readerOnly struct {
59
+ io.Reader
60
+}
61
+
62
func newSSEEncodeHandler(t *testing.T) *encode.Encode {
63
t.Helper()
64
enc := &encode.Encode{
@@ -117,7 +127,8 @@ func TestNonSSESmallResponseStillBuffersHeader(t *testing.T) {
127
enc := newSSEEncodeHandler(t)
128
r := httptest.NewRequest(http.MethodGet, "/", nil)
129
r.Header.Set("Accept-Encoding", "gzip")
120
- rec := &recordingWriter{ResponseWriter: httptest.NewRecorder()}
130
+ baseRec := httptest.NewRecorder()
131
+ rec := &recordingWriter{ResponseWriter: baseRec}
132
133
next := caddyhttp.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) error {
134
w.Header().Set("Content-Type", "text/plain")
@@ -132,3 +143,107 @@ func TestNonSSESmallResponseStillBuffersHeader(t *testing.T) {
143
t.Fatalf("ServeHTTP() error = %v", err)
144
}
145
}
146
+
147
+// A response marked with the Incremental header field (RFC 10036) needs the
148
+// same treatment as SSE: its headers must reach the client before the body,
149
+// since the sender may not produce content for a long time.
150
+func TestIncrementalHeadersFlushedBeforeBody(t *testing.T) {
151
+ enc := newSSEEncodeHandler(t)
152
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
153
+ r.Header.Set("Accept-Encoding", "gzip")
154
+ baseRec := httptest.NewRecorder()
155
+ rec := &recordingWriter{ResponseWriter: baseRec}
156
+
157
+ next := caddyhttp.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) error {
158
+ w.Header().Set("Content-Type", "application/json")
159
+ w.Header().Set("Incremental", "?1")
160
+ w.WriteHeader(http.StatusOK)
161
+
162
+ if !rec.wroteHeader {
163
+ t.Error("incremental response headers were not written to the client before the body")
164
+ }
165
+ if rec.status != http.StatusOK {
166
+ t.Errorf("underlying status = %d, want 200", rec.status)
167
+ }
168
+ if !baseRec.Flushed {
169
+ t.Error("incremental response headers were not flushed to the client")
170
+ }
171
+ return nil
172
+ })
173
+
174
+ if err := enc.ServeHTTP(rec, r, next); err != nil {
175
+ t.Fatalf("ServeHTTP() error = %v", err)
176
+ }
177
+}
178
+
179
+func TestIncrementalImplicitWriteFlushes(t *testing.T) {
180
+ enc := newSSEEncodeHandler(t)
181
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
182
+ r.Header.Set("Accept-Encoding", "gzip")
183
+ baseRec := httptest.NewRecorder()
184
+ rec := &recordingWriter{ResponseWriter: baseRec}
185
+
186
+ next := caddyhttp.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) error {
187
+ w.Header().Set("Content-Type", "application/json")
188
+ w.Header().Set("Incremental", "?1")
189
+ if _, err := w.Write([]byte("x")); err != nil {
190
+ return err
191
+ }
192
+ if !baseRec.Flushed {
193
+ t.Error("incremental response write was not flushed to the client")
194
+ }
195
+ return nil
196
+ })
197
+
198
+ if err := enc.ServeHTTP(rec, r, next); err != nil {
199
+ t.Fatalf("ServeHTTP() error = %v", err)
200
+ }
201
+}
202
+
203
+func TestIncrementalReadFromFlushes(t *testing.T) {
204
+ enc := newSSEEncodeHandler(t)
205
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
206
+ r.Header.Set("Accept-Encoding", "gzip")
207
+ baseRec := httptest.NewRecorder()
208
+ rec := &recordingWriter{ResponseWriter: baseRec}
209
+
210
+ next := caddyhttp.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) error {
211
+ w.Header().Set("Content-Type", "application/json")
212
+ w.Header().Set("Incremental", "?1")
213
+ if _, err := io.Copy(w, readerOnly{Reader: strings.NewReader("x")}); err != nil {
214
+ return err
215
+ }
216
+ if !baseRec.Flushed {
217
+ t.Error("incremental ReadFrom data was not flushed to the client")
218
+ }
219
+ return nil
220
+ })
221
+
222
+ if err := enc.ServeHTTP(rec, r, next); err != nil {
223
+ t.Fatalf("ServeHTTP() error = %v", err)
224
+ }
225
+}
226
+
227
+func TestIncrementalInformationalResponseDoesNotAffectFinalResponse(t *testing.T) {
228
+ enc := newSSEEncodeHandler(t)
229
+ r := httptest.NewRequest(http.MethodGet, "/", nil)
230
+ r.Header.Set("Accept-Encoding", "gzip")
231
+ baseRec := httptest.NewRecorder()
232
+ rec := &recordingWriter{ResponseWriter: baseRec}
233
+
234
+ next := caddyhttp.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) error {
235
+ w.Header().Set("Incremental", "?1")
236
+ w.WriteHeader(http.StatusEarlyHints)
237
+ w.Header().Del("Incremental")
238
+ w.Header().Set("Content-Type", "text/plain")
239
+ w.WriteHeader(http.StatusOK)
240
+ if baseRec.Flushed {
241
+ t.Error("informational Incremental field forced the final response to flush")
242
+ }
243
+ return nil
244
+ })
245
+
246
+ if err := enc.ServeHTTP(rec, r, next); err != nil {
247
+ t.Fatalf("ServeHTTP() error = %v", err)
248
+ }
249
+}
modules/caddyhttp/incremental.go
new
+53
@@ -0,0 +1,53 @@
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 caddyhttp
16
+
17
+import (
18
+ "net/http"
19
+
20
+ "github.com/dunglas/httpsfv"
21
+)
22
+
23
+// incrementalHeader is the HTTP field with which a sender asks intermediaries
24
+// to start forwarding a message before it has been received in full.
25
+// See RFC 10036: https://www.rfc-editor.org/rfc/rfc10036.html
26
+const incrementalHeader = "Incremental"
27
+
28
+// IsIncremental returns true if hdr requests incremental forwarding of the
29
+// message it belongs to. The field applies to a single message, so a
30
+// request and its response have to carry it independently.
31
+func IsIncremental(hdr http.Header) bool {
32
+ vals := hdr.Values(incrementalHeader)
33
+ if len(vals) == 0 {
34
+ return false
35
+ }
36
+
37
+ // every sender serializes true this way, and parsing it costs
38
+ // ~3x more time and two allocations
39
+ if len(vals) == 1 && vals[0] == "?1" {
40
+ return true
41
+ }
42
+
43
+ item, err := httpsfv.UnmarshalItem(vals)
44
+ if err != nil {
45
+ return false
46
+ }
47
+
48
+ // only a Boolean is valid; any other type is ignored, as are
49
+ // parameters we don't know about
50
+ b, ok := item.Value.(bool)
51
+
52
+ return ok && b
53
+}
modules/caddyhttp/incremental_test.go
new
+61
@@ -0,0 +1,61 @@
1
+package caddyhttp
2
+
3
+import (
4
+ "net/http"
5
+ "testing"
6
+)
7
+
8
+func TestIsIncremental(t *testing.T) {
9
+ for _, tc := range []struct {
10
+ name string
11
+ values []string
12
+ want bool
13
+ }{
14
+ {name: "absent"},
15
+ {name: "true", values: []string{"?1"}, want: true},
16
+ {name: "false", values: []string{"?0"}},
17
+ {name: "surrounding space", values: []string{" ?1 "}, want: true},
18
+ {name: "unknown parameter", values: []string{"?1;q=0.5"}, want: true},
19
+ {name: "empty parameter", values: []string{"?1;"}},
20
+ {name: "trailing garbage", values: []string{"?1 junk"}},
21
+ {name: "integer", values: []string{"1"}},
22
+ {name: "token", values: []string{"true"}},
23
+ {name: "string", values: []string{`"?1"`}},
24
+ {name: "invalid boolean", values: []string{"?2"}},
25
+ {name: "empty value", values: []string{""}},
26
+ {name: "repeated field", values: []string{"?1", "?0"}},
27
+ } {
28
+ t.Run(tc.name, func(t *testing.T) {
29
+ hdr := http.Header{}
30
+ for _, v := range tc.values {
31
+ hdr.Add(incrementalHeader, v)
32
+ }
33
+ if got := IsIncremental(hdr); got != tc.want {
34
+ t.Errorf("IsIncremental(%q) = %v, want %v", tc.values, got, tc.want)
35
+ }
36
+ })
37
+ }
38
+}
39
+
40
+func BenchmarkIsIncremental(b *testing.B) {
41
+ for _, bc := range []struct {
42
+ name string
43
+ value string
44
+ }{
45
+ {name: "absent"},
46
+ {name: "true", value: "?1"},
47
+ {name: "false", value: "?0"},
48
+ {name: "true with param", value: "?1;q=0.5"},
49
+ } {
50
+ hdr := http.Header{}
51
+ if bc.value != "" {
52
+ hdr.Set(incrementalHeader, bc.value)
53
+ }
54
+ b.Run(bc.name, func(b *testing.B) {
55
+ b.ReportAllocs()
56
+ for b.Loop() {
57
+ IsIncremental(hdr)
58
+ }
59
+ })
60
+ }
61
+}
modules/caddyhttp/reverseproxy/caddyfile.go
+12
@@ -101,6 +101,9 @@ func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error)
101
// stream_close_delay <duration>
102
// verbose_logs
103
//
104
+// # reporting
105
+// proxy_status_name <name>
106
+//
107
// # request manipulation
108
// trusted_proxies [private_ranges] <ranges...>
109
// header_up [+|-]<field> [<value|regexp> [<replacement>]]
@@ -675,6 +678,15 @@ func (h *Handler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
678
h.StreamBufferSize = int(size)
679
}
680
681
+ case "proxy_status_name":
682
+ if !d.NextArg() {
683
+ return d.ArgErr()
684
+ }
685
+ h.ProxyStatusName = d.Val()
686
+ if d.NextArg() {
687
+ return d.ArgErr()
688
+ }
689
+
690
case "stream_timeout":
691
if !d.NextArg() {
692
return d.ArgErr()
modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go
+12
-3
@@ -157,6 +157,14 @@ func (t Transport) DefaultBufferSizes() (int64, int64) {
157
return 4096, 0
158
}
159
160
+// RequiresContentLength reports that fastcgi cannot forward a body of unknown
161
+// length: without a valid CONTENT_LENGTH the client rejects the request rather
162
+// than let the upstream hang, so the request buffer above is the only way to
163
+// learn the length of a body that does not announce one.
164
+func (t Transport) RequiresContentLength() bool {
165
+ return true
166
+}
167
+
168
// RoundTrip implements http.RoundTripper.
169
func (t Transport) RoundTrip(r *http.Request) (*http.Response, error) {
170
server := r.Context().Value(caddyhttp.ServerCtxKey).(*caddyhttp.Server)
@@ -532,7 +540,8 @@ var headerNameReplacer = strings.NewReplacer("-", "_")
540
var (
541
_ zapcore.ObjectMarshaler = (*loggableEnv)(nil)
542
535
- _ caddy.Provisioner = (*Transport)(nil)
536
- _ http.RoundTripper = (*Transport)(nil)
537
- _ reverseproxy.BufferedTransport = (*Transport)(nil)
543
+ _ caddy.Provisioner = (*Transport)(nil)
544
+ _ http.RoundTripper = (*Transport)(nil)
545
+ _ reverseproxy.BufferedTransport = (*Transport)(nil)
546
+ _ reverseproxy.ContentLengthRequiredTransport = (*Transport)(nil)
547
)
modules/caddyhttp/reverseproxy/fastcgi/incremental_test.go
new
+134
@@ -0,0 +1,134 @@
1
+package fastcgi
2
+
3
+import (
4
+ "context"
5
+ "errors"
6
+ "io"
7
+ "net"
8
+ "net/http"
9
+ "net/http/fcgi"
10
+ "net/http/httptest"
11
+ "strings"
12
+ "testing"
13
+
14
+ "github.com/caddyserver/caddy/v2"
15
+ "github.com/caddyserver/caddy/v2/modules/caddyhttp"
16
+)
17
+
18
+// fcgiBackend serves h over FastCGI and returns its address.
19
+func fcgiBackend(t *testing.T, h http.Handler) string {
20
+ t.Helper()
21
+
22
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
23
+ if err != nil {
24
+ t.Fatalf("listen: %v", err)
25
+ }
26
+ t.Cleanup(func() { ln.Close() })
27
+ go fcgi.Serve(ln, h)
28
+
29
+ return ln.Addr().String()
30
+}
31
+
32
+// fcgiRoundTrip sends req to a FastCGI backend at addr, the way the reverse
33
+// proxy does once it has picked an upstream and decided not to buffer.
34
+func fcgiRoundTrip(t *testing.T, addr string, req *http.Request) (*http.Response, error) {
35
+ t.Helper()
36
+
37
+ ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
38
+ t.Cleanup(cancel)
39
+
40
+ transport := Transport{Root: "/", SplitPath: []string{".php"}}
41
+ if err := transport.Provision(ctx); err != nil {
42
+ t.Fatalf("provisioning transport: %v", err)
43
+ }
44
+
45
+ req.URL.Host = addr
46
+ req = caddyhttp.PrepareRequest(req, caddy.NewReplacer(), nil, &caddyhttp.Server{})
47
+
48
+ res, err := transport.RoundTrip(req)
49
+ if res != nil {
50
+ t.Cleanup(func() { res.Body.Close() })
51
+ }
52
+
53
+ return res, err
54
+}
55
+
56
+// A request whose length is known carries its own CONTENT_LENGTH, so fastcgi
57
+// streams it without the request buffer it otherwise needs: refusing to
58
+// forward it incrementally would deny a request that works fine unbuffered.
59
+func TestIncrementalUnbufferedRequestWithKnownLength(t *testing.T) {
60
+ var gotBody string
61
+ var gotLength int64
62
+ addr := fcgiBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
63
+ b, _ := io.ReadAll(r.Body)
64
+ gotBody, gotLength = string(b), r.ContentLength
65
+ }))
66
+
67
+ req := httptest.NewRequest(http.MethodPost, "http://example.com/index.php", strings.NewReader("hello"))
68
+ req.Header.Set("Incremental", "?1")
69
+
70
+ res, err := fcgiRoundTrip(t, addr, req)
71
+ if err != nil {
72
+ t.Fatalf("RoundTrip() error = %v", err)
73
+ }
74
+ if res.StatusCode != http.StatusOK {
75
+ t.Errorf("status = %d, want 200", res.StatusCode)
76
+ }
77
+ if gotBody != "hello" {
78
+ t.Errorf("upstream body = %q, want %q", gotBody, "hello")
79
+ }
80
+ if gotLength != 5 {
81
+ t.Errorf("upstream CONTENT_LENGTH = %d, want 5", gotLength)
82
+ }
83
+}
84
+
85
+// A request whose length is unknown, as a chunked request is, has no
86
+// CONTENT_LENGTH to pass on, and the fastcgi client rejects it with 411 rather
87
+// than let the upstream hang. Only the request buffer can supply that length,
88
+// which is why incremental forwarding is refused instead in this case.
89
+func TestIncrementalUnbufferedRequestWithUnknownLength(t *testing.T) {
90
+ var reached bool
91
+ addr := fcgiBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
92
+ reached = true
93
+ }))
94
+
95
+ req := httptest.NewRequest(http.MethodPost, "http://example.com/index.php", io.NopCloser(strings.NewReader("hello")))
96
+ req.Header.Set("Incremental", "?1")
97
+ if req.ContentLength != -1 {
98
+ t.Fatalf("ContentLength = %d, want -1", req.ContentLength)
99
+ }
100
+
101
+ _, err := fcgiRoundTrip(t, addr, req)
102
+
103
+ var handlerErr caddyhttp.HandlerError
104
+ if !errors.As(err, &handlerErr) {
105
+ t.Fatalf("expected a caddyhttp.HandlerError, got %T: %v", err, err)
106
+ }
107
+ if handlerErr.StatusCode != http.StatusLengthRequired {
108
+ t.Errorf("status = %d, want %d", handlerErr.StatusCode, http.StatusLengthRequired)
109
+ }
110
+ if reached {
111
+ t.Error("request reached the upstream despite having no CONTENT_LENGTH")
112
+ }
113
+}
114
+
115
+// Declaring that a length is required is what makes the reverse proxy refuse
116
+// to forward a body of unknown length incrementally rather than send it on to
117
+// the 411 above.
118
+func TestRequiresContentLength(t *testing.T) {
119
+ if !(Transport{}).RequiresContentLength() {
120
+ t.Error("RequiresContentLength() = false, want true")
121
+ }
122
+}
123
+
124
+// The request buffer fastcgi asks for by default is what supplies the
125
+// CONTENT_LENGTH above, so these values matter beyond this package.
126
+func TestDefaultBufferSizes(t *testing.T) {
127
+ reqBuffers, respBuffers := Transport{}.DefaultBufferSizes()
128
+ if reqBuffers != 4096 {
129
+ t.Errorf("request buffers = %d, want 4096", reqBuffers)
130
+ }
131
+ if respBuffers != 0 {
132
+ t.Errorf("response buffers = %d, want 0", respBuffers)
133
+ }
134
+}
modules/caddyhttp/reverseproxy/incremental_test.go
new
+685
@@ -0,0 +1,685 @@
1
+package reverseproxy
2
+
3
+import (
4
+ "errors"
5
+ "fmt"
6
+ "io"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "strings"
10
+ "testing"
11
+ "time"
12
+
13
+ "github.com/caddyserver/caddy/v2"
14
+ "github.com/caddyserver/caddy/v2/modules/caddyhttp"
15
+ "github.com/caddyserver/caddy/v2/modules/caddyhttp/headers"
16
+ "github.com/caddyserver/caddy/v2/modules/caddyhttp/rewrite"
17
+)
18
+
19
+// testProxyStatusName identifies the proxy in Proxy-Status assertions. RFC 9209
20
+// wants the deployment named rather than the software, so the tests configure
21
+// one the way a deployment would.
22
+const testProxyStatusName = "proxy-3.example.com"
23
+
24
+// assertRefused checks the response of a refused incremental forwarding
25
+// against what RFC 10036 section 4.1 recommends.
26
+func assertRefused(t *testing.T, err error, hdr http.Header) {
27
+ t.Helper()
28
+
29
+ var handlerErr caddyhttp.HandlerError
30
+ if !errors.As(err, &handlerErr) {
31
+ t.Fatalf("expected a caddyhttp.HandlerError, got %T: %v", err, err)
32
+ }
33
+ if handlerErr.StatusCode != http.StatusNotImplemented {
34
+ t.Errorf("status = %d, want %d", handlerErr.StatusCode, http.StatusNotImplemented)
35
+ }
36
+
37
+ want := testProxyStatusName + ";error=" + proxyErrorIncrementalRefused
38
+ if got := hdr.Get("Proxy-Status"); got != want {
39
+ t.Errorf("Proxy-Status = %q, want %q", got, want)
40
+ }
41
+}
42
+
43
+// incrementalHandler proxies to backendAddr under a name it can report in a
44
+// Proxy-Status field.
45
+func incrementalHandler(backendAddr string) *Handler {
46
+ h := minimalHandler(0, &Upstream{
47
+ Host: new(Host),
48
+ Dial: backendAddr,
49
+ })
50
+ h.ProxyStatusName = testProxyStatusName
51
+
52
+ return h
53
+}
54
+
55
+// incrementalRequest builds a request marked incremental; an unknown length is
56
+// what a chunked request looks like once the server has parsed it.
57
+func incrementalRequest(body string, knownLength bool) *http.Request {
58
+ var r io.Reader = strings.NewReader(body)
59
+ if !knownLength {
60
+ r = io.NopCloser(r) // opaque to httptest, so ContentLength stays -1
61
+ }
62
+ req := httptest.NewRequest(http.MethodPost, "http://example.com/", r)
63
+ req.Header.Set("Incremental", "?1")
64
+ return prepareTestRequest(req)
65
+}
66
+
67
+// lengthRequiringTransport stands in for fastcgi, which cannot forward a body
68
+// whose length it does not know.
69
+type lengthRequiringTransport struct{ testTransport }
70
+
71
+func (lengthRequiringTransport) RequiresContentLength() bool { return true }
72
+
73
+type roundTripFunc func(*http.Request) (*http.Response, error)
74
+
75
+func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
76
+ return f(r)
77
+}
78
+
79
+type lengthRequiringRoundTripFunc roundTripFunc
80
+
81
+func (f lengthRequiringRoundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
82
+ return roundTripFunc(f)(r)
83
+}
84
+
85
+func (lengthRequiringRoundTripFunc) RequiresContentLength() bool { return true }
86
+
87
+type stagedBody struct {
88
+ release <-chan struct{}
89
+ sent bool
90
+}
91
+
92
+func (b *stagedBody) Read(p []byte) (int, error) {
93
+ if !b.sent {
94
+ b.sent = true
95
+ p[0] = 'x'
96
+ return 1, nil
97
+ }
98
+ <-b.release
99
+ return 0, io.EOF
100
+}
101
+
102
+func (*stagedBody) Close() error { return nil }
103
+
104
+// Without buffering or a transport framing conflict, an unknown-length
105
+// incremental request is streamed unchanged.
106
+func TestIncrementalProxiedWhenRequestLengthUnknown(t *testing.T) {
107
+ var gotBody string
108
+ var gotLength int64
109
+ var gotIncremental string
110
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
111
+ b, _ := io.ReadAll(r.Body)
112
+ gotBody = string(b)
113
+ gotLength = r.ContentLength
114
+ gotIncremental = r.Header.Get("Incremental")
115
+ }))
116
+ defer backend.Close()
117
+
118
+ h := incrementalHandler(backend.Listener.Addr().String())
119
+
120
+ rec := httptest.NewRecorder()
121
+ if err := h.ServeHTTP(rec, incrementalRequest("hello", false), caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
122
+ return nil
123
+ })); err != nil {
124
+ t.Fatalf("ServeHTTP() error = %v", err)
125
+ }
126
+
127
+ if gotBody != "hello" {
128
+ t.Errorf("upstream body = %q, want %q", gotBody, "hello")
129
+ }
130
+ if gotLength != -1 {
131
+ t.Errorf("upstream ContentLength = %d, want -1", gotLength)
132
+ }
133
+ if gotIncremental != "?1" {
134
+ t.Errorf("upstream Incremental = %q, want ?1", gotIncremental)
135
+ }
136
+}
137
+
138
+// A transport that needs a CONTENT_LENGTH is the exception: only buffering the
139
+// body in full can supply one, which is what incremental forwarding rules out.
140
+func TestIncrementalRefusedWhenTransportRequiresContentLength(t *testing.T) {
141
+ for _, requestBuffers := range []int64{0, 4096} {
142
+ t.Run(fmt.Sprintf("buffers_%d", requestBuffers), func(t *testing.T) {
143
+ var reached bool
144
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
145
+ reached = true
146
+ }))
147
+ defer backend.Close()
148
+
149
+ h := incrementalHandler(backend.Listener.Addr().String())
150
+ h.RequestBuffers = requestBuffers
151
+ h.Transport = lengthRequiringTransport{testTransport{&http.Transport{}}}
152
+
153
+ rec := httptest.NewRecorder()
154
+ err := h.ServeHTTP(rec, incrementalRequest("hello", false), caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
155
+ return nil
156
+ }))
157
+
158
+ assertRefused(t, err, rec.Header())
159
+ if reached {
160
+ t.Error("request was forwarded upstream despite being refused")
161
+ }
162
+ })
163
+ }
164
+}
165
+
166
+// An unlimited buffer holds the entire body no matter its length, which RFC
167
+// 10036 section 3 forbids for a message marked incremental.
168
+func TestIncrementalRefusedWhenRequestBufferUnlimited(t *testing.T) {
169
+ var reached bool
170
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
171
+ reached = true
172
+ }))
173
+ defer backend.Close()
174
+
175
+ h := incrementalHandler(backend.Listener.Addr().String())
176
+ h.RequestBuffers = -1
177
+
178
+ rec := httptest.NewRecorder()
179
+ err := h.ServeHTTP(rec, incrementalRequest("hello", true), caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
180
+ return nil
181
+ }))
182
+
183
+ assertRefused(t, err, rec.Header())
184
+ if reached {
185
+ t.Error("request was forwarded upstream despite being refused")
186
+ }
187
+}
188
+
189
+// An explicit buffer is operator policy. An incremental request is refused
190
+// instead of allowing an external header to disable that policy.
191
+func TestIncrementalRefusedWhenRequestBufferConfigured(t *testing.T) {
192
+ h := incrementalHandler("unused.invalid")
193
+ h.RequestBuffers = 4096
194
+ rec := httptest.NewRecorder()
195
+ err := h.ServeHTTP(rec, incrementalRequest("hello", true), caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
196
+ return nil
197
+ }))
198
+ assertRefused(t, err, rec.Header())
199
+}
200
+
201
+// A transport-supplied buffer that exists only to discover Content-Length is
202
+// unnecessary when the request already has one and must not delay forwarding.
203
+func TestIncrementalKnownLengthRequestBypassesTransportDefault(t *testing.T) {
204
+ release := make(chan struct{})
205
+ reached := make(chan struct{})
206
+ h := incrementalHandler("unused.invalid")
207
+ h.RequestBuffers = 4096
208
+ h.requestBuffersFromTransport = true
209
+ h.Transport = lengthRequiringRoundTripFunc(func(req *http.Request) (*http.Response, error) {
210
+ close(reached)
211
+ return &http.Response{
212
+ StatusCode: http.StatusOK,
213
+ Status: "200 OK",
214
+ Header: make(http.Header),
215
+ Body: http.NoBody,
216
+ ContentLength: 0,
217
+ Request: req,
218
+ }, nil
219
+ })
220
+ req := httptest.NewRequest(http.MethodPost, "http://example.com/", &stagedBody{release: release})
221
+ req.ContentLength = 2
222
+ req.Header.Set("Content-Length", "2")
223
+ req.Header.Set("Incremental", "?1")
224
+ req = prepareTestRequest(req)
225
+
226
+ done := make(chan error, 1)
227
+ go func() {
228
+ done <- h.ServeHTTP(httptest.NewRecorder(), req, caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
229
+ return nil
230
+ }))
231
+ }()
232
+
233
+ select {
234
+ case <-reached:
235
+ case <-time.After(2 * time.Second):
236
+ t.Error("incremental request remained in the transport's default buffer")
237
+ }
238
+ close(release)
239
+ if err := <-done; err != nil {
240
+ t.Fatalf("ServeHTTP() error = %v", err)
241
+ }
242
+}
243
+
244
+// Without buffering configured there is nothing to refuse, so the request is
245
+// proxied as usual.
246
+func TestIncrementalProxiedWhenRequestNotBuffered(t *testing.T) {
247
+ var gotBody string
248
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
249
+ b, _ := io.ReadAll(r.Body)
250
+ gotBody = string(b)
251
+ }))
252
+ defer backend.Close()
253
+
254
+ h := incrementalHandler(backend.Listener.Addr().String())
255
+
256
+ rec := httptest.NewRecorder()
257
+ if err := h.ServeHTTP(rec, incrementalRequest("hello", false), caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
258
+ return nil
259
+ })); err != nil {
260
+ t.Fatalf("ServeHTTP() error = %v", err)
261
+ }
262
+
263
+ if gotBody != "hello" {
264
+ t.Errorf("upstream body = %q, want %q", gotBody, "hello")
265
+ }
266
+}
267
+
268
+// A bodyless request has no content to forward incrementally, so buffering it
269
+// is a no-op and there is nothing to refuse.
270
+func TestIncrementalProxiedWhenRequestHasNoBody(t *testing.T) {
271
+ var reached bool
272
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
273
+ reached = true
274
+ }))
275
+ defer backend.Close()
276
+
277
+ h := incrementalHandler(backend.Listener.Addr().String())
278
+ h.RequestBuffers = -1
279
+
280
+ req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
281
+ req.Header.Set("Incremental", "?1")
282
+ req = prepareTestRequest(req)
283
+
284
+ rec := httptest.NewRecorder()
285
+ if err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
286
+ return nil
287
+ })); err != nil {
288
+ t.Fatalf("ServeHTTP() error = %v", err)
289
+ }
290
+
291
+ if !reached {
292
+ t.Error("bodyless request was not forwarded upstream")
293
+ }
294
+}
295
+
296
+// A configured method rewrite can remove the request content before proxying.
297
+// Refusal is decided after that rewrite rather than from the original request.
298
+func TestIncrementalRewriteRemovesRequestContentBeforeRefusal(t *testing.T) {
299
+ var gotLength int64 = -1
300
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
301
+ gotLength = r.ContentLength
302
+ }))
303
+ defer backend.Close()
304
+
305
+ h := incrementalHandler(backend.Listener.Addr().String())
306
+ h.RequestBuffers = -1
307
+ h.Rewrite = &rewrite.Rewrite{Method: http.MethodGet}
308
+
309
+ rec := httptest.NewRecorder()
310
+ if err := h.ServeHTTP(rec, incrementalRequest("hello", true), caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
311
+ return nil
312
+ })); err != nil {
313
+ t.Fatalf("ServeHTTP() error = %v", err)
314
+ }
315
+ if gotLength != 0 {
316
+ t.Errorf("upstream ContentLength = %d, want 0", gotLength)
317
+ }
318
+}
319
+
320
+func TestIncrementalAddedByHeaderUpAfterBufferingIsRefused(t *testing.T) {
321
+ h := incrementalHandler("unused.invalid")
322
+ h.RequestBuffers = 4096
323
+ h.Headers = &headers.Handler{Request: &headers.HeaderOps{
324
+ Set: http.Header{"Incremental": []string{"?1"}},
325
+ }}
326
+ req := incrementalRequest("hello", true)
327
+ req.Header.Del("Incremental")
328
+
329
+ rec := httptest.NewRecorder()
330
+ err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
331
+ return nil
332
+ }))
333
+ assertRefused(t, err, rec.Header())
334
+}
335
+
336
+// serveIncremental runs an ordinary request against a backend whose response
337
+// is marked incremental. Request and response signals are independent.
338
+func serveIncremental(t *testing.T, responseBuffers int64, backendFn http.HandlerFunc) (*httptest.ResponseRecorder, error) {
339
+ t.Helper()
340
+
341
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
342
+ w.Header().Set("Incremental", "?1")
343
+ backendFn(w, r)
344
+ }))
345
+ t.Cleanup(backend.Close)
346
+
347
+ h := incrementalHandler(backend.Listener.Addr().String())
348
+ h.ResponseBuffers = responseBuffers
349
+
350
+ req := prepareTestRequest(httptest.NewRequest(http.MethodGet, "http://example.com/", nil))
351
+ rec := httptest.NewRecorder()
352
+ err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
353
+ return nil
354
+ }))
355
+ return rec, err
356
+}
357
+
358
+// An explicit response buffer is operator policy. An incremental response is
359
+// refused rather than allowing the upstream to disable that policy.
360
+func TestIncrementalRefusedWhenResponseBufferConfigured(t *testing.T) {
361
+ rec, err := serveIncremental(t, 4096, func(w http.ResponseWriter, r *http.Request) {
362
+ w.Header().Set("Content-Type", "text/event-stream")
363
+ w.WriteHeader(http.StatusOK)
364
+ w.(http.Flusher).Flush() // forces chunked, so the length stays unknown
365
+ _, _ = w.Write([]byte("data: hi\n\n"))
366
+ })
367
+ assertRefused(t, err, rec.Header())
368
+}
369
+
370
+// An unlimited buffer holds the entire response, whatever its length.
371
+func TestIncrementalRefusedWhenResponseBufferUnlimited(t *testing.T) {
372
+ rec, err := serveIncremental(t, -1, func(w http.ResponseWriter, r *http.Request) {
373
+ _, _ = w.Write([]byte("data: hi\n\n"))
374
+ })
375
+
376
+ assertRefused(t, err, rec.Header())
377
+}
378
+
379
+// A bodyless response has no content to forward incrementally, so there is
380
+// nothing to refuse even with buffering configured.
381
+func TestIncrementalProxiedWhenResponseHasNoBody(t *testing.T) {
382
+ rec, err := serveIncremental(t, -1, func(w http.ResponseWriter, r *http.Request) {
383
+ w.WriteHeader(http.StatusOK)
384
+ })
385
+ if err != nil {
386
+ t.Fatalf("ServeHTTP() error = %v", err)
387
+ }
388
+
389
+ if rec.Code != http.StatusOK {
390
+ t.Errorf("status = %d, want 200", rec.Code)
391
+ }
392
+}
393
+
394
+// The upstream response is passed through untouched when no response
395
+// buffering is configured.
396
+func TestIncrementalProxiedWhenResponseNotBuffered(t *testing.T) {
397
+ rec, err := serveIncremental(t, 0, func(w http.ResponseWriter, r *http.Request) {
398
+ w.Header().Set("Content-Type", "text/event-stream")
399
+ w.WriteHeader(http.StatusOK)
400
+ w.(http.Flusher).Flush()
401
+ _, _ = w.Write([]byte("data: hi\n\n"))
402
+ })
403
+ if err != nil {
404
+ t.Fatalf("ServeHTTP() error = %v", err)
405
+ }
406
+
407
+ if rec.Code != http.StatusOK {
408
+ t.Errorf("status = %d, want 200", rec.Code)
409
+ }
410
+ if got := rec.Body.String(); got != "data: hi\n\n" {
411
+ t.Errorf("body = %q, want %q", got, "data: hi\n\n")
412
+ }
413
+ if got := rec.Header().Get("Incremental"); got != "?1" {
414
+ t.Errorf("downstream Incremental = %q, want ?1", got)
415
+ }
416
+}
417
+
418
+// Replacing an upstream response does not forward or buffer its body. The
419
+// replacement remains valid even when that unused response was incremental.
420
+func TestIncrementalResponseCanBeReplaced(t *testing.T) {
421
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
422
+ w.Header().Set("Incremental", "?1")
423
+ _, _ = w.Write([]byte("upstream"))
424
+ }))
425
+ defer backend.Close()
426
+
427
+ h := incrementalHandler(backend.Listener.Addr().String())
428
+ h.ResponseBuffers = -1
429
+ h.HandleResponse = []caddyhttp.ResponseHandler{{}}
430
+ req := prepareTestRequest(httptest.NewRequest(http.MethodGet, "http://example.com/", nil))
431
+ rec := httptest.NewRecorder()
432
+ err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
433
+ _, writeErr := w.Write([]byte("replacement"))
434
+ return writeErr
435
+ }))
436
+ if err != nil {
437
+ t.Fatalf("ServeHTTP() error = %v", err)
438
+ }
439
+ if got := rec.Body.String(); got != "replacement" {
440
+ t.Errorf("body = %q, want %q", got, "replacement")
441
+ }
442
+}
443
+
444
+func TestIncrementalAddedByHeaderDownAfterBufferingIsRefused(t *testing.T) {
445
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
446
+ _, _ = w.Write([]byte("hello"))
447
+ }))
448
+ defer backend.Close()
449
+
450
+ h := incrementalHandler(backend.Listener.Addr().String())
451
+ h.ResponseBuffers = 4096
452
+ h.Headers = &headers.Handler{Response: &headers.RespHeaderOps{HeaderOps: &headers.HeaderOps{
453
+ Set: http.Header{"Incremental": []string{"?1"}},
454
+ }}}
455
+ req := prepareTestRequest(httptest.NewRequest(http.MethodGet, "http://example.com/", nil))
456
+ rec := httptest.NewRecorder()
457
+ err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
458
+ return nil
459
+ }))
460
+ assertRefused(t, err, rec.Header())
461
+}
462
+
463
+// A software name identifies no deployment, so with nothing configured to
464
+// identify this proxy there is no Proxy-Status field to generate: RFC 9209
465
+// leaves generating it optional.
466
+func TestProxyStatusOmittedWhenUnnamed(t *testing.T) {
467
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
468
+ defer backend.Close()
469
+
470
+ h := incrementalHandler(backend.Listener.Addr().String())
471
+ h.ProxyStatusName = ""
472
+ h.RequestBuffers = -1
473
+
474
+ rec := httptest.NewRecorder()
475
+ err := h.ServeHTTP(rec, incrementalRequest("hello", true), caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
476
+ return nil
477
+ }))
478
+
479
+ var handlerErr caddyhttp.HandlerError
480
+ if !errors.As(err, &handlerErr) || handlerErr.StatusCode != http.StatusNotImplemented {
481
+ t.Fatalf("expected a 501 HandlerError, got %v", err)
482
+ }
483
+ if got, ok := rec.Header()["Proxy-Status"]; ok {
484
+ t.Errorf("Proxy-Status = %q, want no field", got)
485
+ }
486
+}
487
+
488
+// RFC 9209 section 2 asks intermediaries to preserve the members already in
489
+// the field, so the whole chain that handled the response stays visible.
490
+func TestProxyStatusPreservesUpstreamMembers(t *testing.T) {
491
+ rec, err := serveIncremental(t, -1, func(w http.ResponseWriter, r *http.Request) {
492
+ w.Header().Set("Proxy-Status", "ExampleCDN")
493
+ _, _ = w.Write([]byte("data: hi\n\n"))
494
+ })
495
+
496
+ var handlerErr caddyhttp.HandlerError
497
+ if !errors.As(err, &handlerErr) || handlerErr.StatusCode != http.StatusNotImplemented {
498
+ t.Fatalf("expected a 501 HandlerError, got %v", err)
499
+ }
500
+
501
+ want := "ExampleCDN, " + testProxyStatusName + ";error=" + proxyErrorIncrementalRefused
502
+ if got := rec.Header().Get("Proxy-Status"); got != want {
503
+ t.Errorf("Proxy-Status = %q, want %q", got, want)
504
+ }
505
+}
506
+
507
+func TestProxyStatusDropsInvalidMembers(t *testing.T) {
508
+ h := &Handler{ProxyStatusName: testProxyStatusName}
509
+ upstream := http.Header{
510
+ "Proxy-Status": []string{"?1, (inner), upstream.example"},
511
+ }
512
+
513
+ got, ok := h.proxyStatus(upstream, proxyErrorIncrementalRefused)
514
+ if !ok {
515
+ t.Fatal("proxyStatus() did not produce a value")
516
+ }
517
+ want := testProxyStatusName + ";error=" + proxyErrorIncrementalRefused
518
+ if got != want {
519
+ t.Errorf("Proxy-Status = %q, want %q", got, want)
520
+ }
521
+}
522
+
523
+// A name a token cannot hold is quoted as a string instead, which RFC 9209
524
+// allows just as well.
525
+func TestProxyStatusNameNeedingQuotes(t *testing.T) {
526
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
527
+ defer backend.Close()
528
+
529
+ h := incrementalHandler(backend.Listener.Addr().String())
530
+ h.ProxyStatusName = "Example CDN"
531
+ h.RequestBuffers = -1
532
+
533
+ rec := httptest.NewRecorder()
534
+ _ = h.ServeHTTP(rec, incrementalRequest("hello", true), caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
535
+ return nil
536
+ }))
537
+
538
+ want := `"Example CDN";error=` + proxyErrorIncrementalRefused
539
+ if got := rec.Header().Get("Proxy-Status"); got != want {
540
+ t.Errorf("Proxy-Status = %q, want %q", got, want)
541
+ }
542
+}
543
+
544
+// HTTP/3 reports an unknown length for bodyless GET and HEAD requests. The
545
+// transport still needs to receive these requests rather than a 501 refusal.
546
+func TestIncrementalProxiedWhenBodylessRequestHasUnknownLength(t *testing.T) {
547
+ var reached bool
548
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
549
+ reached = true
550
+ }))
551
+ defer backend.Close()
552
+
553
+ h := incrementalHandler(backend.Listener.Addr().String())
554
+ h.RequestBuffers = 4096
555
+ h.Transport = lengthRequiringTransport{testTransport{&http.Transport{}}}
556
+
557
+ req := httptest.NewRequest(http.MethodGet, "http://example.com/", io.NopCloser(strings.NewReader("")))
558
+ req.Header.Set("Incremental", "?1")
559
+ req = prepareTestRequest(req)
560
+ if req.ContentLength != -1 {
561
+ t.Fatalf("ContentLength = %d, want -1", req.ContentLength)
562
+ }
563
+
564
+ rec := httptest.NewRecorder()
565
+ if err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
566
+ return nil
567
+ })); err != nil {
568
+ t.Fatalf("ServeHTTP() error = %v", err)
569
+ }
570
+
571
+ if !reached {
572
+ t.Error("bodyless request was not forwarded upstream")
573
+ }
574
+}
575
+
576
+func TestIncrementalAddedByHeaderUpWhenBodylessRequestHasUnknownLength(t *testing.T) {
577
+ h := incrementalHandler("unused.invalid")
578
+ h.Headers = &headers.Handler{Request: &headers.HeaderOps{
579
+ Set: http.Header{"Incremental": []string{"?1"}},
580
+ }}
581
+ h.Transport = lengthRequiringRoundTripFunc(func(req *http.Request) (*http.Response, error) {
582
+ if req.ContentLength != 0 {
583
+ t.Errorf("ContentLength = %d, want 0", req.ContentLength)
584
+ }
585
+ if req.Body != nil {
586
+ t.Errorf("Body = %T, want nil", req.Body)
587
+ }
588
+ return &http.Response{
589
+ StatusCode: http.StatusOK,
590
+ Status: "200 OK",
591
+ Header: make(http.Header),
592
+ Body: http.NoBody,
593
+ ContentLength: 0,
594
+ Request: req,
595
+ }, nil
596
+ })
597
+
598
+ req := httptest.NewRequest(http.MethodGet, "http://example.com/", io.NopCloser(strings.NewReader("")))
599
+ req = prepareTestRequest(req)
600
+ if req.ContentLength != -1 {
601
+ t.Fatalf("ContentLength = %d, want -1", req.ContentLength)
602
+ }
603
+
604
+ rec := httptest.NewRecorder()
605
+ if err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
606
+ return nil
607
+ })); err != nil {
608
+ t.Fatalf("ServeHTTP() error = %v", err)
609
+ }
610
+}
611
+
612
+func TestIncrementalBodylessUnknownLengthIsNormalised(t *testing.T) {
613
+ h := Handler{Transport: lengthRequiringTransport{testTransport{&http.Transport{}}}}
614
+ req := httptest.NewRequest(http.MethodGet, "http://example.com/", io.NopCloser(strings.NewReader("")))
615
+ req.Header.Set("Incremental", "?1")
616
+ repl := caddy.NewReplacer()
617
+ req = caddyhttp.PrepareRequest(req, repl, nil, &caddyhttp.Server{})
618
+
619
+ got, err := h.prepareRequest(req, repl)
620
+ if err != nil {
621
+ t.Fatalf("prepareRequest() error = %v", err)
622
+ }
623
+ if got.ContentLength != 0 {
624
+ t.Errorf("ContentLength = %d, want 0", got.ContentLength)
625
+ }
626
+ if got.Body != nil {
627
+ t.Errorf("Body = %T, want nil", got.Body)
628
+ }
629
+}
630
+
631
+// A response to HEAD advertises the length the content would have had, while
632
+// carrying no content at all, so there is nothing for a buffer to hold back.
633
+func TestIncrementalProxiedWhenHeadResponseAdvertisesLength(t *testing.T) {
634
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
635
+ w.Header().Set("Incremental", "?1")
636
+ w.Header().Set("Content-Length", "10000")
637
+ w.WriteHeader(http.StatusOK)
638
+ }))
639
+ defer backend.Close()
640
+
641
+ h := incrementalHandler(backend.Listener.Addr().String())
642
+ h.ResponseBuffers = -1
643
+
644
+ req := prepareTestRequest(httptest.NewRequest(http.MethodHead, "http://example.com/", nil))
645
+ rec := httptest.NewRecorder()
646
+ if err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
647
+ return nil
648
+ })); err != nil {
649
+ t.Fatalf("ServeHTTP() error = %v", err)
650
+ }
651
+
652
+ if rec.Code != http.StatusOK {
653
+ t.Errorf("status = %d, want 200", rec.Code)
654
+ }
655
+}
656
+
657
+func TestResponseHasContent(t *testing.T) {
658
+ for _, tc := range []struct {
659
+ name string
660
+ method string
661
+ status int
662
+ length int64
663
+ body io.ReadCloser
664
+ want bool
665
+ }{
666
+ {name: "unknown response", method: http.MethodGet, status: http.StatusOK, length: -1, body: io.NopCloser(strings.NewReader("x")), want: true},
667
+ {name: "HEAD", method: http.MethodHead, status: http.StatusOK, length: 10, body: io.NopCloser(strings.NewReader(""))},
668
+ {name: "informational", method: http.MethodGet, status: http.StatusEarlyHints, length: -1, body: io.NopCloser(strings.NewReader(""))},
669
+ {name: "no content", method: http.MethodGet, status: http.StatusNoContent, length: -1, body: io.NopCloser(strings.NewReader(""))},
670
+ {name: "reset content", method: http.MethodGet, status: http.StatusResetContent, length: -1, body: io.NopCloser(strings.NewReader(""))},
671
+ {name: "not modified", method: http.MethodGet, status: http.StatusNotModified, length: -1, body: io.NopCloser(strings.NewReader(""))},
672
+ {name: "successful CONNECT", method: http.MethodConnect, status: http.StatusOK, length: -1, body: io.NopCloser(strings.NewReader(""))},
673
+ {name: "failed CONNECT", method: http.MethodConnect, status: http.StatusBadGateway, length: -1, body: io.NopCloser(strings.NewReader("x")), want: true},
674
+ {name: "zero length", method: http.MethodGet, status: http.StatusOK, length: 0, body: io.NopCloser(strings.NewReader(""))},
675
+ {name: "NoBody", method: http.MethodGet, status: http.StatusOK, length: -1, body: http.NoBody},
676
+ } {
677
+ t.Run(tc.name, func(t *testing.T) {
678
+ req := httptest.NewRequest(tc.method, "http://example.com/", nil)
679
+ res := &http.Response{StatusCode: tc.status, ContentLength: tc.length, Body: tc.body}
680
+ if got := responseHasContent(req, res); got != tc.want {
681
+ t.Errorf("responseHasContent() = %v, want %v", got, tc.want)
682
+ }
683
+ })
684
+ }
685
+}
modules/caddyhttp/reverseproxy/reverseproxy.go
+202
-7
@@ -35,6 +35,7 @@ import (
35
"sync/atomic"
36
"time"
37
38
+ "github.com/dunglas/httpsfv"
39
"go.uber.org/zap"
40
"go.uber.org/zap/zapcore"
41
"golang.org/x/net/http/httpguts"
@@ -158,15 +159,27 @@ type Handler struct {
159
// and buffered in memory before being proxied to the backend. This
160
// should be avoided if at all possible for performance reasons, but
161
// could be useful if the backend is intolerant of read latency or
161
- // chunked encodings.
162
+ // chunked encodings. An incremental request with an explicitly configured
163
+ // buffer receives a 501 response. A transport-supplied default is bypassed
164
+ // only when the request already meets the transport's framing requirements.
165
RequestBuffers int64 `json:"request_buffers,omitempty"`
166
167
// If nonzero, the entire response body up to this size will be read
168
// and buffered in memory before being proxied to the client. This
169
// should be avoided if at all possible for performance reasons, but
167
- // could be useful if the backend has tighter memory constraints.
170
+ // could be useful if the backend has tighter memory constraints. Forwarding
171
+ // an incremental response with content produces a 501 response whenever
172
+ // buffering is configured.
173
ResponseBuffers int64 `json:"response_buffers,omitempty"`
174
175
+ // The name identifying this proxy in the Proxy-Status response header
176
+ // field (RFC 9209). It has to identify the deployment rather than the
177
+ // software, so there is no sensible default: a service name
178
+ // ("ExampleCDN"), a hostname ("proxy-3.example.com") or an IP address
179
+ // are all appropriate. This is used when Caddy refuses to forward a
180
+ // message marked Incremental. When empty, no Proxy-Status field is generated.
181
+ ProxyStatusName string `json:"proxy_status_name,omitempty"`
182
+
183
// If nonzero, streaming requests such as WebSockets will be
184
// forcibly closed at the end of the timeout. Default: no timeout.
185
StreamTimeout caddy.Duration `json:"stream_timeout,omitempty"`
@@ -231,6 +244,11 @@ type Handler struct {
244
// user can override transport defaults.
245
transportHeaderOps *headers.HeaderOps
246
247
+ // requestBuffersFromTransport distinguishes a transport default from an
248
+ // operator-configured buffer. Incremental forwarding may bypass the former
249
+ // only when the request already meets the transport's framing requirements.
250
+ requestBuffersFromTransport bool
251
+
252
// Holds the parsed CIDR ranges from TrustedProxies
253
trustedProxies []netip.Prefix
254
@@ -288,6 +306,7 @@ func (h *Handler) Provision(ctx caddy.Context) error {
306
reqBuffers, respBuffers := bt.DefaultBufferSizes()
307
if h.RequestBuffers == 0 {
308
h.RequestBuffers = reqBuffers
309
+ h.requestBuffersFromTransport = reqBuffers != 0
310
}
311
if h.ResponseBuffers == 0 {
312
h.ResponseBuffers = respBuffers
@@ -488,6 +507,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht
507
// prepare the request for proxying; this is needed only once
508
clonedReq, err := h.prepareRequest(r, repl)
509
if err != nil {
510
+ if errors.Is(err, errIncrementalRefused) {
511
+ return h.refuseIncremental(w, nil)
512
+ }
513
return caddyhttp.Error(http.StatusInternalServerError,
514
fmt.Errorf("preparing request for upstream round-trip: %v", err))
515
}
@@ -520,6 +542,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht
542
// their values
543
reqHost := clonedReq.Host
544
reqHeader := clonedReq.Header
545
+ requestWasIncremental := caddyhttp.IsIncremental(reqHeader)
546
547
// If the request contained a body, wrap it in io.NopCloser
548
// to prevent Go's transport from closing it on dial errors.
@@ -570,7 +593,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht
593
}
594
595
var done bool
573
- done, proxyErr = h.proxyLoopIteration(clonedReq, r, w, proxyErr, start, retries, repl, reqHeader, reqHost, next)
596
+ done, proxyErr = h.proxyLoopIteration(clonedReq, r, w, proxyErr, start, retries, repl, reqHeader, reqHost, requestWasIncremental, next)
597
if done {
598
break
599
}
@@ -601,7 +624,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht
624
// It returns true when the loop is done and should break; false otherwise. The error value returned should
625
// be assigned to the proxyErr value for the next iteration of the loop (or the error handled after break).
626
func (h *Handler) proxyLoopIteration(r *http.Request, origReq *http.Request, w http.ResponseWriter, proxyErr error, start time.Time, retries int,
604
- repl *caddy.Replacer, reqHeader http.Header, reqHost string, next caddyhttp.Handler,
627
+ repl *caddy.Replacer, reqHeader http.Header, reqHost string, requestWasIncremental bool, next caddyhttp.Handler,
628
) (bool, error) {
629
// get the updated list of upstreams
630
upstreams := h.Upstreams
@@ -691,6 +714,11 @@ func (h *Handler) proxyLoopIteration(r *http.Request, origReq *http.Request, w h
714
userOps.ApplyToRequest(r)
715
}
716
}
717
+ if !requestWasIncremental && requestHasContent(r) && caddyhttp.IsIncremental(r.Header) &&
718
+ (h.RequestBuffers != 0 || r.ContentLength < 0 && h.transportRequiresContentLength()) {
719
+ return true, h.refuseIncremental(w, nil)
720
+ }
721
+ normalizeIncrementalRequest(r)
722
723
// normalize websocket headers for compatibility with older servers
724
if upgradeType(r.Header) != "" {
@@ -779,6 +807,14 @@ func (h Handler) prepareRequest(req *http.Request, repl *caddy.Replacer) (*http.
807
}
808
}
809
810
+ // Fully buffering the request is incompatible with incremental forwarding.
811
+ // A negative length is treated as potential content because determining
812
+ // whether it is empty would itself require waiting for the stream to end.
813
+ if h.incrementalRequestConflicts(req) {
814
+ return nil, errIncrementalRefused
815
+ }
816
+ normalizeIncrementalRequest(req)
817
+
818
// if enabled, buffer client request; this should only be
819
// enabled if the upstream requires it and does not work
820
// with "slow clients" (gunicorn, etc.) - this obviously
@@ -787,7 +823,7 @@ func (h Handler) prepareRequest(req *http.Request, repl *caddy.Replacer) (*http.
823
// attacks, so it is strongly recommended to only use this
824
// feature if absolutely required, if read timeouts are
825
// set, and if body size is limited
790
- if h.RequestBuffers != 0 && req.Body != nil {
826
+ if h.RequestBuffers != 0 && req.Body != nil && !caddyhttp.IsIncremental(req.Header) {
827
var readBytes int64
828
req.Body, readBytes = h.bufferedBody(req.Body, h.RequestBuffers)
829
// set Content-Length when body is fully buffered
@@ -1110,7 +1146,7 @@ func (h *Handler) reverseProxy(rw http.ResponseWriter, req *http.Request, origRe
1146
}
1147
1148
// if enabled, buffer the response body
1113
- if h.ResponseBuffers != 0 {
1149
+ if h.ResponseBuffers != 0 && !caddyhttp.IsIncremental(res.Header) {
1150
res.Body, _ = h.bufferedBody(res.Body, h.ResponseBuffers)
1151
}
1152
@@ -1227,6 +1263,8 @@ func (h *Handler) finalizeResponse(
1263
start time.Time,
1264
logger *zap.Logger,
1265
) error {
1266
+ responseWasIncremental := caddyhttp.IsIncremental(res.Header)
1267
+
1268
// Strip hop-by-hop headers from the upstream response.
1269
// For 101 Switching Protocols, save the Upgrade value
1270
// first (handleUpgradeResponse needs it for validation)
@@ -1266,6 +1304,11 @@ func (h *Handler) finalizeResponse(
1304
h.Headers.Response.ApplyTo(res.Header, repl)
1305
}
1306
}
1307
+ if responseHasContent(req, res) && h.ResponseBuffers != 0 &&
1308
+ (responseWasIncremental || caddyhttp.IsIncremental(res.Header)) {
1309
+ res.Body.Close()
1310
+ return roundtripSucceededError{h.refuseIncremental(rw, res.Header)}
1311
+ }
1312
1313
copyHeader(rw.Header(), res.Header)
1314
@@ -1285,7 +1328,7 @@ func (h *Handler) finalizeResponse(
1328
logger.Debug("wrote header")
1329
}
1330
1288
- err := h.copyResponse(rw, res.Body, h.flushInterval(req, res), logger)
1331
+ err := h.copyResponse(rw, res.Body, h.flushInterval(req, res, responseWasIncremental), logger)
1332
errClose := res.Body.Close() // close now, instead of defer, to populate res.Trailer
1333
if h.VerboseLogs || errClose != nil {
1334
if c := logger.Check(zapcore.DebugLevel, "closed response body from upstream"); c != nil {
@@ -1784,6 +1827,158 @@ type HealthCheckSchemeOverriderTransport interface {
1827
OverrideHealthCheckScheme(base *url.URL, port string)
1828
}
1829
1830
+// proxyErrorIncrementalRefused is the Proxy-Status error type (RFC 9209) for
1831
+// a message we refuse to forward incrementally, as registered by RFC 10036
1832
+// section 5.
1833
+const proxyErrorIncrementalRefused = "incremental_refused"
1834
+
1835
+// errIncrementalRefused is the cause reported when a message asking for
1836
+// incremental forwarding cannot be forwarded that way.
1837
+var errIncrementalRefused = errors.New("refusing to forward the message incrementally")
1838
+
1839
+// refuseIncremental reports that a message asking for incremental forwarding
1840
+// cannot be forwarded that way without overriding configured buffering or the
1841
+// transport's framing requirements. RFC 10036 section 3 requires an error
1842
+// response when incremental forwarding is refused. Section 4.1 recommends this
1843
+// status and Proxy-Status error type. Upstream carries the headers of the
1844
+// response being refused when present. This preserves its valid existing
1845
+// members.
1846
+func (h *Handler) refuseIncremental(rw http.ResponseWriter, upstream http.Header) error {
1847
+ if value, ok := h.proxyStatus(upstream, proxyErrorIncrementalRefused); ok {
1848
+ rw.Header().Set("Proxy-Status", value)
1849
+ }
1850
+
1851
+ return caddyhttp.Error(http.StatusNotImplemented, errIncrementalRefused)
1852
+}
1853
+
1854
+// proxyStatus builds a Proxy-Status field value (RFC 9209) reporting proxyErr,
1855
+// appending this proxy to the members upstream already reported so the whole
1856
+// chain stays visible. It reports false if no name identifies this deployment,
1857
+// since a software name identifies no intermediary and generating the field at
1858
+// all is optional.
1859
+func (h *Handler) proxyStatus(upstream http.Header, proxyErr string) (string, bool) {
1860
+ if h.ProxyStatusName == "" {
1861
+ return "", false
1862
+ }
1863
+
1864
+ // a malformed field from upstream is dropped rather than propagated
1865
+ list, err := httpsfv.UnmarshalList(upstream.Values("Proxy-Status"))
1866
+ if err != nil {
1867
+ list = httpsfv.List{}
1868
+ }
1869
+ for _, member := range list {
1870
+ item, ok := member.(httpsfv.Item)
1871
+ if !ok {
1872
+ list = httpsfv.List{}
1873
+ break
1874
+ }
1875
+ valid := false
1876
+ switch item.Value.(type) {
1877
+ case string, httpsfv.Token:
1878
+ valid = true
1879
+ }
1880
+ if !valid {
1881
+ list = httpsfv.List{}
1882
+ break
1883
+ }
1884
+ }
1885
+
1886
+ // the name is a token where it can be, as the examples in RFC 9209 are,
1887
+ // and a string where a token cannot hold it (a space, say)
1888
+ item := httpsfv.NewItem(httpsfv.Token(h.ProxyStatusName))
1889
+ item.Params.Add("error", httpsfv.Token(proxyErr))
1890
+ if _, err := httpsfv.Marshal(item); err != nil {
1891
+ item.Value = h.ProxyStatusName
1892
+ }
1893
+
1894
+ value, err := httpsfv.Marshal(append(list, item))
1895
+ if err != nil {
1896
+ return "", false
1897
+ }
1898
+
1899
+ return value, true
1900
+}
1901
+
1902
+// responseHasContent reports whether res can carry content for req. This uses
1903
+// the response semantics as well as Body because custom transports are not
1904
+// required to represent an empty body with http.NoBody.
1905
+func responseHasContent(req *http.Request, res *http.Response) bool {
1906
+ if req.Method == http.MethodHead || res.Body == nil || res.Body == http.NoBody || res.ContentLength == 0 {
1907
+ return false
1908
+ }
1909
+ if req.Method == http.MethodConnect && res.StatusCode >= 200 && res.StatusCode < 300 {
1910
+ return false
1911
+ }
1912
+ if res.StatusCode >= 100 && res.StatusCode < 200 ||
1913
+ res.StatusCode == http.StatusNoContent || res.StatusCode == http.StatusResetContent ||
1914
+ res.StatusCode == http.StatusNotModified {
1915
+ return false
1916
+ }
1917
+ return true
1918
+}
1919
+
1920
+// requestHasContent reports whether r may carry content that matters for
1921
+// incremental forwarding. HTTP/3 uses an unknown length for bodyless GET and
1922
+// HEAD requests (see issue #6678), so their usual no-body semantics are used
1923
+// rather than waiting for the request stream to end.
1924
+func requestHasContent(r *http.Request) bool {
1925
+ if r.ContentLength > 0 {
1926
+ return true
1927
+ }
1928
+ if r.ContentLength == 0 {
1929
+ return false
1930
+ }
1931
+
1932
+ switch r.Method {
1933
+ case http.MethodGet, http.MethodHead, http.MethodOptions,
1934
+ http.MethodDelete, http.MethodTrace, http.MethodConnect:
1935
+ return false
1936
+ }
1937
+
1938
+ return true
1939
+}
1940
+
1941
+func normalizeIncrementalRequest(req *http.Request) {
1942
+ if caddyhttp.IsIncremental(req.Header) && !requestHasContent(req) {
1943
+ // HTTP/3 can represent a bodyless GET or HEAD with an unknown length.
1944
+ // Do not pass that sentinel to transports that require framing.
1945
+ req.ContentLength = 0
1946
+ req.Body = nil
1947
+ }
1948
+}
1949
+
1950
+// incrementalRequestConflicts reports whether honouring an incremental request
1951
+// would override an explicit buffer or violate the transport's framing needs.
1952
+func (h *Handler) incrementalRequestConflicts(req *http.Request) bool {
1953
+ if !requestHasContent(req) || !caddyhttp.IsIncremental(req.Header) {
1954
+ return false
1955
+ }
1956
+ needsLength := h.transportRequiresContentLength()
1957
+ if h.RequestBuffers != 0 {
1958
+ if !h.requestBuffersFromTransport || !needsLength {
1959
+ return true
1960
+ }
1961
+ }
1962
+ return req.ContentLength < 0 && needsLength
1963
+}
1964
+
1965
+// ContentLengthRequiredTransport is implemented by transports that cannot
1966
+// forward a request body whose length they do not know, and so need it
1967
+// buffered in full when the request does not carry a Content-Length.
1968
+type ContentLengthRequiredTransport interface {
1969
+ // RequiresContentLength returns true if the transport needs the length of
1970
+ // a request body before it can forward it.
1971
+ RequiresContentLength() bool
1972
+}
1973
+
1974
+// transportRequiresContentLength reports whether the transport in use cannot
1975
+// forward a request body of unknown length.
1976
+func (h *Handler) transportRequiresContentLength() bool {
1977
+ clt, ok := h.Transport.(ContentLengthRequiredTransport)
1978
+
1979
+ return ok && clt.RequiresContentLength()
1980
+}
1981
+
1982
// BufferedTransport is implemented by transports
1983
// that needs to buffer requests and/or responses.
1984
type BufferedTransport interface {
modules/caddyhttp/reverseproxy/streaming.go
+8
-2
@@ -268,14 +268,20 @@ func (h *Handler) handleUpgradeResponse(logger *zap.Logger, wg *sync.WaitGroup,
268
269
// flushInterval returns the p.FlushInterval value, conditionally
270
// overriding its value for a specific request/response.
271
-func (h Handler) flushInterval(req *http.Request, res *http.Response) time.Duration {
271
+func (h Handler) flushInterval(req *http.Request, res *http.Response, incremental bool) time.Duration {
272
+ // The upstream asked for the response to be forwarded incrementally.
273
+ // See RFC 10036: https://www.rfc-editor.org/rfc/rfc10036.html
274
+ if incremental || caddyhttp.IsIncremental(res.Header) {
275
+ return -1 // negative means immediately
276
+ }
277
+
278
resCTHeader := res.Header.Get("Content-Type")
279
resCT, _, err := mime.ParseMediaType(resCTHeader)
280
281
// For Server-Sent Events responses, flush immediately.
282
// The MIME type is defined in https://www.w3.org/TR/eventsource/#text-event-stream
283
if err == nil && resCT == "text/event-stream" {
278
- return -1 // negative means immediately
284
+ return -1
285
}
286
287
// We might have the case of streaming for which Content-Length might be unset.
modules/caddyhttp/reverseproxy/streaming_test.go
+32
@@ -362,3 +362,35 @@ func TestHandlerUpgradedStreamHalfClose(t *testing.T) {
362
})
363
}
364
}
365
+
366
+// A response carrying the Incremental header field (RFC 10036) must be
367
+// forwarded without buffering, whatever its Content-Type and Content-Length.
368
+func TestFlushIntervalIncremental(t *testing.T) {
369
+ h := Handler{FlushInterval: caddy.Duration(time.Second)}
370
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
371
+
372
+ for _, tc := range []struct {
373
+ name string
374
+ incremental string
375
+ receivedIncremental bool
376
+ want time.Duration
377
+ }{
378
+ {name: "incremental", incremental: "?1", want: -1},
379
+ {name: "not incremental", incremental: "?0", want: time.Second},
380
+ {name: "absent", want: time.Second},
381
+ {name: "removed by header operations", receivedIncremental: true, want: -1},
382
+ } {
383
+ t.Run(tc.name, func(t *testing.T) {
384
+ res := &http.Response{
385
+ Header: http.Header{"Content-Type": []string{"application/json"}},
386
+ ContentLength: 42,
387
+ }
388
+ if tc.incremental != "" {
389
+ res.Header.Set("Incremental", tc.incremental)
390
+ }
391
+ if got := h.flushInterval(req, res, tc.receivedIncremental); got != tc.want {
392
+ t.Errorf("flushInterval() = %v, want %v", got, tc.want)
393
+ }
394
+ })
395
+ }
396
+}