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

caddyhttp: mitigate slowloris via idle read/write deadlines (#7913)

* caddyhttp: mitigate slowloris via idle read/write deadlines ReadTimeout and WriteTimeout previously applied as a single hard deadline over the whole body/response through http.Server, so any non-zero value also killed large transfers from legitimately slow clients. Reset the deadline on every successful read/write instead (via http.ResponseController), and give both a sane 1m default now that doing so no longer penalizes slow-but-progressing clients. * caddyhttp: split idle read/write timeouts from the existing hard ones Reworking ReadTimeout/WriteTimeout's own semantics was an unwanted behavior change for existing configs relying on the hard deadline. Leave them untouched and add ReadIdleTimeout/WriteIdleTimeout instead, reset on every successful read/write; both default to 1m since, being new, no existing config could have depended on a different value. Combining an idle timeout with its hard counterpart now gives the same base+ceiling shape as Apache's mod_reqtimeout, for free. * caddyhttp: cap idle-reset deadlines at the hard timeout ceiling Deadlines are a single absolute value on the connection, not a min of several: ReadTimeout/WriteTimeout's own hard deadline, set once by net/http before the handler runs, was silently getting overwritten by the first idle-reset Read/Write, voiding it entirely. Clamp the idle-reset deadline to the hard one when both are set, so combining them actually behaves like the advertised base+ceiling. * caddyhttp: add ReadMinRate/WriteMinRate, Apache MinRate equivalent Pure idle-reset alone doesn't bound a trickle that sends just enough to never go idle. ReadMinRate/WriteMinRate (bytes/second) grow the allowed deadline from a fixed start based on bytes transferred so far instead of resetting to a flat window on every call, so a transfer that doesn't sustain the configured rate falls behind real time and gets cut, matching Apache mod_reqtimeout's MinRate. Zero (default) keeps the existing flat idle-reset behavior unchanged. * caddyhttp: use named return and consistent blank lines in idleDeadline Matches the named-return style already used by ResponseWriterWrapper.ReadFrom. * caddyhttp: chunk idleTimeoutWriter's Write/ReadFrom, cap at 64 KiB SetWriteDeadline bounds the whole call it precedes, not just a stall within it. net.Conn.Write loops internally until a buffer is fully sent (unlike Read, which returns after one syscall), and ResponseWriter.ReadFrom hands the entire remaining source to the connection in one call. A single large Write, or any body copied via io.Copy triggering the ReadFrom fast path (http.ServeContent, static file serving), had its whole transfer bounded by one deadline, silently truncating a slow-but-healthy transfer exactly like a hard WriteTimeout would - the same bug found and fixed the same way in FrankenPHP's go_ub_write (php/frankenphp#2574). Cap each underlying call at 64 KiB and reset the deadline between chunks instead. net/sendfile.go special-cases *io.LimitedReader, so chunking ReadFrom still uses the sendfile fast path per chunk. * caddyhttp: export idle-timeout types, add configurable MaxWriteChunk Export IdleTimeoutReader/IdleTimeoutWriter/IdleDeadline so other packages (request_body next) can reuse the same idle-reset mechanism instead of reimplementing it, and turn the hardcoded 64 KiB write chunk size into a configurable MaxWriteChunk field defaulting to the same value - nginx's sendfile_max_chunk exists for the identical reason and is admin-tunable rather than fixed. * requestbody: idle-reset ReadTimeout/WriteTimeout, add MinRate/MaxWriteChunk ReadTimeout/WriteTimeout set a single deadline once, so any transfer running longer than the timeout got cut regardless of whether it was actually stalled - the same bug the server-wide timeouts had before switching to idle-reset. Reuse caddyhttp.IdleTimeoutReader/Writer here too, giving per-route granularity nginx/Apache have via location/ directory scoping and Caddy's server-wide timeouts don't: a route matching this handler can now set its own idle window independently from the rest of the server block. * caddyhttp: fold read/write min_rate into the idle-timeout directive Two directives per rate (read_body_idle + read_body_min_rate) for a value that's meaningless without the other. Fold min_rate into the idle-timeout directive as an optional second argument instead. * caddyhttp: split write pacing out of request_body into new timeouts handler request_body is a request-body concern (max_size, set); ReadTimeout/ WriteTimeout/MinRate/MaxWriteChunk pace both directions, and write pacing has nothing to do with the request body. Move all of it to a dedicated http.handlers.timeouts module instead, mirroring the server-wide timeouts option one level down.

Kévin Dunglas committed Aug 22, 2026 at 05:17 UTC 0cf03d32f7d99cf160d5375e8a40fbe3d910d515
12 files changed +1053 -59
caddyconfig/httpcaddyfile/directives.go
+1
@@ -60,6 +60,7 @@ var defaultDirectiveOrder = []string{
60 "header",
61 "copy_response_headers", // only in reverse_proxy's handle_response
62 "request_body",
63 + "timeouts", // wraps the response writer, so keep it close to the real writer, ahead of encode/push/etc.
64
65 "redir",
66
caddyconfig/httpcaddyfile/serveroptions.go
+57
@@ -41,7 +41,12 @@ type serverOptions struct {
41 PacketConnWrappersRaw []json.RawMessage
42 ReadTimeout caddy.Duration
43 ReadHeaderTimeout caddy.Duration
44 + ReadIdleTimeout caddy.Duration
45 + ReadMinRate int64
46 WriteTimeout caddy.Duration
47 + WriteIdleTimeout caddy.Duration
48 + WriteMinRate int64
49 + MaxWriteChunk int
50 IdleTimeout caddy.Duration
51 KeepAliveInterval caddy.Duration
52 KeepAliveIdle caddy.Duration
@@ -138,6 +143,24 @@ func unmarshalCaddyfileServerOptions(d *caddyfile.Dispenser) (any, error) {
143 }
144 serverOpts.ReadTimeout = caddy.Duration(dur)
145
146 + case "read_body_idle":
147 + args := d.RemainingArgs()
148 + if len(args) < 1 || len(args) > 2 {
149 + return nil, d.ArgErr()
150 + }
151 + dur, err := caddy.ParseDuration(args[0])
152 + if err != nil {
153 + return nil, d.Errf("parsing read_body_idle timeout duration: %v", err)
154 + }
155 + serverOpts.ReadIdleTimeout = caddy.Duration(dur)
156 + if len(args) == 2 {
157 + rate, err := strconv.ParseInt(args[1], 10, 64)
158 + if err != nil {
159 + return nil, d.Errf("parsing read_body_idle min_rate bytes/second: %v", err)
160 + }
161 + serverOpts.ReadMinRate = rate
162 + }
163 +
164 case "read_header":
165 if !d.NextArg() {
166 return nil, d.ArgErr()
@@ -158,6 +181,35 @@ func unmarshalCaddyfileServerOptions(d *caddyfile.Dispenser) (any, error) {
181 }
182 serverOpts.WriteTimeout = caddy.Duration(dur)
183
184 + case "write_idle":
185 + args := d.RemainingArgs()
186 + if len(args) < 1 || len(args) > 2 {
187 + return nil, d.ArgErr()
188 + }
189 + dur, err := caddy.ParseDuration(args[0])
190 + if err != nil {
191 + return nil, d.Errf("parsing write_idle timeout duration: %v", err)
192 + }
193 + serverOpts.WriteIdleTimeout = caddy.Duration(dur)
194 + if len(args) == 2 {
195 + rate, err := strconv.ParseInt(args[1], 10, 64)
196 + if err != nil {
197 + return nil, d.Errf("parsing write_idle min_rate bytes/second: %v", err)
198 + }
199 + serverOpts.WriteMinRate = rate
200 + }
201 +
202 + case "write_max_chunk":
203 + var sizeStr string
204 + if !d.AllArgs(&sizeStr) {
205 + return nil, d.ArgErr()
206 + }
207 + size, err := humanize.ParseBytes(sizeStr)
208 + if err != nil {
209 + return nil, d.Errf("parsing write_max_chunk: %v", err)
210 + }
211 + serverOpts.MaxWriteChunk = int(size)
212 +
213 case "idle":
214 if !d.NextArg() {
215 return nil, d.ArgErr()
@@ -389,7 +441,12 @@ func applyServerOptions(
441 server.PacketConnWrappersRaw = opts.PacketConnWrappersRaw
442 server.ReadTimeout = opts.ReadTimeout
443 server.ReadHeaderTimeout = opts.ReadHeaderTimeout
444 + server.ReadIdleTimeout = opts.ReadIdleTimeout
445 + server.ReadMinRate = opts.ReadMinRate
446 server.WriteTimeout = opts.WriteTimeout
447 + server.WriteIdleTimeout = opts.WriteIdleTimeout
448 + server.WriteMinRate = opts.WriteMinRate
449 + server.MaxWriteChunk = opts.MaxWriteChunk
450 server.IdleTimeout = opts.IdleTimeout
451 server.KeepAliveInterval = opts.KeepAliveInterval
452 server.KeepAliveIdle = opts.KeepAliveIdle
modules/caddyhttp/app.go
+17
@@ -407,6 +407,15 @@ func (app *App) Provision(ctx caddy.Context) error {
407 if srv.ReadHeaderTimeout == 0 {
408 srv.ReadHeaderTimeout = defaultReadHeaderTimeout // see #6663
409 }
410 + if srv.ReadIdleTimeout == 0 {
411 + srv.ReadIdleTimeout = defaultReadIdleTimeout
412 + }
413 + if srv.WriteIdleTimeout == 0 {
414 + srv.WriteIdleTimeout = defaultWriteIdleTimeout
415 + }
416 + if srv.MaxWriteChunk == 0 {
417 + srv.MaxWriteChunk = DefaultMaxWriteChunk
418 + }
419 }
420 ctx.Context = oldContext
421 return nil
@@ -891,6 +900,14 @@ const (
900 // long time even on legitimately slow connections or
901 // busy servers to read it.
902 defaultReadHeaderTimeout = caddy.Duration(time.Minute)
903 +
904 + // defaultReadIdleTimeout and defaultWriteIdleTimeout mitigate
905 + // slowloris-style attacks on the request body and response write.
906 + // Unlike a hard deadline, these are safe defaults even for large
907 + // payloads because the deadline is reset on every successful
908 + // read/write; only a stalled connection is affected.
909 + defaultReadIdleTimeout = caddy.Duration(time.Minute)
910 + defaultWriteIdleTimeout = caddy.Duration(time.Minute)
911 )
912
913 // Interface guards
modules/caddyhttp/idletimeout.go new
+192
@@ -0,0 +1,192 @@
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 + "io"
19 + "net/http"
20 + "time"
21 +
22 + "go.uber.org/zap"
23 + "go.uber.org/zap/zapcore"
24 +)
25 +
26 +// DefaultMaxWriteChunk is used by IdleTimeoutWriter when MaxChunk is zero.
27 +// See IdleTimeoutWriter for why a limit is needed at all; nginx's
28 +// analogous sendfile_max_chunk defaults to 2 MiB and is admin-tunable
29 +// for the same reason this is exposed as a field rather than a constant.
30 +const DefaultMaxWriteChunk = 64 * 1024
31 +
32 +// IdleDeadline computes the next read/write deadline for the idle-reset
33 +// mechanism shared by IdleTimeoutReader and IdleTimeoutWriter.
34 +//
35 +// With MinRate == 0, the deadline is simply pushed forward on every
36 +// call (now+Timeout): a slow but steadily-progressing transfer is never
37 +// killed, while a connection that stalls (no bytes for the duration of
38 +// Timeout) is. This alone doesn't bound a transfer that trickles just
39 +// enough data to never go idle.
40 +//
41 +// With MinRate > 0, the allowance instead grows from a fixed Start
42 +// based on bytes transferred so far (1/MinRate seconds of extra
43 +// allowance per byte), matching Apache mod_reqtimeout's MinRate: a
44 +// trickle that doesn't sustain MinRate bytes/sec falls behind real
45 +// time and gets cut, even though no single call ever stalls.
46 +//
47 +// HardDeadline, if non-zero, caps the result either way, so this can't
48 +// silently defeat an explicitly configured ReadTimeout/WriteTimeout
49 +// ceiling.
50 +type IdleDeadline struct {
51 + Start time.Time
52 + Timeout time.Duration
53 + MinRate int64
54 + HardDeadline time.Time
55 +
56 + transferred int64
57 +}
58 +
59 +func (d *IdleDeadline) next() (deadline time.Time) {
60 + if d.MinRate > 0 {
61 + credit := time.Duration(d.transferred) * time.Second / time.Duration(d.MinRate)
62 + deadline = d.Start.Add(d.Timeout + credit)
63 + } else {
64 + deadline = time.Now().Add(d.Timeout)
65 + }
66 + if !d.HardDeadline.IsZero() && deadline.After(d.HardDeadline) {
67 + deadline = d.HardDeadline
68 + }
69 +
70 + return
71 +}
72 +
73 +// IdleTimeoutReader wraps a request body with IdleDeadline, resetting
74 +// the read deadline before every Read call instead of bounding the
75 +// whole body transfer with a single hard deadline.
76 +type IdleTimeoutReader struct {
77 + io.ReadCloser
78 + Ctrl *http.ResponseController
79 + Deadline IdleDeadline
80 + Logger *zap.Logger
81 +
82 + unsupported bool
83 +}
84 +
85 +func (r *IdleTimeoutReader) Read(p []byte) (int, error) {
86 + if !r.unsupported {
87 + if err := r.Ctrl.SetReadDeadline(r.Deadline.next()); err != nil {
88 + r.unsupported = true
89 + if c := r.Logger.Check(zapcore.DebugLevel, "could not set read deadline"); c != nil {
90 + c.Write(zap.Error(err))
91 + }
92 + }
93 + }
94 +
95 + n, err := r.ReadCloser.Read(p)
96 + r.Deadline.transferred += int64(n)
97 +
98 + return n, err
99 +}
100 +
101 +// IdleTimeoutWriter wraps a ResponseWriter with IdleDeadline, resetting
102 +// the write deadline before every Write call, the same way
103 +// IdleTimeoutReader does for reads. A handler that pauses between
104 +// writes (e.g. streaming or SSE) is unaffected, since with MinRate == 0
105 +// the deadline only bounds the duration of the write actually in flight.
106 +//
107 +// MaxChunk bounds how much a single underlying Write/ReadFrom call is
108 +// allowed to cover; zero uses DefaultMaxWriteChunk. SetWriteDeadline
109 +// bounds the whole call it precedes, not just a stall within it:
110 +// net.Conn.Write loops internally until the entire buffer is sent
111 +// (unlike Read, which returns after one syscall), and
112 +// ResponseWriter.ReadFrom hands the entire remaining source to the
113 +// connection in one call, be it via sendfile or an internal buffered
114 +// copy loop. Without chunking, a single large Write or a large body
115 +// copied via io.Copy would have its whole transfer bounded by one
116 +// deadline, silently truncating a slow-but-healthy transfer exactly
117 +// like a hard WriteTimeout would. A 64 KiB default still preserves
118 +// most of the sendfile fast path's benefit (net/sendfile.go
119 +// special-cases *io.LimitedReader to keep using sendfile per chunk).
120 +type IdleTimeoutWriter struct {
121 + *ResponseWriterWrapper
122 + Ctrl *http.ResponseController
123 + Deadline IdleDeadline
124 + MaxChunk int
125 + Logger *zap.Logger
126 +
127 + unsupported bool
128 +}
129 +
130 +func (w *IdleTimeoutWriter) resetDeadline() {
131 + if w.unsupported {
132 + return
133 + }
134 +
135 + if err := w.Ctrl.SetWriteDeadline(w.Deadline.next()); err != nil {
136 + w.unsupported = true
137 + if c := w.Logger.Check(zapcore.DebugLevel, "could not set write deadline"); c != nil {
138 + c.Write(zap.Error(err))
139 + }
140 + }
141 +}
142 +
143 +func (w *IdleTimeoutWriter) maxChunk() int {
144 + if w.MaxChunk > 0 {
145 + return w.MaxChunk
146 + }
147 + return DefaultMaxWriteChunk
148 +}
149 +
150 +func (w *IdleTimeoutWriter) Write(p []byte) (int, error) {
151 + maxChunk := w.maxChunk()
152 + var total int
153 + for len(p) > 0 {
154 + chunk := p
155 + if len(chunk) > maxChunk {
156 + chunk = chunk[:maxChunk]
157 + }
158 + w.resetDeadline()
159 + n, err := w.ResponseWriterWrapper.Write(chunk)
160 + total += n
161 + w.Deadline.transferred += int64(n)
162 + p = p[n:]
163 + if err != nil {
164 + return total, err
165 + }
166 + }
167 + return total, nil
168 +}
169 +
170 +func (w *IdleTimeoutWriter) ReadFrom(r io.Reader) (int64, error) {
171 + maxChunk := w.maxChunk()
172 + var total int64
173 + for {
174 + w.resetDeadline()
175 + n, err := w.ResponseWriterWrapper.ReadFrom(io.LimitReader(r, int64(maxChunk)))
176 + total += n
177 + w.Deadline.transferred += n
178 + if err != nil {
179 + return total, err
180 + }
181 + if n < int64(maxChunk) {
182 + return total, nil
183 + }
184 + }
185 +}
186 +
187 +// Interface guards
188 +var (
189 + _ io.ReadCloser = (*IdleTimeoutReader)(nil)
190 + _ http.ResponseWriter = (*IdleTimeoutWriter)(nil)
191 + _ io.ReaderFrom = (*IdleTimeoutWriter)(nil)
192 +)
modules/caddyhttp/idletimeout_test.go new
+375
@@ -0,0 +1,375 @@
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 + "bytes"
19 + "io"
20 + "net/http"
21 + "net/http/httptest"
22 + "testing"
23 + "time"
24 +
25 + "github.com/stretchr/testify/assert"
26 + "github.com/stretchr/testify/require"
27 + "go.uber.org/zap"
28 +)
29 +
30 +// pacedReader emits chunkCount chunks of chunkSize bytes, sleeping delay
31 +// before each one, simulating a client that trickles a request body.
32 +type pacedReader struct {
33 + delay time.Duration
34 + chunkSize int
35 + chunkCount int
36 +}
37 +
38 +func (p *pacedReader) Read(b []byte) (int, error) {
39 + if p.chunkCount <= 0 {
40 + return 0, io.EOF
41 + }
42 + time.Sleep(p.delay)
43 + p.chunkCount--
44 + n := copy(b, make([]byte, p.chunkSize))
45 + return n, nil
46 +}
47 +
48 +func TestIdleTimeoutReader(t *testing.T) {
49 + const timeout = 150 * time.Millisecond
50 +
51 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
52 + wrapped := &IdleTimeoutReader{
53 + ReadCloser: r.Body,
54 + Ctrl: http.NewResponseController(w),
55 + Deadline: IdleDeadline{Timeout: timeout},
56 + Logger: zap.NewNop(),
57 + }
58 + _, err := io.Copy(io.Discard, wrapped)
59 + if err != nil {
60 + http.Error(w, err.Error(), http.StatusRequestTimeout)
61 + return
62 + }
63 + w.WriteHeader(http.StatusOK)
64 + }))
65 + defer srv.Close()
66 +
67 + t.Run("slow but steadily progressing upload is not killed", func(t *testing.T) {
68 + // each gap is well under timeout, but the cumulative transfer
69 + // time is well over it; a hard deadline would kill this
70 + body := &pacedReader{delay: timeout / 4, chunkSize: 8, chunkCount: 8}
71 + resp, err := http.Post(srv.URL, "application/octet-stream", body)
72 + require.NoError(t, err)
73 + defer resp.Body.Close()
74 + assert.Equal(t, http.StatusOK, resp.StatusCode)
75 + })
76 +
77 + t.Run("stalled upload is aborted", func(t *testing.T) {
78 + // a single gap that exceeds timeout must trip the deadline
79 + body := &pacedReader{delay: timeout * 4, chunkSize: 8, chunkCount: 2}
80 + resp, err := http.Post(srv.URL, "application/octet-stream", body)
81 + if err != nil {
82 + // the connection may also be reset outright, which is fine
83 + return
84 + }
85 + defer resp.Body.Close()
86 + assert.NotEqual(t, http.StatusOK, resp.StatusCode)
87 + })
88 +}
89 +
90 +func TestIdleTimeoutReader_HardDeadlineCapsIdleReset(t *testing.T) {
91 + const idle = 500 * time.Millisecond
92 + const hard = 200 * time.Millisecond
93 +
94 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
95 + wrapped := &IdleTimeoutReader{
96 + ReadCloser: r.Body,
97 + Ctrl: http.NewResponseController(w),
98 + Deadline: IdleDeadline{
99 + Timeout: idle,
100 + HardDeadline: time.Now().Add(hard),
101 + },
102 + Logger: zap.NewNop(),
103 + }
104 + _, err := io.Copy(io.Discard, wrapped)
105 + if err != nil {
106 + http.Error(w, err.Error(), http.StatusRequestTimeout)
107 + return
108 + }
109 + w.WriteHeader(http.StatusOK)
110 + }))
111 + defer srv.Close()
112 +
113 + // each gap is under the idle timeout, so the connection is never
114 + // idle, but the cumulative transfer time exceeds hardDeadline: an
115 + // explicit hard ceiling must still cut it off despite the ongoing
116 + // idle-reset activity
117 + body := &pacedReader{delay: hard, chunkSize: 8, chunkCount: 8}
118 + resp, err := http.Post(srv.URL, "application/octet-stream", body)
119 + if err != nil {
120 + // the connection may also be reset outright, which is fine
121 + return
122 + }
123 + defer resp.Body.Close()
124 + assert.NotEqual(t, http.StatusOK, resp.StatusCode)
125 +}
126 +
127 +func TestIdleTimeoutReader_MinRateCatchesTrickle(t *testing.T) {
128 + const idle = 250 * time.Millisecond
129 + const chunkDelay = 150 * time.Millisecond
130 +
131 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
132 + wrapped := &IdleTimeoutReader{
133 + ReadCloser: r.Body,
134 + Ctrl: http.NewResponseController(w),
135 + Deadline: IdleDeadline{
136 + Start: time.Now(),
137 + Timeout: idle,
138 + // a huge min rate means even a full-size read call
139 + // earns virtually no extra credit, so a byte-sized
140 + // trickle can't keep the connection alive past idle
141 + MinRate: 100_000_000,
142 + },
143 + Logger: zap.NewNop(),
144 + }
145 + _, err := io.Copy(io.Discard, wrapped)
146 + if err != nil {
147 + http.Error(w, err.Error(), http.StatusRequestTimeout)
148 + return
149 + }
150 + w.WriteHeader(http.StatusOK)
151 + }))
152 + defer srv.Close()
153 +
154 + // each individual gap (150ms) is under the idle window (250ms), so
155 + // pure idle-reset alone would never trip; MinRate must still catch
156 + // this trickle since it never earns meaningful credit
157 + body := &pacedReader{delay: chunkDelay, chunkSize: 1, chunkCount: 6}
158 + resp, err := http.Post(srv.URL, "application/octet-stream", body)
159 + if err != nil {
160 + return
161 + }
162 + defer resp.Body.Close()
163 + assert.NotEqual(t, http.StatusOK, resp.StatusCode)
164 +}
165 +
166 +func TestIdleTimeoutReader_MinRateAllowsSustainedRate(t *testing.T) {
167 + const idle = 250 * time.Millisecond
168 + const chunkDelay = 100 * time.Millisecond
169 + const minRate = 1000 // bytes/second
170 +
171 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
172 + wrapped := &IdleTimeoutReader{
173 + ReadCloser: r.Body,
174 + Ctrl: http.NewResponseController(w),
175 + Deadline: IdleDeadline{
176 + Start: time.Now(),
177 + Timeout: idle,
178 + MinRate: minRate,
179 + },
180 + Logger: zap.NewNop(),
181 + }
182 + _, err := io.Copy(io.Discard, wrapped)
183 + if err != nil {
184 + http.Error(w, err.Error(), http.StatusRequestTimeout)
185 + return
186 + }
187 + w.WriteHeader(http.StatusOK)
188 + }))
189 + defer srv.Close()
190 +
191 + // 200 bytes every 100ms is 2000 bytes/second, comfortably above the
192 + // 1000 bytes/second minRate, so credit earned per chunk (200ms)
193 + // outpaces the real time elapsed per chunk (100ms): this transfer
194 + // must complete despite exceeding the idle window cumulatively
195 + body := &pacedReader{delay: chunkDelay, chunkSize: 200, chunkCount: 8}
196 + resp, err := http.Post(srv.URL, "application/octet-stream", body)
197 + require.NoError(t, err)
198 + defer resp.Body.Close()
199 + assert.Equal(t, http.StatusOK, resp.StatusCode)
200 +}
201 +
202 +func TestIdleTimeoutWriter(t *testing.T) {
203 + const timeout = 150 * time.Millisecond
204 +
205 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
206 + wrapped := &IdleTimeoutWriter{
207 + ResponseWriterWrapper: &ResponseWriterWrapper{ResponseWriter: w},
208 + Ctrl: http.NewResponseController(w),
209 + Deadline: IdleDeadline{Timeout: timeout},
210 + Logger: zap.NewNop(),
211 + }
212 + flusher, _ := wrapped.ResponseWriterWrapper.ResponseWriter.(http.Flusher)
213 + for range 8 {
214 + time.Sleep(timeout / 4)
215 + _, err := wrapped.Write(make([]byte, 8))
216 + if err != nil {
217 + t.Errorf("unexpected write error: %v", err)
218 + return
219 + }
220 + if flusher != nil {
221 + flusher.Flush()
222 + }
223 + }
224 + }))
225 + defer srv.Close()
226 +
227 + // each write gap is well under timeout, but the cumulative streaming
228 + // time is well over it; a hard deadline would kill this response
229 + resp, err := http.Get(srv.URL)
230 + require.NoError(t, err)
231 + defer resp.Body.Close()
232 +
233 + n, err := io.Copy(io.Discard, resp.Body)
234 + require.NoError(t, err)
235 + assert.EqualValues(t, 64, n)
236 +}
237 +
238 +func TestIdleTimeoutWriter_HardDeadlineCapsIdleReset(t *testing.T) {
239 + const idle = 500 * time.Millisecond
240 + const hard = 200 * time.Millisecond
241 +
242 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
243 + wrapped := &IdleTimeoutWriter{
244 + ResponseWriterWrapper: &ResponseWriterWrapper{ResponseWriter: w},
245 + Ctrl: http.NewResponseController(w),
246 + Deadline: IdleDeadline{
247 + Timeout: idle,
248 + HardDeadline: time.Now().Add(hard),
249 + },
250 + Logger: zap.NewNop(),
251 + }
252 + flusher, _ := wrapped.ResponseWriterWrapper.ResponseWriter.(http.Flusher)
253 + for range 8 {
254 + time.Sleep(hard)
255 + if _, err := wrapped.Write(make([]byte, 8)); err != nil {
256 + return
257 + }
258 + if flusher != nil {
259 + flusher.Flush()
260 + }
261 + }
262 + }))
263 + defer srv.Close()
264 +
265 + // each write gap is under the idle timeout, so the connection is
266 + // never idle, but the cumulative streaming time exceeds
267 + // hardDeadline: an explicit hard ceiling must still cut it off,
268 + // whether that surfaces as a failed request, a truncated body, or
269 + // both
270 + resp, err := http.Get(srv.URL)
271 + if err != nil {
272 + return
273 + }
274 + defer resp.Body.Close()
275 +
276 + n, err := io.Copy(io.Discard, resp.Body)
277 + if err == nil {
278 + assert.Less(t, n, int64(64))
279 + }
280 +}
281 +
282 +// readFromCounter is a fake http.ResponseWriter that records how many
283 +// times ReadFrom was called on it and the size of the largest call, so
284 +// tests can assert on chunking behavior deterministically instead of
285 +// depending on real TCP timing.
286 +type readFromCounter struct {
287 + *httptest.ResponseRecorder
288 + calls int
289 + maxCall int
290 + total int64
291 + writeCalls int
292 + maxWriteCall int
293 +}
294 +
295 +func (c *readFromCounter) SetWriteDeadline(time.Time) error { return nil }
296 +
297 +func (c *readFromCounter) ReadFrom(r io.Reader) (int64, error) {
298 + c.calls++
299 + n, err := io.Copy(io.Discard, r)
300 + c.total += n
301 + if int(n) > c.maxCall {
302 + c.maxCall = int(n)
303 + }
304 + return n, err
305 +}
306 +
307 +func (c *readFromCounter) Write(p []byte) (int, error) {
308 + c.writeCalls++
309 + if len(p) > c.maxWriteCall {
310 + c.maxWriteCall = len(p)
311 + }
312 + return c.ResponseRecorder.Write(p)
313 +}
314 +
315 +func TestIdleTimeoutWriter_ReadFromChunksLargeTransfer(t *testing.T) {
316 + const size = DefaultMaxWriteChunk*3 + 100 // 3 full chunks plus a partial tail
317 +
318 + counter := &readFromCounter{ResponseRecorder: httptest.NewRecorder()}
319 + wrapped := &IdleTimeoutWriter{
320 + ResponseWriterWrapper: &ResponseWriterWrapper{ResponseWriter: counter},
321 + Ctrl: http.NewResponseController(counter),
322 + Deadline: IdleDeadline{Timeout: time.Second},
323 + Logger: zap.NewNop(),
324 + }
325 +
326 + n, err := wrapped.ReadFrom(bytes.NewReader(make([]byte, size)))
327 + require.NoError(t, err)
328 + assert.EqualValues(t, size, n)
329 + assert.EqualValues(t, size, counter.total)
330 + assert.LessOrEqual(t, counter.maxCall, DefaultMaxWriteChunk,
331 + "no single underlying ReadFrom call should cover more than DefaultMaxWriteChunk bytes, "+
332 + "since SetWriteDeadline bounds the whole call it precedes, not just a stall within it")
333 + assert.Equal(t, 4, counter.calls, "expected 3 full chunks plus a partial tail chunk")
334 +}
335 +
336 +func TestIdleTimeoutWriter_WriteChunksLargePayload(t *testing.T) {
337 + const size = DefaultMaxWriteChunk*2 + 1
338 +
339 + counter := &readFromCounter{ResponseRecorder: httptest.NewRecorder()}
340 + wrapped := &IdleTimeoutWriter{
341 + ResponseWriterWrapper: &ResponseWriterWrapper{ResponseWriter: counter},
342 + Ctrl: http.NewResponseController(counter),
343 + Deadline: IdleDeadline{Timeout: time.Second},
344 + Logger: zap.NewNop(),
345 + }
346 +
347 + n, err := wrapped.Write(make([]byte, size))
348 + require.NoError(t, err)
349 + assert.EqualValues(t, size, n)
350 + assert.EqualValues(t, size, counter.ResponseRecorder.Body.Len())
351 + assert.LessOrEqual(t, counter.maxWriteCall, DefaultMaxWriteChunk,
352 + "no single underlying Write call should cover more than DefaultMaxWriteChunk bytes")
353 + assert.Equal(t, 3, counter.writeCalls, "expected 2 full chunks plus a 1-byte tail chunk")
354 +}
355 +
356 +func TestIdleTimeoutWriter_MaxChunkOverride(t *testing.T) {
357 + const size = 1000
358 + const maxChunk = 100
359 +
360 + counter := &readFromCounter{ResponseRecorder: httptest.NewRecorder()}
361 + wrapped := &IdleTimeoutWriter{
362 + ResponseWriterWrapper: &ResponseWriterWrapper{ResponseWriter: counter},
363 + Ctrl: http.NewResponseController(counter),
364 + Deadline: IdleDeadline{Timeout: time.Second},
365 + MaxChunk: maxChunk,
366 + Logger: zap.NewNop(),
367 + }
368 +
369 + n, err := wrapped.Write(make([]byte, size))
370 + require.NoError(t, err)
371 + assert.EqualValues(t, size, n)
372 + assert.LessOrEqual(t, counter.maxWriteCall, maxChunk,
373 + "a configured MaxChunk should override DefaultMaxWriteChunk")
374 + assert.Equal(t, size/maxChunk, counter.writeCalls)
375 +}
modules/caddyhttp/requestbody/caddyfile.go
-24
@@ -15,8 +15,6 @@
15 package requestbody
16
17 import (
18 - "time"
19 -
18 "github.com/dustin/go-humanize"
19
20 "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
@@ -46,28 +44,6 @@ func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error)
44 }
45 rb.MaxSize = int64(size)
46
49 - case "read_timeout":
50 - var timeoutStr string
51 - if !h.AllArgs(&timeoutStr) {
52 - return nil, h.ArgErr()
53 - }
54 - timeout, err := time.ParseDuration(timeoutStr)
55 - if err != nil {
56 - return nil, h.Errf("parsing read_timeout: %v", err)
57 - }
58 - rb.ReadTimeout = timeout
59 -
60 - case "write_timeout":
61 - var timeoutStr string
62 - if !h.AllArgs(&timeoutStr) {
63 - return nil, h.ArgErr()
64 - }
65 - timeout, err := time.ParseDuration(timeoutStr)
66 - if err != nil {
67 - return nil, h.Errf("parsing write_timeout: %v", err)
68 - }
69 - rb.WriteTimeout = timeout
70 -
47 case "set":
48 var setStr string
49 if !h.AllArgs(&setStr) {
modules/caddyhttp/requestbody/requestbody.go
-35
@@ -19,10 +19,6 @@ import (
19 "io"
20 "net/http"
21 "strings"
22 - "time"
23 -
24 - "go.uber.org/zap"
25 - "go.uber.org/zap/zapcore"
22
23 "github.com/caddyserver/caddy/v2"
24 "github.com/caddyserver/caddy/v2/modules/caddyhttp"
@@ -38,17 +34,9 @@ type RequestBody struct {
34 // If more bytes are read, an error with HTTP status 413 is returned.
35 MaxSize int64 `json:"max_size,omitempty"`
36
41 - // EXPERIMENTAL. Subject to change/removal.
42 - ReadTimeout time.Duration `json:"read_timeout,omitempty"`
43 -
44 - // EXPERIMENTAL. Subject to change/removal.
45 - WriteTimeout time.Duration `json:"write_timeout,omitempty"`
46 -
37 // This field permit to replace body on the fly
38 // EXPERIMENTAL. Subject to change/removal.
39 Set string `json:"set,omitempty"`
50 -
51 - logger *zap.Logger
40 }
41
42 // CaddyModule returns the Caddy module information.
@@ -59,11 +47,6 @@ func (RequestBody) CaddyModule() caddy.ModuleInfo {
47 }
48 }
49
62 -func (rb *RequestBody) Provision(ctx caddy.Context) error {
63 - rb.logger = ctx.Logger()
64 - return nil
65 -}
66 -
50 func (rb RequestBody) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
51 if rb.Set != "" {
52 if r.Body != nil {
@@ -83,24 +66,6 @@ func (rb RequestBody) ServeHTTP(w http.ResponseWriter, r *http.Request, next cad
66 if rb.MaxSize > 0 {
67 r.Body = errorWrapper{http.MaxBytesReader(w, r.Body, rb.MaxSize)}
68 }
86 - if rb.ReadTimeout > 0 || rb.WriteTimeout > 0 {
87 - //nolint:bodyclose
88 - rc := http.NewResponseController(w)
89 - if rb.ReadTimeout > 0 {
90 - if err := rc.SetReadDeadline(time.Now().Add(rb.ReadTimeout)); err != nil {
91 - if c := rb.logger.Check(zapcore.ErrorLevel, "could not set read deadline"); c != nil {
92 - c.Write(zap.Error(err))
93 - }
94 - }
95 - }
96 - if rb.WriteTimeout > 0 {
97 - if err := rc.SetWriteDeadline(time.Now().Add(rb.WriteTimeout)); err != nil {
98 - if c := rb.logger.Check(zapcore.ErrorLevel, "could not set write deadline"); c != nil {
99 - c.Write(zap.Error(err))
100 - }
101 - }
102 - }
103 - }
69 return next.ServeHTTP(w, r)
70 }
71
modules/caddyhttp/server.go
+83
@@ -69,11 +69,49 @@ type Server struct {
69 // Default is 1 minute.
70 ReadHeaderTimeout caddy.Duration `json:"read_header_timeout,omitempty"`
71
72 + // How long to allow a read from a client's upload to stall before
73 + // aborting the connection, reset on every successful read. Unlike
74 + // ReadTimeout, which bounds the whole transfer with a single
75 + // deadline, this only fires if the connection actually stalls, so
76 + // it mitigates slowloris-style attacks without penalizing large
77 + // uploads from legitimately slow clients. Combine with ReadTimeout
78 + // for a hard ceiling on top.
79 + // Default is 1 minute.
80 + ReadIdleTimeout caddy.Duration `json:"read_idle_timeout,omitempty"`
81 +
82 + // ReadMinRate, if set, requires the client to sustain at least this
83 + // many bytes/second, averaged from the start of the request body
84 + // read, or the connection is aborted. Unlike ReadIdleTimeout alone,
85 + // this also catches a trickle that sends just enough to never go
86 + // idle, but never accumulates real throughput (a "MinRate" in
87 + // Apache mod_reqtimeout terms). If zero, no rate is enforced and
88 + // ReadIdleTimeout resets to a flat window on every read instead.
89 + ReadMinRate int64 `json:"read_min_rate,omitempty"`
90 +
91 // WriteTimeout is how long to allow a write to a client. Note
92 // that setting this to a small value when serving large files
93 // may negatively affect legitimately slow clients.
94 WriteTimeout caddy.Duration `json:"write_timeout,omitempty"`
95
96 + // How long to allow a write to a client to stall before aborting
97 + // the connection, reset on every successful write, the same way
98 + // ReadIdleTimeout works for reads. A handler that streams a large
99 + // response, or pauses between writes (e.g. SSE), is unaffected as
100 + // long as each individual write keeps making progress. Combine
101 + // with WriteTimeout for a hard ceiling on top.
102 + // Default is 1 minute.
103 + WriteIdleTimeout caddy.Duration `json:"write_idle_timeout,omitempty"`
104 +
105 + // WriteMinRate is like ReadMinRate, but for writes to the client.
106 + WriteMinRate int64 `json:"write_min_rate,omitempty"`
107 +
108 + // MaxWriteChunk bounds how many bytes a single underlying write
109 + // operation is allowed to cover, so that WriteIdleTimeout/WriteMinRate
110 + // can actually apply between chunks of a large response instead of
111 + // being bounded by one deadline for the whole thing (nginx's
112 + // sendfile_max_chunk exists for the same reason). Default: 64 KiB.
113 + MaxWriteChunk int `json:"max_write_chunk,omitempty"`
114 +
115 // IdleTimeout is the maximum time to wait for the next request
116 // when keep-alives are enabled. If zero, a default timeout of
117 // 5m is applied to help avoid resource exhaustion.
@@ -532,6 +570,51 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
570 }
571 }
572
573 + // guard against slowloris-style attacks by aborting the connection
574 + // if a read from the request body or a write to the response
575 + // stalls for longer than the configured timeout; the deadline is
576 + // reset on every successful read/write, so this doesn't affect
577 + // legitimately slow clients as long as they keep making progress.
578 + // hardDeadline, anchored to this handler's start, caps how far
579 + // that reset can push the deadline out, so an explicitly configured
580 + // ReadTimeout/WriteTimeout ceiling still applies on top instead of
581 + // being silently overwritten by the first read/write.
582 + rc := http.NewResponseController(w)
583 + var readHardDeadline, writeHardDeadline time.Time
584 + if s.ReadTimeout > 0 {
585 + readHardDeadline = start.Add(time.Duration(s.ReadTimeout))
586 + }
587 + if s.WriteTimeout > 0 {
588 + writeHardDeadline = start.Add(time.Duration(s.WriteTimeout))
589 + }
590 + if s.ReadIdleTimeout > 0 && r.Body != nil {
591 + r.Body = &IdleTimeoutReader{
592 + ReadCloser: r.Body,
593 + Ctrl: rc,
594 + Deadline: IdleDeadline{
595 + Start: start,
596 + Timeout: time.Duration(s.ReadIdleTimeout),
597 + MinRate: s.ReadMinRate,
598 + HardDeadline: readHardDeadline,
599 + },
600 + Logger: s.logger,
601 + }
602 + }
603 + if s.WriteIdleTimeout > 0 {
604 + w = &IdleTimeoutWriter{
605 + ResponseWriterWrapper: &ResponseWriterWrapper{ResponseWriter: w},
606 + Ctrl: rc,
607 + Deadline: IdleDeadline{
608 + Start: start,
609 + Timeout: time.Duration(s.WriteIdleTimeout),
610 + MinRate: s.WriteMinRate,
611 + HardDeadline: writeHardDeadline,
612 + },
613 + MaxChunk: s.MaxWriteChunk,
614 + Logger: s.logger,
615 + }
616 + }
617 +
618 // set the Server header
619 h := w.Header()
620 h["Server"] = serverHeader
modules/caddyhttp/standard/imports.go
+1
@@ -21,5 +21,6 @@ import (
21 _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/reverseproxy/forwardauth"
22 _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/rewrite"
23 _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/templates"
24 + _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/timeouts"
25 _ "github.com/caddyserver/caddy/v2/modules/caddyhttp/tracing"
26 )
modules/caddyhttp/timeouts/caddyfile.go new
+92
@@ -0,0 +1,92 @@
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 timeouts
16 +
17 +import (
18 + "strconv"
19 + "time"
20 +
21 + "github.com/dustin/go-humanize"
22 +
23 + "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
24 + "github.com/caddyserver/caddy/v2/modules/caddyhttp"
25 +)
26 +
27 +func init() {
28 + httpcaddyfile.RegisterHandlerDirective("timeouts", parseCaddyfile)
29 +}
30 +
31 +func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
32 + h.Next() // consume directive name
33 +
34 + t := new(Timeouts)
35 +
36 + // configuration should be in a block
37 + for h.NextBlock(0) {
38 + switch h.Val() {
39 + case "read_timeout":
40 + args := h.RemainingArgs()
41 + if len(args) < 1 || len(args) > 2 {
42 + return nil, h.ArgErr()
43 + }
44 + timeout, err := time.ParseDuration(args[0])
45 + if err != nil {
46 + return nil, h.Errf("parsing read_timeout: %v", err)
47 + }
48 + t.ReadTimeout = timeout
49 + if len(args) == 2 {
50 + rate, err := strconv.ParseInt(args[1], 10, 64)
51 + if err != nil {
52 + return nil, h.Errf("parsing read_timeout min_rate: %v", err)
53 + }
54 + t.ReadMinRate = rate
55 + }
56 +
57 + case "write_timeout":
58 + args := h.RemainingArgs()
59 + if len(args) < 1 || len(args) > 2 {
60 + return nil, h.ArgErr()
61 + }
62 + timeout, err := time.ParseDuration(args[0])
63 + if err != nil {
64 + return nil, h.Errf("parsing write_timeout: %v", err)
65 + }
66 + t.WriteTimeout = timeout
67 + if len(args) == 2 {
68 + rate, err := strconv.ParseInt(args[1], 10, 64)
69 + if err != nil {
70 + return nil, h.Errf("parsing write_timeout min_rate: %v", err)
71 + }
72 + t.WriteMinRate = rate
73 + }
74 +
75 + case "max_write_chunk":
76 + var sizeStr string
77 + if !h.AllArgs(&sizeStr) {
78 + return nil, h.ArgErr()
79 + }
80 + size, err := humanize.ParseBytes(sizeStr)
81 + if err != nil {
82 + return nil, h.Errf("parsing max_write_chunk: %v", err)
83 + }
84 + t.MaxWriteChunk = int(size)
85 +
86 + default:
87 + return nil, h.Errf("unrecognized timeouts subdirective '%s'", h.Val())
88 + }
89 + }
90 +
91 + return t, nil
92 +}
modules/caddyhttp/timeouts/timeouts.go new
+127
@@ -0,0 +1,127 @@
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 timeouts
16 +
17 +import (
18 + "net/http"
19 + "time"
20 +
21 + "go.uber.org/zap"
22 +
23 + "github.com/caddyserver/caddy/v2"
24 + "github.com/caddyserver/caddy/v2/modules/caddyhttp"
25 +)
26 +
27 +func init() {
28 + caddy.RegisterModule(Timeouts{})
29 +}
30 +
31 +// Timeouts is a middleware that applies read/write idle timeouts, minimum
32 +// transfer rates, and a write chunk size cap to routes matching this
33 +// handler, independent of the server-wide equivalents.
34 +type Timeouts struct {
35 + // How long to allow a read from the request body to stall before
36 + // aborting the connection, reset on every successful read (like the
37 + // server-wide read_idle_timeout, but scoped to routes matching this
38 + // handler). If zero, no idle timeout is applied here.
39 + // EXPERIMENTAL. Subject to change/removal.
40 + ReadTimeout time.Duration `json:"read_timeout,omitempty"`
41 +
42 + // ReadMinRate requires the client to sustain at least this many
43 + // bytes/second, averaged from the start of the read, or the
44 + // connection is aborted (Apache mod_reqtimeout's MinRate). Only
45 + // takes effect if ReadTimeout is also set.
46 + // EXPERIMENTAL. Subject to change/removal.
47 + ReadMinRate int64 `json:"read_min_rate,omitempty"`
48 +
49 + // How long to allow a write to the client to stall before aborting
50 + // the connection, reset on every successful write (like the
51 + // server-wide write_idle_timeout, but scoped to routes matching this
52 + // handler). If zero, no idle timeout is applied here.
53 + // EXPERIMENTAL. Subject to change/removal.
54 + WriteTimeout time.Duration `json:"write_timeout,omitempty"`
55 +
56 + // WriteMinRate is like ReadMinRate, but for writes to the client.
57 + // Only takes effect if WriteTimeout is also set.
58 + // EXPERIMENTAL. Subject to change/removal.
59 + WriteMinRate int64 `json:"write_min_rate,omitempty"`
60 +
61 + // MaxWriteChunk bounds how many bytes a single underlying write
62 + // operation is allowed to cover, so WriteTimeout/WriteMinRate can
63 + // actually apply between chunks of a large response instead of
64 + // being bounded by one deadline for the whole thing. If zero,
65 + // caddyhttp.DefaultMaxWriteChunk is used.
66 + // EXPERIMENTAL. Subject to change/removal.
67 + MaxWriteChunk int `json:"max_write_chunk,omitempty"`
68 +
69 + logger *zap.Logger
70 +}
71 +
72 +// CaddyModule returns the Caddy module information.
73 +func (Timeouts) CaddyModule() caddy.ModuleInfo {
74 + return caddy.ModuleInfo{
75 + ID: "http.handlers.timeouts",
76 + New: func() caddy.Module { return new(Timeouts) },
77 + }
78 +}
79 +
80 +func (t *Timeouts) Provision(ctx caddy.Context) error {
81 + t.logger = ctx.Logger()
82 + return nil
83 +}
84 +
85 +func (t Timeouts) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
86 + if t.ReadTimeout <= 0 && t.WriteTimeout <= 0 {
87 + return next.ServeHTTP(w, r)
88 + }
89 +
90 + //nolint:bodyclose
91 + rc := http.NewResponseController(w)
92 + start := time.Now()
93 +
94 + if t.ReadTimeout > 0 && r.Body != nil {
95 + r.Body = &caddyhttp.IdleTimeoutReader{
96 + ReadCloser: r.Body,
97 + Ctrl: rc,
98 + Deadline: caddyhttp.IdleDeadline{
99 + Start: start,
100 + Timeout: t.ReadTimeout,
101 + MinRate: t.ReadMinRate,
102 + },
103 + Logger: t.logger,
104 + }
105 + }
106 + if t.WriteTimeout > 0 {
107 + w = &caddyhttp.IdleTimeoutWriter{
108 + ResponseWriterWrapper: &caddyhttp.ResponseWriterWrapper{ResponseWriter: w},
109 + Ctrl: rc,
110 + Deadline: caddyhttp.IdleDeadline{
111 + Start: start,
112 + Timeout: t.WriteTimeout,
113 + MinRate: t.WriteMinRate,
114 + },
115 + MaxChunk: t.MaxWriteChunk,
116 + Logger: t.logger,
117 + }
118 + }
119 +
120 + return next.ServeHTTP(w, r)
121 +}
122 +
123 +// Interface guards
124 +var (
125 + _ caddy.Provisioner = (*Timeouts)(nil)
126 + _ caddyhttp.MiddlewareHandler = (*Timeouts)(nil)
127 +)
modules/caddyhttp/timeouts/timeouts_test.go new
+108
@@ -0,0 +1,108 @@
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 timeouts
16 +
17 +import (
18 + "io"
19 + "net/http"
20 + "net/http/httptest"
21 + "testing"
22 + "time"
23 +
24 + "github.com/stretchr/testify/assert"
25 + "github.com/stretchr/testify/require"
26 + "go.uber.org/zap"
27 +
28 + "github.com/caddyserver/caddy/v2/modules/caddyhttp"
29 +)
30 +
31 +// pacedReader emits chunkCount chunks of chunkSize bytes, sleeping delay
32 +// before each one, simulating a client that trickles a request body.
33 +type pacedReader struct {
34 + delay time.Duration
35 + chunkSize int
36 + chunkCount int
37 +}
38 +
39 +func (p *pacedReader) Read(b []byte) (int, error) {
40 + if p.chunkCount <= 0 {
41 + return 0, io.EOF
42 + }
43 + time.Sleep(p.delay)
44 + p.chunkCount--
45 + n := copy(b, make([]byte, p.chunkSize))
46 + return n, nil
47 +}
48 +
49 +// noError adapts a caddyhttp.Handler to a plain http.Handler for httptest.NewServer.
50 +func noError(h caddyhttp.Handler) http.HandlerFunc {
51 + return func(w http.ResponseWriter, r *http.Request) {
52 + if err := h.ServeHTTP(w, r); err != nil {
53 + http.Error(w, err.Error(), http.StatusInternalServerError)
54 + }
55 + }
56 +}
57 +
58 +func TestTimeouts_ReadTimeoutIsIdleReset(t *testing.T) {
59 + const timeout = 150 * time.Millisecond
60 +
61 + tm := Timeouts{ReadTimeout: timeout}
62 + tm.logger = zap.NewNop()
63 +
64 + srv := httptest.NewServer(noError(caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
65 + return tm.ServeHTTP(w, r, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
66 + _, err := io.Copy(io.Discard, r.Body)
67 + if err != nil {
68 + http.Error(w, err.Error(), http.StatusRequestTimeout)
69 + return nil
70 + }
71 + w.WriteHeader(http.StatusOK)
72 + return nil
73 + }))
74 + })))
75 + defer srv.Close()
76 +
77 + // each gap is well under timeout, but the cumulative transfer time
78 + // is well over it; a hard (non-idle-reset) deadline would kill this
79 + body := &pacedReader{delay: timeout / 4, chunkSize: 8, chunkCount: 8}
80 + resp, err := http.Post(srv.URL, "application/octet-stream", body)
81 + require.NoError(t, err)
82 + defer resp.Body.Close()
83 + assert.Equal(t, http.StatusOK, resp.StatusCode)
84 +}
85 +
86 +func TestTimeouts_WriteMaxChunkOverride(t *testing.T) {
87 + const size = 10000
88 + const maxChunk = 100
89 +
90 + tm := Timeouts{WriteTimeout: time.Second, MaxWriteChunk: maxChunk}
91 + tm.logger = zap.NewNop()
92 +
93 + srv := httptest.NewServer(noError(caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
94 + return tm.ServeHTTP(w, r, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
95 + _, err := w.Write(make([]byte, size))
96 + return err
97 + }))
98 + })))
99 + defer srv.Close()
100 +
101 + resp, err := http.Get(srv.URL)
102 + require.NoError(t, err)
103 + defer resp.Body.Close()
104 +
105 + n, err := io.Copy(io.Discard, resp.Body)
106 + require.NoError(t, err)
107 + assert.EqualValues(t, size, n)
108 +}