Skip to content

feat: add WithRequestBodyTimeout to bound slow request bodies#2465

Open
iliaal wants to merge 2 commits into
php:mainfrom
iliaal:feat/request-body-timeout
Open

feat: add WithRequestBodyTimeout to bound slow request bodies#2465
iliaal wants to merge 2 commits into
php:mainfrom
iliaal:feat/request-body-timeout

Conversation

@iliaal
Copy link
Copy Markdown

@iliaal iliaal commented Jun 4, 2026

Problem

go_read_post loops on request.Body.Read with no per-read deadline. A client that announces a body (or a large Content-Length) then dribbles or stalls holds a PHP thread for the lifetime of the connection. With a bounded thread pool, max_threads such connections exhaust the pool and legitimate requests get rejected at max_wait_time. This is a slow-POST DoS. It's bounded and recoverable, not a code defect, but the default posture is weaker than the common nginx+php-fpm stack, which buffers the body and ships a default client_body_timeout.

What this adds

WithRequestBodyTimeout(d) sets an idle timeout on body reads: the deadline resets before each read via http.ResponseController.SetReadDeadline, so a steady upload of any size succeeds while a stalled one is cut off and the thread is released. Zero (the default) keeps today's blocking behavior. Best effort: if the ResponseWriter can't expose a deadline, the read proceeds without one.

Why not just Caddy read_timeout

read_timeout maps to http.Server.ReadTimeout, a total deadline for the entire request including the body, set server-wide. That's the wrong tool for three reasons, which is why it's off by default:

Aspect read_timeout (ReadTimeout) WithRequestBodyTimeout
Semantics total wall-clock for headers+body idle: fires only on a stall
Large uploads a slow-but-steady upload hits the cap steady upload of any size passes
Scope server-wide per request / per route
SSE, websockets, Mercure also bounds long-lived reads, can break them only the body read

It also does nothing for library users of frankenphp, who have no Caddy. For a deployment with no SSE and no large uploads, read_timeout is the simpler zero-code answer; this option is for the cases it can't express.

@iliaal iliaal force-pushed the feat/request-body-timeout branch from ce3a248 to 1537e4a Compare June 4, 2026 01:41
Copy link
Copy Markdown
Contributor

@henderkes henderkes left a comment

Choose a reason for hiding this comment

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

Generally good, but the option is never actually registered in ServeHTTP, so it's never actually taking effect?

Comment on lines +56 to +58
require.Contains(t, string(resp), "200 OK")
// PHP saw an empty body: php://input read zero bytes.
require.Contains(t, string(resp), "read=0")
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.

do we want to silently pass a failed read to php as if everything was fine and then expect 200 back? I'm a bit confused here.

@iliaal
Copy link
Copy Markdown
Author

iliaal commented Jun 4, 2026

The option does take effect at the library level: it sets fc.requestBodyTimeout, which go_read_post reads to apply the deadline, and ServeHTTP just retrieves that same fc from the request context. But you're right that nothing wired it into the Caddy module, so a Caddyfile deployment couldn't switch it on. I've added a request_body_timeout directive (latest commit) so it's configurable end to end. requestbodytimeout_test.go covers the library path; config_test.go covers the directive parsing.

iliaal added 2 commits June 4, 2026 08:13
go_read_post blocks in request.Body.Read with no per-read deadline, so a
slow client that announces a body and then stalls holds a PHP thread for
the lifetime of the connection. With a bounded thread pool this is a
slow-POST denial of service.

WithRequestBodyTimeout sets an idle timeout: the read deadline is reset
before every read, so a steady upload of any size still succeeds while a
stalled one is cut off and the thread is released. Zero (the default)
keeps the previous blocking behaviour.

The timeout uses http.ResponseController.SetReadDeadline; when the
ResponseWriter cannot expose a deadline the read proceeds without one.
Wire WithRequestBodyTimeout into the Caddy module so the body timeout is
configurable from a Caddyfile, not only the library API:

    php {
        request_body_timeout 5s
    }
@iliaal iliaal force-pushed the feat/request-body-timeout branch from 4929917 to 35f90d5 Compare June 4, 2026 12:13
@iliaal iliaal requested a review from henderkes June 4, 2026 12:14
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