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:
Kévin Dunglas 2026-08-22 05:17:26 +02:00 • committed by GitHub
parent 45ba3278b5
commit 0cf03d32f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1053 additions and 59 deletions

View file

@ -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",

View file

@ -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

View file

@ -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

View 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)
)

View 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)
}

View file

@ -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) {

View file

@ -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)
}

View file

@ -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

View file

@ -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"
)

View 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
}

View 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)
)

View 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)
}