Skip to content

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

Open
dunglas wants to merge 10 commits into
caddyserver:masterfrom
dunglas:slowloris-idle-timeout
Open

caddyhttp: mitigate slowloris via idle read/write deadlines#7913
dunglas wants to merge 10 commits into
caddyserver:masterfrom
dunglas:slowloris-idle-timeout

Conversation

@dunglas

@dunglas dunglas commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds ReadIdleTimeout/WriteIdleTimeout (read_idle_timeout/write_idle_timeout in JSON, read_body_idle/write_idle in the Caddyfile timeouts block), reset on every successful read/write via http.ResponseController. A stalled connection is killed; a slow-but-progressing one isn't — matching nginx's client_body_timeout/send_timeout semantics.

The existing ReadTimeout/WriteTimeout fields are untouched: same hard-deadline-over-the-whole-transfer semantics, same default (0/unbounded) as before. No behavior change for existing configs. Combine an idle timeout with its hard counterpart for a ceiling on top — the idle-reset deadline is capped so it can't silently push past an explicitly configured ReadTimeout/WriteTimeout.

Also adds ReadMinRate/WriteMinRate (bytes/second, 0 = disabled), matching Apache mod_reqtimeout's MinRate. Idle-reset alone doesn't bound a client that trickles just enough data to never go idle; with a min rate set, the allowed deadline grows 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, even though no single read/write ever stalls. In the Caddyfile, min rate is a second, optional argument on the idle-timeout directive rather than a separate one: read_body_idle 60s 100/write_idle 60s 100.

All new fields default to 1 minute (idle) / 0 (min rate, opt-in) — safe to default the idle timeouts since they're new fields, so no existing config could have depended on a different value.

Write chunking

SetWriteDeadline bounds the whole call it precedes, not just a stall within it: net.Conn.Write loops internally until a buffer is fully sent, and ResponseWriter.ReadFrom hands the entire remaining source to the connection in one call (sendfile or an internal buffered copy loop — the latter always for TLS, since *tls.Conn isn't an io.ReaderFrom). Without chunking, a single large Write, or any response 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. This is the same bug independently found and fixed the same way in FrankenPHP's go_ub_write (php/frankenphp#2574), and nginx hit it historically too (sendfile_max_chunk, added after a single fast connection could seize a worker process entirely).

Fixed by capping each underlying Write/ReadFrom call and resetting the deadline between chunks. The cap is configurable (MaxWriteChunk/max_write_chunk on Server, write_max_chunk in the Caddyfile timeouts block), defaulting to 64 KiB — matching nginx's own tunable sendfile_max_chunk. net/sendfile.go special-cases *io.LimitedReader, so chunked ReadFrom still gets the sendfile fast path per chunk.

Per-route granularity

New timeouts handler (http.handlers.timeouts, Caddyfile directive timeouts) applies the same idle-reset ReadTimeout/WriteTimeout/ReadMinRate/WriteMinRate/MaxWriteChunk per-route, independent of the rest of the server block — matching nginx's location{} and Apache's <Directory> scoping. Same Caddyfile shape as the server-wide option: read_timeout 60s 100/write_timeout 60s 100 take the min rate as an optional second argument.

Kept separate from request_body rather than bolted onto it: request_body is about the request body specifically (max_size, set), while write-side pacing is a response concern that has nothing to do with the request body. A dedicated handler keeps that boundary clean and mirrors the server-wide timeouts option one level down.

Covers HTTP/1.1, HTTP/2, and HTTP/3 (quic-go's http3.responseWriter implements SetReadDeadline/SetWriteDeadline directly, on the same stream used for the request body).

Assistance Disclosure

This PR has been designed and reviewed by me, the code has been written by Claude Code.

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.
Copilot AI review requested due to automatic review settings July 28, 2026 10:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.
Copilot AI review requested due to automatic review settings July 28, 2026 11:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.
Copilot AI review requested due to automatic review settings July 28, 2026 11:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

dunglas added 2 commits July 28, 2026 13:37
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.
Matches the named-return style already used by ResponseWriterWrapper.ReadFrom.
Copilot AI review requested due to automatic review settings July 28, 2026 12:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.
Copilot AI review requested due to automatic review settings July 29, 2026 14:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

dunglas added 2 commits July 29, 2026 18:13
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.
…eChunk

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.
Copilot AI review requested due to automatic review settings July 29, 2026 16:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

dunglas added 2 commits July 29, 2026 21:23
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.
…andler

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.
Copilot AI review requested due to automatic review settings July 29, 2026 19:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants