mirror of
https://github.com/caddyserver/caddy.git
synced 2026-10-22 11:55:16 +00:00
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.
This commit is contained in:
parent
45ba3278b5
commit
0cf03d32f7
12 changed files with 1053 additions and 59 deletions
|
|
@ -60,6 +60,7 @@ var defaultDirectiveOrder = []string{
|
|||
"header",
|
||||
"copy_response_headers", // only in reverse_proxy's handle_response
|
||||
"request_body",
|
||||
"timeouts", // wraps the response writer, so keep it close to the real writer, ahead of encode/push/etc.
|
||||
|
||||
"redir",
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,12 @@ type serverOptions struct {
|
|||
PacketConnWrappersRaw []json.RawMessage
|
||||
ReadTimeout caddy.Duration
|
||||
ReadHeaderTimeout caddy.Duration
|
||||
ReadIdleTimeout caddy.Duration
|
||||
ReadMinRate int64
|
||||
WriteTimeout caddy.Duration
|
||||
WriteIdleTimeout caddy.Duration
|
||||
WriteMinRate int64
|
||||
MaxWriteChunk int
|
||||
IdleTimeout caddy.Duration
|
||||
KeepAliveInterval caddy.Duration
|
||||
KeepAliveIdle caddy.Duration
|
||||
|
|
@ -138,6 +143,24 @@ func unmarshalCaddyfileServerOptions(d *caddyfile.Dispenser) (any, error) {
|
|||
}
|
||||
serverOpts.ReadTimeout = caddy.Duration(dur)
|
||||
|
||||
case "read_body_idle":
|
||||
args := d.RemainingArgs()
|
||||
if len(args) < 1 || len(args) > 2 {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
dur, err := caddy.ParseDuration(args[0])
|
||||
if err != nil {
|
||||
return nil, d.Errf("parsing read_body_idle timeout duration: %v", err)
|
||||
}
|
||||
serverOpts.ReadIdleTimeout = caddy.Duration(dur)
|
||||
if len(args) == 2 {
|
||||
rate, err := strconv.ParseInt(args[1], 10, 64)
|
||||
if err != nil {
|
||||
return nil, d.Errf("parsing read_body_idle min_rate bytes/second: %v", err)
|
||||
}
|
||||
serverOpts.ReadMinRate = rate
|
||||
}
|
||||
|
||||
case "read_header":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
|
|
@ -158,6 +181,35 @@ func unmarshalCaddyfileServerOptions(d *caddyfile.Dispenser) (any, error) {
|
|||
}
|
||||
serverOpts.WriteTimeout = caddy.Duration(dur)
|
||||
|
||||
case "write_idle":
|
||||
args := d.RemainingArgs()
|
||||
if len(args) < 1 || len(args) > 2 {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
dur, err := caddy.ParseDuration(args[0])
|
||||
if err != nil {
|
||||
return nil, d.Errf("parsing write_idle timeout duration: %v", err)
|
||||
}
|
||||
serverOpts.WriteIdleTimeout = caddy.Duration(dur)
|
||||
if len(args) == 2 {
|
||||
rate, err := strconv.ParseInt(args[1], 10, 64)
|
||||
if err != nil {
|
||||
return nil, d.Errf("parsing write_idle min_rate bytes/second: %v", err)
|
||||
}
|
||||
serverOpts.WriteMinRate = rate
|
||||
}
|
||||
|
||||
case "write_max_chunk":
|
||||
var sizeStr string
|
||||
if !d.AllArgs(&sizeStr) {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
size, err := humanize.ParseBytes(sizeStr)
|
||||
if err != nil {
|
||||
return nil, d.Errf("parsing write_max_chunk: %v", err)
|
||||
}
|
||||
serverOpts.MaxWriteChunk = int(size)
|
||||
|
||||
case "idle":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
|
|
@ -389,7 +441,12 @@ func applyServerOptions(
|
|||
server.PacketConnWrappersRaw = opts.PacketConnWrappersRaw
|
||||
server.ReadTimeout = opts.ReadTimeout
|
||||
server.ReadHeaderTimeout = opts.ReadHeaderTimeout
|
||||
server.ReadIdleTimeout = opts.ReadIdleTimeout
|
||||
server.ReadMinRate = opts.ReadMinRate
|
||||
server.WriteTimeout = opts.WriteTimeout
|
||||
server.WriteIdleTimeout = opts.WriteIdleTimeout
|
||||
server.WriteMinRate = opts.WriteMinRate
|
||||
server.MaxWriteChunk = opts.MaxWriteChunk
|
||||
server.IdleTimeout = opts.IdleTimeout
|
||||
server.KeepAliveInterval = opts.KeepAliveInterval
|
||||
server.KeepAliveIdle = opts.KeepAliveIdle
|
||||
|
|
|
|||
|
|
@ -407,6 +407,15 @@ func (app *App) Provision(ctx caddy.Context) error {
|
|||
if srv.ReadHeaderTimeout == 0 {
|
||||
srv.ReadHeaderTimeout = defaultReadHeaderTimeout // see #6663
|
||||
}
|
||||
if srv.ReadIdleTimeout == 0 {
|
||||
srv.ReadIdleTimeout = defaultReadIdleTimeout
|
||||
}
|
||||
if srv.WriteIdleTimeout == 0 {
|
||||
srv.WriteIdleTimeout = defaultWriteIdleTimeout
|
||||
}
|
||||
if srv.MaxWriteChunk == 0 {
|
||||
srv.MaxWriteChunk = DefaultMaxWriteChunk
|
||||
}
|
||||
}
|
||||
ctx.Context = oldContext
|
||||
return nil
|
||||
|
|
@ -891,6 +900,14 @@ const (
|
|||
// long time even on legitimately slow connections or
|
||||
// busy servers to read it.
|
||||
defaultReadHeaderTimeout = caddy.Duration(time.Minute)
|
||||
|
||||
// defaultReadIdleTimeout and defaultWriteIdleTimeout mitigate
|
||||
// slowloris-style attacks on the request body and response write.
|
||||
// Unlike a hard deadline, these are safe defaults even for large
|
||||
// payloads because the deadline is reset on every successful
|
||||
// read/write; only a stalled connection is affected.
|
||||
defaultReadIdleTimeout = caddy.Duration(time.Minute)
|
||||
defaultWriteIdleTimeout = caddy.Duration(time.Minute)
|
||||
)
|
||||
|
||||
// Interface guards
|
||||
|
|
|
|||
192
modules/caddyhttp/idletimeout.go
Normal file
192
modules/caddyhttp/idletimeout.go
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
// Copyright 2015 Matthew Holt and The Caddy Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package caddyhttp
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
// DefaultMaxWriteChunk is used by IdleTimeoutWriter when MaxChunk is zero.
|
||||
// See IdleTimeoutWriter for why a limit is needed at all; nginx's
|
||||
// analogous sendfile_max_chunk defaults to 2 MiB and is admin-tunable
|
||||
// for the same reason this is exposed as a field rather than a constant.
|
||||
const DefaultMaxWriteChunk = 64 * 1024
|
||||
|
||||
// IdleDeadline computes the next read/write deadline for the idle-reset
|
||||
// mechanism shared by IdleTimeoutReader and IdleTimeoutWriter.
|
||||
//
|
||||
// With MinRate == 0, the deadline is simply pushed forward on every
|
||||
// call (now+Timeout): a slow but steadily-progressing transfer is never
|
||||
// killed, while a connection that stalls (no bytes for the duration of
|
||||
// Timeout) is. This alone doesn't bound a transfer that trickles just
|
||||
// enough data to never go idle.
|
||||
//
|
||||
// With MinRate > 0, the allowance instead grows from a fixed Start
|
||||
// based on bytes transferred so far (1/MinRate seconds of extra
|
||||
// allowance per byte), matching Apache mod_reqtimeout's MinRate: a
|
||||
// trickle that doesn't sustain MinRate bytes/sec falls behind real
|
||||
// time and gets cut, even though no single call ever stalls.
|
||||
//
|
||||
// HardDeadline, if non-zero, caps the result either way, so this can't
|
||||
// silently defeat an explicitly configured ReadTimeout/WriteTimeout
|
||||
// ceiling.
|
||||
type IdleDeadline struct {
|
||||
Start time.Time
|
||||
Timeout time.Duration
|
||||
MinRate int64
|
||||
HardDeadline time.Time
|
||||
|
||||
transferred int64
|
||||
}
|
||||
|
||||
func (d *IdleDeadline) next() (deadline time.Time) {
|
||||
if d.MinRate > 0 {
|
||||
credit := time.Duration(d.transferred) * time.Second / time.Duration(d.MinRate)
|
||||
deadline = d.Start.Add(d.Timeout + credit)
|
||||
} else {
|
||||
deadline = time.Now().Add(d.Timeout)
|
||||
}
|
||||
if !d.HardDeadline.IsZero() && deadline.After(d.HardDeadline) {
|
||||
deadline = d.HardDeadline
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// IdleTimeoutReader wraps a request body with IdleDeadline, resetting
|
||||
// the read deadline before every Read call instead of bounding the
|
||||
// whole body transfer with a single hard deadline.
|
||||
type IdleTimeoutReader struct {
|
||||
io.ReadCloser
|
||||
Ctrl *http.ResponseController
|
||||
Deadline IdleDeadline
|
||||
Logger *zap.Logger
|
||||
|
||||
unsupported bool
|
||||
}
|
||||
|
||||
func (r *IdleTimeoutReader) Read(p []byte) (int, error) {
|
||||
if !r.unsupported {
|
||||
if err := r.Ctrl.SetReadDeadline(r.Deadline.next()); err != nil {
|
||||
r.unsupported = true
|
||||
if c := r.Logger.Check(zapcore.DebugLevel, "could not set read deadline"); c != nil {
|
||||
c.Write(zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
n, err := r.ReadCloser.Read(p)
|
||||
r.Deadline.transferred += int64(n)
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
// IdleTimeoutWriter wraps a ResponseWriter with IdleDeadline, resetting
|
||||
// the write deadline before every Write call, the same way
|
||||
// IdleTimeoutReader does for reads. A handler that pauses between
|
||||
// writes (e.g. streaming or SSE) is unaffected, since with MinRate == 0
|
||||
// the deadline only bounds the duration of the write actually in flight.
|
||||
//
|
||||
// MaxChunk bounds how much a single underlying Write/ReadFrom call is
|
||||
// allowed to cover; zero uses DefaultMaxWriteChunk. SetWriteDeadline
|
||||
// bounds the whole call it precedes, not just a stall within it:
|
||||
// net.Conn.Write loops internally until the entire buffer is sent
|
||||
// (unlike Read, which returns after one syscall), and
|
||||
// ResponseWriter.ReadFrom hands the entire remaining source to the
|
||||
// connection in one call, be it via sendfile or an internal buffered
|
||||
// copy loop. Without chunking, a single large Write or a large body
|
||||
// copied via io.Copy would have its whole transfer bounded by one
|
||||
// deadline, silently truncating a slow-but-healthy transfer exactly
|
||||
// like a hard WriteTimeout would. A 64 KiB default still preserves
|
||||
// most of the sendfile fast path's benefit (net/sendfile.go
|
||||
// special-cases *io.LimitedReader to keep using sendfile per chunk).
|
||||
type IdleTimeoutWriter struct {
|
||||
*ResponseWriterWrapper
|
||||
Ctrl *http.ResponseController
|
||||
Deadline IdleDeadline
|
||||
MaxChunk int
|
||||
Logger *zap.Logger
|
||||
|
||||
unsupported bool
|
||||
}
|
||||
|
||||
func (w *IdleTimeoutWriter) resetDeadline() {
|
||||
if w.unsupported {
|
||||
return
|
||||
}
|
||||
|
||||
if err := w.Ctrl.SetWriteDeadline(w.Deadline.next()); err != nil {
|
||||
w.unsupported = true
|
||||
if c := w.Logger.Check(zapcore.DebugLevel, "could not set write deadline"); c != nil {
|
||||
c.Write(zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *IdleTimeoutWriter) maxChunk() int {
|
||||
if w.MaxChunk > 0 {
|
||||
return w.MaxChunk
|
||||
}
|
||||
return DefaultMaxWriteChunk
|
||||
}
|
||||
|
||||
func (w *IdleTimeoutWriter) Write(p []byte) (int, error) {
|
||||
maxChunk := w.maxChunk()
|
||||
var total int
|
||||
for len(p) > 0 {
|
||||
chunk := p
|
||||
if len(chunk) > maxChunk {
|
||||
chunk = chunk[:maxChunk]
|
||||
}
|
||||
w.resetDeadline()
|
||||
n, err := w.ResponseWriterWrapper.Write(chunk)
|
||||
total += n
|
||||
w.Deadline.transferred += int64(n)
|
||||
p = p[n:]
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (w *IdleTimeoutWriter) ReadFrom(r io.Reader) (int64, error) {
|
||||
maxChunk := w.maxChunk()
|
||||
var total int64
|
||||
for {
|
||||
w.resetDeadline()
|
||||
n, err := w.ResponseWriterWrapper.ReadFrom(io.LimitReader(r, int64(maxChunk)))
|
||||
total += n
|
||||
w.Deadline.transferred += n
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
if n < int64(maxChunk) {
|
||||
return total, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Interface guards
|
||||
var (
|
||||
_ io.ReadCloser = (*IdleTimeoutReader)(nil)
|
||||
_ http.ResponseWriter = (*IdleTimeoutWriter)(nil)
|
||||
_ io.ReaderFrom = (*IdleTimeoutWriter)(nil)
|
||||
)
|
||||
375
modules/caddyhttp/idletimeout_test.go
Normal file
375
modules/caddyhttp/idletimeout_test.go
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
// Copyright 2015 Matthew Holt and The Caddy Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package caddyhttp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// pacedReader emits chunkCount chunks of chunkSize bytes, sleeping delay
|
||||
// before each one, simulating a client that trickles a request body.
|
||||
type pacedReader struct {
|
||||
delay time.Duration
|
||||
chunkSize int
|
||||
chunkCount int
|
||||
}
|
||||
|
||||
func (p *pacedReader) Read(b []byte) (int, error) {
|
||||
if p.chunkCount <= 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
time.Sleep(p.delay)
|
||||
p.chunkCount--
|
||||
n := copy(b, make([]byte, p.chunkSize))
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func TestIdleTimeoutReader(t *testing.T) {
|
||||
const timeout = 150 * time.Millisecond
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
wrapped := &IdleTimeoutReader{
|
||||
ReadCloser: r.Body,
|
||||
Ctrl: http.NewResponseController(w),
|
||||
Deadline: IdleDeadline{Timeout: timeout},
|
||||
Logger: zap.NewNop(),
|
||||
}
|
||||
_, err := io.Copy(io.Discard, wrapped)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusRequestTimeout)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
t.Run("slow but steadily progressing upload is not killed", func(t *testing.T) {
|
||||
// each gap is well under timeout, but the cumulative transfer
|
||||
// time is well over it; a hard deadline would kill this
|
||||
body := &pacedReader{delay: timeout / 4, chunkSize: 8, chunkCount: 8}
|
||||
resp, err := http.Post(srv.URL, "application/octet-stream", body)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("stalled upload is aborted", func(t *testing.T) {
|
||||
// a single gap that exceeds timeout must trip the deadline
|
||||
body := &pacedReader{delay: timeout * 4, chunkSize: 8, chunkCount: 2}
|
||||
resp, err := http.Post(srv.URL, "application/octet-stream", body)
|
||||
if err != nil {
|
||||
// the connection may also be reset outright, which is fine
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIdleTimeoutReader_HardDeadlineCapsIdleReset(t *testing.T) {
|
||||
const idle = 500 * time.Millisecond
|
||||
const hard = 200 * time.Millisecond
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
wrapped := &IdleTimeoutReader{
|
||||
ReadCloser: r.Body,
|
||||
Ctrl: http.NewResponseController(w),
|
||||
Deadline: IdleDeadline{
|
||||
Timeout: idle,
|
||||
HardDeadline: time.Now().Add(hard),
|
||||
},
|
||||
Logger: zap.NewNop(),
|
||||
}
|
||||
_, err := io.Copy(io.Discard, wrapped)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusRequestTimeout)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// each gap is under the idle timeout, so the connection is never
|
||||
// idle, but the cumulative transfer time exceeds hardDeadline: an
|
||||
// explicit hard ceiling must still cut it off despite the ongoing
|
||||
// idle-reset activity
|
||||
body := &pacedReader{delay: hard, chunkSize: 8, chunkCount: 8}
|
||||
resp, err := http.Post(srv.URL, "application/octet-stream", body)
|
||||
if err != nil {
|
||||
// the connection may also be reset outright, which is fine
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestIdleTimeoutReader_MinRateCatchesTrickle(t *testing.T) {
|
||||
const idle = 250 * time.Millisecond
|
||||
const chunkDelay = 150 * time.Millisecond
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
wrapped := &IdleTimeoutReader{
|
||||
ReadCloser: r.Body,
|
||||
Ctrl: http.NewResponseController(w),
|
||||
Deadline: IdleDeadline{
|
||||
Start: time.Now(),
|
||||
Timeout: idle,
|
||||
// a huge min rate means even a full-size read call
|
||||
// earns virtually no extra credit, so a byte-sized
|
||||
// trickle can't keep the connection alive past idle
|
||||
MinRate: 100_000_000,
|
||||
},
|
||||
Logger: zap.NewNop(),
|
||||
}
|
||||
_, err := io.Copy(io.Discard, wrapped)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusRequestTimeout)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// each individual gap (150ms) is under the idle window (250ms), so
|
||||
// pure idle-reset alone would never trip; MinRate must still catch
|
||||
// this trickle since it never earns meaningful credit
|
||||
body := &pacedReader{delay: chunkDelay, chunkSize: 1, chunkCount: 6}
|
||||
resp, err := http.Post(srv.URL, "application/octet-stream", body)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestIdleTimeoutReader_MinRateAllowsSustainedRate(t *testing.T) {
|
||||
const idle = 250 * time.Millisecond
|
||||
const chunkDelay = 100 * time.Millisecond
|
||||
const minRate = 1000 // bytes/second
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
wrapped := &IdleTimeoutReader{
|
||||
ReadCloser: r.Body,
|
||||
Ctrl: http.NewResponseController(w),
|
||||
Deadline: IdleDeadline{
|
||||
Start: time.Now(),
|
||||
Timeout: idle,
|
||||
MinRate: minRate,
|
||||
},
|
||||
Logger: zap.NewNop(),
|
||||
}
|
||||
_, err := io.Copy(io.Discard, wrapped)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusRequestTimeout)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// 200 bytes every 100ms is 2000 bytes/second, comfortably above the
|
||||
// 1000 bytes/second minRate, so credit earned per chunk (200ms)
|
||||
// outpaces the real time elapsed per chunk (100ms): this transfer
|
||||
// must complete despite exceeding the idle window cumulatively
|
||||
body := &pacedReader{delay: chunkDelay, chunkSize: 200, chunkCount: 8}
|
||||
resp, err := http.Post(srv.URL, "application/octet-stream", body)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestIdleTimeoutWriter(t *testing.T) {
|
||||
const timeout = 150 * time.Millisecond
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
wrapped := &IdleTimeoutWriter{
|
||||
ResponseWriterWrapper: &ResponseWriterWrapper{ResponseWriter: w},
|
||||
Ctrl: http.NewResponseController(w),
|
||||
Deadline: IdleDeadline{Timeout: timeout},
|
||||
Logger: zap.NewNop(),
|
||||
}
|
||||
flusher, _ := wrapped.ResponseWriterWrapper.ResponseWriter.(http.Flusher)
|
||||
for range 8 {
|
||||
time.Sleep(timeout / 4)
|
||||
_, err := wrapped.Write(make([]byte, 8))
|
||||
if err != nil {
|
||||
t.Errorf("unexpected write error: %v", err)
|
||||
return
|
||||
}
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// each write gap is well under timeout, but the cumulative streaming
|
||||
// time is well over it; a hard deadline would kill this response
|
||||
resp, err := http.Get(srv.URL)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
n, err := io.Copy(io.Discard, resp.Body)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 64, n)
|
||||
}
|
||||
|
||||
func TestIdleTimeoutWriter_HardDeadlineCapsIdleReset(t *testing.T) {
|
||||
const idle = 500 * time.Millisecond
|
||||
const hard = 200 * time.Millisecond
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
wrapped := &IdleTimeoutWriter{
|
||||
ResponseWriterWrapper: &ResponseWriterWrapper{ResponseWriter: w},
|
||||
Ctrl: http.NewResponseController(w),
|
||||
Deadline: IdleDeadline{
|
||||
Timeout: idle,
|
||||
HardDeadline: time.Now().Add(hard),
|
||||
},
|
||||
Logger: zap.NewNop(),
|
||||
}
|
||||
flusher, _ := wrapped.ResponseWriterWrapper.ResponseWriter.(http.Flusher)
|
||||
for range 8 {
|
||||
time.Sleep(hard)
|
||||
if _, err := wrapped.Write(make([]byte, 8)); err != nil {
|
||||
return
|
||||
}
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// each write gap is under the idle timeout, so the connection is
|
||||
// never idle, but the cumulative streaming time exceeds
|
||||
// hardDeadline: an explicit hard ceiling must still cut it off,
|
||||
// whether that surfaces as a failed request, a truncated body, or
|
||||
// both
|
||||
resp, err := http.Get(srv.URL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
n, err := io.Copy(io.Discard, resp.Body)
|
||||
if err == nil {
|
||||
assert.Less(t, n, int64(64))
|
||||
}
|
||||
}
|
||||
|
||||
// readFromCounter is a fake http.ResponseWriter that records how many
|
||||
// times ReadFrom was called on it and the size of the largest call, so
|
||||
// tests can assert on chunking behavior deterministically instead of
|
||||
// depending on real TCP timing.
|
||||
type readFromCounter struct {
|
||||
*httptest.ResponseRecorder
|
||||
calls int
|
||||
maxCall int
|
||||
total int64
|
||||
writeCalls int
|
||||
maxWriteCall int
|
||||
}
|
||||
|
||||
func (c *readFromCounter) SetWriteDeadline(time.Time) error { return nil }
|
||||
|
||||
func (c *readFromCounter) ReadFrom(r io.Reader) (int64, error) {
|
||||
c.calls++
|
||||
n, err := io.Copy(io.Discard, r)
|
||||
c.total += n
|
||||
if int(n) > c.maxCall {
|
||||
c.maxCall = int(n)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (c *readFromCounter) Write(p []byte) (int, error) {
|
||||
c.writeCalls++
|
||||
if len(p) > c.maxWriteCall {
|
||||
c.maxWriteCall = len(p)
|
||||
}
|
||||
return c.ResponseRecorder.Write(p)
|
||||
}
|
||||
|
||||
func TestIdleTimeoutWriter_ReadFromChunksLargeTransfer(t *testing.T) {
|
||||
const size = DefaultMaxWriteChunk*3 + 100 // 3 full chunks plus a partial tail
|
||||
|
||||
counter := &readFromCounter{ResponseRecorder: httptest.NewRecorder()}
|
||||
wrapped := &IdleTimeoutWriter{
|
||||
ResponseWriterWrapper: &ResponseWriterWrapper{ResponseWriter: counter},
|
||||
Ctrl: http.NewResponseController(counter),
|
||||
Deadline: IdleDeadline{Timeout: time.Second},
|
||||
Logger: zap.NewNop(),
|
||||
}
|
||||
|
||||
n, err := wrapped.ReadFrom(bytes.NewReader(make([]byte, size)))
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, size, n)
|
||||
assert.EqualValues(t, size, counter.total)
|
||||
assert.LessOrEqual(t, counter.maxCall, DefaultMaxWriteChunk,
|
||||
"no single underlying ReadFrom call should cover more than DefaultMaxWriteChunk bytes, "+
|
||||
"since SetWriteDeadline bounds the whole call it precedes, not just a stall within it")
|
||||
assert.Equal(t, 4, counter.calls, "expected 3 full chunks plus a partial tail chunk")
|
||||
}
|
||||
|
||||
func TestIdleTimeoutWriter_WriteChunksLargePayload(t *testing.T) {
|
||||
const size = DefaultMaxWriteChunk*2 + 1
|
||||
|
||||
counter := &readFromCounter{ResponseRecorder: httptest.NewRecorder()}
|
||||
wrapped := &IdleTimeoutWriter{
|
||||
ResponseWriterWrapper: &ResponseWriterWrapper{ResponseWriter: counter},
|
||||
Ctrl: http.NewResponseController(counter),
|
||||
Deadline: IdleDeadline{Timeout: time.Second},
|
||||
Logger: zap.NewNop(),
|
||||
}
|
||||
|
||||
n, err := wrapped.Write(make([]byte, size))
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, size, n)
|
||||
assert.EqualValues(t, size, counter.ResponseRecorder.Body.Len())
|
||||
assert.LessOrEqual(t, counter.maxWriteCall, DefaultMaxWriteChunk,
|
||||
"no single underlying Write call should cover more than DefaultMaxWriteChunk bytes")
|
||||
assert.Equal(t, 3, counter.writeCalls, "expected 2 full chunks plus a 1-byte tail chunk")
|
||||
}
|
||||
|
||||
func TestIdleTimeoutWriter_MaxChunkOverride(t *testing.T) {
|
||||
const size = 1000
|
||||
const maxChunk = 100
|
||||
|
||||
counter := &readFromCounter{ResponseRecorder: httptest.NewRecorder()}
|
||||
wrapped := &IdleTimeoutWriter{
|
||||
ResponseWriterWrapper: &ResponseWriterWrapper{ResponseWriter: counter},
|
||||
Ctrl: http.NewResponseController(counter),
|
||||
Deadline: IdleDeadline{Timeout: time.Second},
|
||||
MaxChunk: maxChunk,
|
||||
Logger: zap.NewNop(),
|
||||
}
|
||||
|
||||
n, err := wrapped.Write(make([]byte, size))
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, size, n)
|
||||
assert.LessOrEqual(t, counter.maxWriteCall, maxChunk,
|
||||
"a configured MaxChunk should override DefaultMaxWriteChunk")
|
||||
assert.Equal(t, size/maxChunk, counter.writeCalls)
|
||||
}
|
||||
|
|
@ -15,8 +15,6 @@
|
|||
package requestbody
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
|
||||
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
|
||||
|
|
@ -46,28 +44,6 @@ func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error)
|
|||
}
|
||||
rb.MaxSize = int64(size)
|
||||
|
||||
case "read_timeout":
|
||||
var timeoutStr string
|
||||
if !h.AllArgs(&timeoutStr) {
|
||||
return nil, h.ArgErr()
|
||||
}
|
||||
timeout, err := time.ParseDuration(timeoutStr)
|
||||
if err != nil {
|
||||
return nil, h.Errf("parsing read_timeout: %v", err)
|
||||
}
|
||||
rb.ReadTimeout = timeout
|
||||
|
||||
case "write_timeout":
|
||||
var timeoutStr string
|
||||
if !h.AllArgs(&timeoutStr) {
|
||||
return nil, h.ArgErr()
|
||||
}
|
||||
timeout, err := time.ParseDuration(timeoutStr)
|
||||
if err != nil {
|
||||
return nil, h.Errf("parsing write_timeout: %v", err)
|
||||
}
|
||||
rb.WriteTimeout = timeout
|
||||
|
||||
case "set":
|
||||
var setStr string
|
||||
if !h.AllArgs(&setStr) {
|
||||
|
|
|
|||
|
|
@ -19,10 +19,6 @@ import (
|
|||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
|
||||
"github.com/caddyserver/caddy/v2"
|
||||
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
|
||||
|
|
@ -38,17 +34,9 @@ type RequestBody struct {
|
|||
// If more bytes are read, an error with HTTP status 413 is returned.
|
||||
MaxSize int64 `json:"max_size,omitempty"`
|
||||
|
||||
// EXPERIMENTAL. Subject to change/removal.
|
||||
ReadTimeout time.Duration `json:"read_timeout,omitempty"`
|
||||
|
||||
// EXPERIMENTAL. Subject to change/removal.
|
||||
WriteTimeout time.Duration `json:"write_timeout,omitempty"`
|
||||
|
||||
// This field permit to replace body on the fly
|
||||
// EXPERIMENTAL. Subject to change/removal.
|
||||
Set string `json:"set,omitempty"`
|
||||
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// CaddyModule returns the Caddy module information.
|
||||
|
|
@ -59,11 +47,6 @@ func (RequestBody) CaddyModule() caddy.ModuleInfo {
|
|||
}
|
||||
}
|
||||
|
||||
func (rb *RequestBody) Provision(ctx caddy.Context) error {
|
||||
rb.logger = ctx.Logger()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rb RequestBody) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
|
||||
if rb.Set != "" {
|
||||
if r.Body != nil {
|
||||
|
|
@ -83,24 +66,6 @@ func (rb RequestBody) ServeHTTP(w http.ResponseWriter, r *http.Request, next cad
|
|||
if rb.MaxSize > 0 {
|
||||
r.Body = errorWrapper{http.MaxBytesReader(w, r.Body, rb.MaxSize)}
|
||||
}
|
||||
if rb.ReadTimeout > 0 || rb.WriteTimeout > 0 {
|
||||
//nolint:bodyclose
|
||||
rc := http.NewResponseController(w)
|
||||
if rb.ReadTimeout > 0 {
|
||||
if err := rc.SetReadDeadline(time.Now().Add(rb.ReadTimeout)); err != nil {
|
||||
if c := rb.logger.Check(zapcore.ErrorLevel, "could not set read deadline"); c != nil {
|
||||
c.Write(zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
if rb.WriteTimeout > 0 {
|
||||
if err := rc.SetWriteDeadline(time.Now().Add(rb.WriteTimeout)); err != nil {
|
||||
if c := rb.logger.Check(zapcore.ErrorLevel, "could not set write deadline"); c != nil {
|
||||
c.Write(zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return next.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -69,11 +69,49 @@ type Server struct {
|
|||
// Default is 1 minute.
|
||||
ReadHeaderTimeout caddy.Duration `json:"read_header_timeout,omitempty"`
|
||||
|
||||
// How long to allow a read from a client's upload to stall before
|
||||
// aborting the connection, reset on every successful read. Unlike
|
||||
// ReadTimeout, which bounds the whole transfer with a single
|
||||
// deadline, this only fires if the connection actually stalls, so
|
||||
// it mitigates slowloris-style attacks without penalizing large
|
||||
// uploads from legitimately slow clients. Combine with ReadTimeout
|
||||
// for a hard ceiling on top.
|
||||
// Default is 1 minute.
|
||||
ReadIdleTimeout caddy.Duration `json:"read_idle_timeout,omitempty"`
|
||||
|
||||
// ReadMinRate, if set, requires the client to sustain at least this
|
||||
// many bytes/second, averaged from the start of the request body
|
||||
// read, or the connection is aborted. Unlike ReadIdleTimeout alone,
|
||||
// this also catches a trickle that sends just enough to never go
|
||||
// idle, but never accumulates real throughput (a "MinRate" in
|
||||
// Apache mod_reqtimeout terms). If zero, no rate is enforced and
|
||||
// ReadIdleTimeout resets to a flat window on every read instead.
|
||||
ReadMinRate int64 `json:"read_min_rate,omitempty"`
|
||||
|
||||
// WriteTimeout is how long to allow a write to a client. Note
|
||||
// that setting this to a small value when serving large files
|
||||
// may negatively affect legitimately slow clients.
|
||||
WriteTimeout caddy.Duration `json:"write_timeout,omitempty"`
|
||||
|
||||
// How long to allow a write to a client to stall before aborting
|
||||
// the connection, reset on every successful write, the same way
|
||||
// ReadIdleTimeout works for reads. A handler that streams a large
|
||||
// response, or pauses between writes (e.g. SSE), is unaffected as
|
||||
// long as each individual write keeps making progress. Combine
|
||||
// with WriteTimeout for a hard ceiling on top.
|
||||
// Default is 1 minute.
|
||||
WriteIdleTimeout caddy.Duration `json:"write_idle_timeout,omitempty"`
|
||||
|
||||
// WriteMinRate is like ReadMinRate, but for writes to the client.
|
||||
WriteMinRate int64 `json:"write_min_rate,omitempty"`
|
||||
|
||||
// MaxWriteChunk bounds how many bytes a single underlying write
|
||||
// operation is allowed to cover, so that WriteIdleTimeout/WriteMinRate
|
||||
// can actually apply between chunks of a large response instead of
|
||||
// being bounded by one deadline for the whole thing (nginx's
|
||||
// sendfile_max_chunk exists for the same reason). Default: 64 KiB.
|
||||
MaxWriteChunk int `json:"max_write_chunk,omitempty"`
|
||||
|
||||
// IdleTimeout is the maximum time to wait for the next request
|
||||
// when keep-alives are enabled. If zero, a default timeout of
|
||||
// 5m is applied to help avoid resource exhaustion.
|
||||
|
|
@ -532,6 +570,51 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
// guard against slowloris-style attacks by aborting the connection
|
||||
// if a read from the request body or a write to the response
|
||||
// stalls for longer than the configured timeout; the deadline is
|
||||
// reset on every successful read/write, so this doesn't affect
|
||||
// legitimately slow clients as long as they keep making progress.
|
||||
// hardDeadline, anchored to this handler's start, caps how far
|
||||
// that reset can push the deadline out, so an explicitly configured
|
||||
// ReadTimeout/WriteTimeout ceiling still applies on top instead of
|
||||
// being silently overwritten by the first read/write.
|
||||
rc := http.NewResponseController(w)
|
||||
var readHardDeadline, writeHardDeadline time.Time
|
||||
if s.ReadTimeout > 0 {
|
||||
readHardDeadline = start.Add(time.Duration(s.ReadTimeout))
|
||||
}
|
||||
if s.WriteTimeout > 0 {
|
||||
writeHardDeadline = start.Add(time.Duration(s.WriteTimeout))
|
||||
}
|
||||
if s.ReadIdleTimeout > 0 && r.Body != nil {
|
||||
r.Body = &IdleTimeoutReader{
|
||||
ReadCloser: r.Body,
|
||||
Ctrl: rc,
|
||||
Deadline: IdleDeadline{
|
||||
Start: start,
|
||||
Timeout: time.Duration(s.ReadIdleTimeout),
|
||||
MinRate: s.ReadMinRate,
|
||||
HardDeadline: readHardDeadline,
|
||||
},
|
||||
Logger: s.logger,
|
||||
}
|
||||
}
|
||||
if s.WriteIdleTimeout > 0 {
|
||||
w = &IdleTimeoutWriter{
|
||||
ResponseWriterWrapper: &ResponseWriterWrapper{ResponseWriter: w},
|
||||
Ctrl: rc,
|
||||
Deadline: IdleDeadline{
|
||||
Start: start,
|
||||
Timeout: time.Duration(s.WriteIdleTimeout),
|
||||
MinRate: s.WriteMinRate,
|
||||
HardDeadline: writeHardDeadline,
|
||||
},
|
||||
MaxChunk: s.MaxWriteChunk,
|
||||
Logger: s.logger,
|
||||
}
|
||||
}
|
||||
|
||||
// set the Server header
|
||||
h := w.Header()
|
||||
h["Server"] = serverHeader
|
||||
|
|
|
|||
|
|
@ -21,5 +21,6 @@ import (
|
|||
_ "github.com/caddyserver/caddy/v2/modules/caddyhttp/reverseproxy/forwardauth"
|
||||
_ "github.com/caddyserver/caddy/v2/modules/caddyhttp/rewrite"
|
||||
_ "github.com/caddyserver/caddy/v2/modules/caddyhttp/templates"
|
||||
_ "github.com/caddyserver/caddy/v2/modules/caddyhttp/timeouts"
|
||||
_ "github.com/caddyserver/caddy/v2/modules/caddyhttp/tracing"
|
||||
)
|
||||
|
|
|
|||
92
modules/caddyhttp/timeouts/caddyfile.go
Normal file
92
modules/caddyhttp/timeouts/caddyfile.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
// Copyright 2015 Matthew Holt and The Caddy Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package timeouts
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
|
||||
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
|
||||
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
|
||||
)
|
||||
|
||||
func init() {
|
||||
httpcaddyfile.RegisterHandlerDirective("timeouts", parseCaddyfile)
|
||||
}
|
||||
|
||||
func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
|
||||
h.Next() // consume directive name
|
||||
|
||||
t := new(Timeouts)
|
||||
|
||||
// configuration should be in a block
|
||||
for h.NextBlock(0) {
|
||||
switch h.Val() {
|
||||
case "read_timeout":
|
||||
args := h.RemainingArgs()
|
||||
if len(args) < 1 || len(args) > 2 {
|
||||
return nil, h.ArgErr()
|
||||
}
|
||||
timeout, err := time.ParseDuration(args[0])
|
||||
if err != nil {
|
||||
return nil, h.Errf("parsing read_timeout: %v", err)
|
||||
}
|
||||
t.ReadTimeout = timeout
|
||||
if len(args) == 2 {
|
||||
rate, err := strconv.ParseInt(args[1], 10, 64)
|
||||
if err != nil {
|
||||
return nil, h.Errf("parsing read_timeout min_rate: %v", err)
|
||||
}
|
||||
t.ReadMinRate = rate
|
||||
}
|
||||
|
||||
case "write_timeout":
|
||||
args := h.RemainingArgs()
|
||||
if len(args) < 1 || len(args) > 2 {
|
||||
return nil, h.ArgErr()
|
||||
}
|
||||
timeout, err := time.ParseDuration(args[0])
|
||||
if err != nil {
|
||||
return nil, h.Errf("parsing write_timeout: %v", err)
|
||||
}
|
||||
t.WriteTimeout = timeout
|
||||
if len(args) == 2 {
|
||||
rate, err := strconv.ParseInt(args[1], 10, 64)
|
||||
if err != nil {
|
||||
return nil, h.Errf("parsing write_timeout min_rate: %v", err)
|
||||
}
|
||||
t.WriteMinRate = rate
|
||||
}
|
||||
|
||||
case "max_write_chunk":
|
||||
var sizeStr string
|
||||
if !h.AllArgs(&sizeStr) {
|
||||
return nil, h.ArgErr()
|
||||
}
|
||||
size, err := humanize.ParseBytes(sizeStr)
|
||||
if err != nil {
|
||||
return nil, h.Errf("parsing max_write_chunk: %v", err)
|
||||
}
|
||||
t.MaxWriteChunk = int(size)
|
||||
|
||||
default:
|
||||
return nil, h.Errf("unrecognized timeouts subdirective '%s'", h.Val())
|
||||
}
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
127
modules/caddyhttp/timeouts/timeouts.go
Normal file
127
modules/caddyhttp/timeouts/timeouts.go
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
// Copyright 2015 Matthew Holt and The Caddy Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package timeouts
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/caddyserver/caddy/v2"
|
||||
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
|
||||
)
|
||||
|
||||
func init() {
|
||||
caddy.RegisterModule(Timeouts{})
|
||||
}
|
||||
|
||||
// Timeouts is a middleware that applies read/write idle timeouts, minimum
|
||||
// transfer rates, and a write chunk size cap to routes matching this
|
||||
// handler, independent of the server-wide equivalents.
|
||||
type Timeouts struct {
|
||||
// How long to allow a read from the request body to stall before
|
||||
// aborting the connection, reset on every successful read (like the
|
||||
// server-wide read_idle_timeout, but scoped to routes matching this
|
||||
// handler). If zero, no idle timeout is applied here.
|
||||
// EXPERIMENTAL. Subject to change/removal.
|
||||
ReadTimeout time.Duration `json:"read_timeout,omitempty"`
|
||||
|
||||
// ReadMinRate requires the client to sustain at least this many
|
||||
// bytes/second, averaged from the start of the read, or the
|
||||
// connection is aborted (Apache mod_reqtimeout's MinRate). Only
|
||||
// takes effect if ReadTimeout is also set.
|
||||
// EXPERIMENTAL. Subject to change/removal.
|
||||
ReadMinRate int64 `json:"read_min_rate,omitempty"`
|
||||
|
||||
// How long to allow a write to the client to stall before aborting
|
||||
// the connection, reset on every successful write (like the
|
||||
// server-wide write_idle_timeout, but scoped to routes matching this
|
||||
// handler). If zero, no idle timeout is applied here.
|
||||
// EXPERIMENTAL. Subject to change/removal.
|
||||
WriteTimeout time.Duration `json:"write_timeout,omitempty"`
|
||||
|
||||
// WriteMinRate is like ReadMinRate, but for writes to the client.
|
||||
// Only takes effect if WriteTimeout is also set.
|
||||
// EXPERIMENTAL. Subject to change/removal.
|
||||
WriteMinRate int64 `json:"write_min_rate,omitempty"`
|
||||
|
||||
// MaxWriteChunk bounds how many bytes a single underlying write
|
||||
// operation is allowed to cover, so WriteTimeout/WriteMinRate can
|
||||
// actually apply between chunks of a large response instead of
|
||||
// being bounded by one deadline for the whole thing. If zero,
|
||||
// caddyhttp.DefaultMaxWriteChunk is used.
|
||||
// EXPERIMENTAL. Subject to change/removal.
|
||||
MaxWriteChunk int `json:"max_write_chunk,omitempty"`
|
||||
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// CaddyModule returns the Caddy module information.
|
||||
func (Timeouts) CaddyModule() caddy.ModuleInfo {
|
||||
return caddy.ModuleInfo{
|
||||
ID: "http.handlers.timeouts",
|
||||
New: func() caddy.Module { return new(Timeouts) },
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Timeouts) Provision(ctx caddy.Context) error {
|
||||
t.logger = ctx.Logger()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t Timeouts) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
|
||||
if t.ReadTimeout <= 0 && t.WriteTimeout <= 0 {
|
||||
return next.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
//nolint:bodyclose
|
||||
rc := http.NewResponseController(w)
|
||||
start := time.Now()
|
||||
|
||||
if t.ReadTimeout > 0 && r.Body != nil {
|
||||
r.Body = &caddyhttp.IdleTimeoutReader{
|
||||
ReadCloser: r.Body,
|
||||
Ctrl: rc,
|
||||
Deadline: caddyhttp.IdleDeadline{
|
||||
Start: start,
|
||||
Timeout: t.ReadTimeout,
|
||||
MinRate: t.ReadMinRate,
|
||||
},
|
||||
Logger: t.logger,
|
||||
}
|
||||
}
|
||||
if t.WriteTimeout > 0 {
|
||||
w = &caddyhttp.IdleTimeoutWriter{
|
||||
ResponseWriterWrapper: &caddyhttp.ResponseWriterWrapper{ResponseWriter: w},
|
||||
Ctrl: rc,
|
||||
Deadline: caddyhttp.IdleDeadline{
|
||||
Start: start,
|
||||
Timeout: t.WriteTimeout,
|
||||
MinRate: t.WriteMinRate,
|
||||
},
|
||||
MaxChunk: t.MaxWriteChunk,
|
||||
Logger: t.logger,
|
||||
}
|
||||
}
|
||||
|
||||
return next.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// Interface guards
|
||||
var (
|
||||
_ caddy.Provisioner = (*Timeouts)(nil)
|
||||
_ caddyhttp.MiddlewareHandler = (*Timeouts)(nil)
|
||||
)
|
||||
108
modules/caddyhttp/timeouts/timeouts_test.go
Normal file
108
modules/caddyhttp/timeouts/timeouts_test.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// Copyright 2015 Matthew Holt and The Caddy Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package timeouts
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
|
||||
)
|
||||
|
||||
// pacedReader emits chunkCount chunks of chunkSize bytes, sleeping delay
|
||||
// before each one, simulating a client that trickles a request body.
|
||||
type pacedReader struct {
|
||||
delay time.Duration
|
||||
chunkSize int
|
||||
chunkCount int
|
||||
}
|
||||
|
||||
func (p *pacedReader) Read(b []byte) (int, error) {
|
||||
if p.chunkCount <= 0 {
|
||||
return 0, io.EOF
|
||||
}
|
||||
time.Sleep(p.delay)
|
||||
p.chunkCount--
|
||||
n := copy(b, make([]byte, p.chunkSize))
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// noError adapts a caddyhttp.Handler to a plain http.Handler for httptest.NewServer.
|
||||
func noError(h caddyhttp.Handler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.ServeHTTP(w, r); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeouts_ReadTimeoutIsIdleReset(t *testing.T) {
|
||||
const timeout = 150 * time.Millisecond
|
||||
|
||||
tm := Timeouts{ReadTimeout: timeout}
|
||||
tm.logger = zap.NewNop()
|
||||
|
||||
srv := httptest.NewServer(noError(caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
||||
return tm.ServeHTTP(w, r, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
||||
_, err := io.Copy(io.Discard, r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusRequestTimeout)
|
||||
return nil
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return nil
|
||||
}))
|
||||
})))
|
||||
defer srv.Close()
|
||||
|
||||
// each gap is well under timeout, but the cumulative transfer time
|
||||
// is well over it; a hard (non-idle-reset) deadline would kill this
|
||||
body := &pacedReader{delay: timeout / 4, chunkSize: 8, chunkCount: 8}
|
||||
resp, err := http.Post(srv.URL, "application/octet-stream", body)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestTimeouts_WriteMaxChunkOverride(t *testing.T) {
|
||||
const size = 10000
|
||||
const maxChunk = 100
|
||||
|
||||
tm := Timeouts{WriteTimeout: time.Second, MaxWriteChunk: maxChunk}
|
||||
tm.logger = zap.NewNop()
|
||||
|
||||
srv := httptest.NewServer(noError(caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
||||
return tm.ServeHTTP(w, r, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
|
||||
_, err := w.Write(make([]byte, size))
|
||||
return err
|
||||
}))
|
||||
})))
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Get(srv.URL)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
n, err := io.Copy(io.Discard, resp.Body)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, size, n)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue