Files
Kévin Dunglas 0cf03d32f7 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.
2026-08-21 21:17:26 -06:00

193 lines
5.8 KiB
Go

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