@marcel91200 / caddy Mirror · read-only / commits / 2f34b936
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 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 +}