Backend Systems · Request Handling
Header Limits, Deadlines, and Slow-Connection Defence
Why an attacker who sends one byte a second ties up more than CPU, how absolute deadlines differ from per-read timeouts, and what limits a server has to count.
A request that never completes still consumes the resources reserved for it. The resources are not CPU cycles in any meaningful quantity; they are a socket, a buffer sized for the largest header block the server will accept, and a slot in whatever concurrency limit the server enforces. An attacker who opens many connections and sends one header byte every few seconds holds those resources for as long as the server permits, and the server’s own configuration determines how cheap the attack is.
This asymmetry is general: the cost to the attacker is a few bytes per interval, and the cost to the defender is a connection slot held idle. Any defence that is proportional to the attacker’s traffic fails against it.
Counting what has to be counted
Four quantities need limits, and servers that limit only some of them leave the others as the cheap path.
The first is the size of the header block. A server reading until the empty line has no natural stopping point, so it needs a cap on the total bytes consumed before the terminator. Go’s server exposes this as a field whose default allows a megabyte of headers, which is generous enough that a thousand concurrent slow requests can hold a gigabyte of buffer between them. Lowering it to a few kilobytes is a decision that must be checked against real clients, since cookies accumulate and some authentication schemes produce long tokens.
The second is the number of header fields. A block of many small fields costs more to parse per byte than one long value, because each field requires a name lookup and a map insertion. Some servers cap the count separately for that reason.
The third is the length of the request line, which is where an absurd URL arrives. The matching status is 414 URI Too Long, and the practical limit is often the same buffer as the headers.
The fourth is the size of any single field value. A limit on the total block still allows one field to consume all of it, and a value that is copied or lowercased per request turns that into a repeated allocation.
The status codes for these rejections are specific and worth using: 431 Request Header Fields Too Large for the header block, 414 for the target, 408 for a connection that did not deliver a complete request in time.
Slow arrivals and why per-read timeouts fail
A connection that delivers one byte per second defeats any timeout measured between reads. Each read completes, each completion resets the timer, and the header block can be extended for as long as the attacker cares to send bytes. The connection never looks idle to the server, so an idle-connection reaper never touches it, and the only way it ends is by the attacker’s choice.
The defence is a deadline fixed at a point in time, not a duration reset by activity. The server records when the connection was accepted or when the first byte of a request arrived, and refuses to continue past a fixed offset from that moment. A request that has not completed its headers within that window is closed with 408, and the attacker’s per-byte progress buys nothing, because the total time is bounded no matter how the bytes are distributed.
Two deadlines are needed rather than one. The header deadline bounds the time to produce a complete header block. A body deadline bounds the time to deliver the body, and it must also be absolute if slow bodies are a concern. A third, longer deadline bounds how long a connection may sit idle between requests, which is what keeps keep-alive connections from accumulating forever after their clients vanish.
The distinction between a read deadline and a write deadline matters too. A slow client that reads its response one byte at a time holds a connection and a response buffer; a write deadline closes it. Servers that set only read timeouts still lose connection slots to clients that never read.
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second, // header block only
ReadTimeout: 30 * time.Second, // entire request including body
WriteTimeout: 30 * time.Second, // from end of headers to end of response
IdleTimeout: 120 * time.Second, // between requests on a reused connection
MaxHeaderBytes: 16 << 10, // 16 KiB, well under the 1 MiB default
}
Configuring a front end and a back end to agree
In a deployment with a reverse proxy, the proxy usually holds the client connection and the application server holds a connection from the proxy. The two have separate limits, and the effective limit is whichever is reached first.
A proxy with header buffers of a few kilobytes will reject a header block that its default accepts only after reading into an overflow buffer, and the documented defaults for the common servers are not large: a 1 KiB primary buffer with a small number of larger buffers for oversized blocks, and a header timeout measured in tens of seconds. Setting the application’s limit far above the proxy’s is harmless for well-behaved traffic and misleading for hostile traffic, because the proxy is the component that actually sees it. Setting it far below produces 431 from the application for requests the proxy accepted, which is confusing to diagnose unless the responses are distinguishable.
The front end also has to decide what to do with a connection that fails in the middle of a header block. Closing without a response is legal and cheap; sending 408 is friendlier and costs a write. Either way, the connection must not be returned to any pool.
Admission control above the limits
Per-connection limits bound the cost of one connection. They do not bound the number of connections, so a server that accepts without limit can be exhausted by clients that respect every timeout and simply open more sockets.
The bound has to be on concurrent connections, with an explicit decision for what happens when it is reached. Accepting and immediately closing costs one syscall and delivers nothing; accepting and answering 503 Service Unavailable with Retry-After tells the client to come back later, which works for cooperative clients and is ignored by hostile ones. Refusing to accept at all leaves connections in the kernel’s queue, where they will eventually be reset, which is the cheapest option for the server and the least informative for the client.
A global cap is crude and effective. A per-source cap is fairer but depends on knowing the source, which behind a proxy means trusting a header the proxy sets; that trust boundary is the subject of proxy headers and hop-by-hop handling, and getting it wrong turns a rate limit into an attacker-controlled parameter.
Where the limits interact with framing
Header limits are also framing decisions. A server that rejects an oversized header block must close the connection rather than continue parsing, because it has stopped consuming at an arbitrary point in the message and cannot resume. A server that accepts a header block and then discovers conflicting length fields has the same obligation, for the reasons covered in HTTP/1.1 message framing. In both cases the connection is unusable and reuse would mean reading one message’s tail as another’s start.
Time limits interact with the concurrency model in the same way. In a thread-per-connection design, a slow connection occupies a thread for the duration of its deadline, so the deadline times the connection cap equals the maximum number of threads that can be tied up by slow clients. In an event loop, the connection occupies an entry in the interest set and a buffer, which is cheaper per connection but still bounded by the same arithmetic. The deadline and the cap are a pair; setting one without the other leaves the product unbounded.
One further asymmetry is worth stating plainly. Every limit described here is a limit on the server’s tolerance, not on the client’s behaviour, and the client can always open another connection once the old one is closed. Defences that reduce the per-connection cost of a slow client, by lowering buffer sizes and shortening deadlines, raise the number of connections an attacker needs to hold the same resources, which is the only quantity under the server’s control.
References
- RFC 9110: HTTP Semantics — IETF
- RFC 6585: Additional HTTP Status Codes — IETF
- nginx documentation — ngx_http_core_module — nginx
- Go standard library — net/http Server — Go project documentation