Go http.Server Timeouts: Why Slowloris Still Gets Through
Suppose you carefully configure an http.Server, then start the application like this:
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Fatal(http.ListenAndServe(":8080", mux))
The configured server is never used. The package-level http.ListenAndServe creates another server whose timeout fields have their zero values, meaning no limits. This is a remarkably tidy way to write secure-looking code which does absolutely nothing.
The immediate fix is log.Fatal(srv.ListenAndServe()). The broader problem is that even correctly applied server timeouts cover different phases of an HTTP connection. Setting four vaguely plausible durations does not produce one general anti-Slowloris shield.
The classic attack is an incomplete header
A traditional Slowloris client opens many TCP connections and sends each HTTP request header extremely slowly. It might transmit a byte often enough to keep ordinary idle detection happy, but never finish the blank line which terminates the headers. Each connection consumes a file descriptor, memory and a goroutine while doing almost no useful work.
ReadHeaderTimeout is Go's direct defence. It places an absolute read deadline on receiving the request headers. It is not an inactivity timer which restarts whenever another byte arrives. A client given five seconds gets roughly five seconds in total, not five seconds per character. Dribbling data therefore does not keep extending its allowance.
The zero value is the first trap. According to the http.Server documentation, a zero ReadHeaderTimeout inherits ReadTimeout. If both are zero, header reading has no deadline. Negative values explicitly disable the corresponding timeout.
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 16 << 10,
}
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
With this server, a classic slow-header connection does not survive indefinitely. MaxHeaderBytes adds a size bound, but size and time solve different problems. A client can send a small header forever when there is no deadline, or send an oversized header quickly when there is no sensible size limit.
What the four timeouts actually cover
ReadHeaderTimeoutlimits the time spent reading request headers. Once the complete header has been parsed, this particular deadline has done its job.ReadTimeoutlimits reading the entire request, including its body. If it is set, the time spent reading headers consumes part of that allowance.WriteTimeoutlimits response writing. It does not stop a client which has not completed its request headers, although for TLS the server also considers it when choosing a handshake deadline.IdleTimeoutlimits the wait for the next request on a keep-alive connection. It is not the deadline for the first request and does not govern a body currently being read.
The implementation is worth inspecting because the sequence is clearer than the field names. Go sets the header read deadline, parses the request, and then changes the read deadline for the body according to ReadTimeout. The relevant path is visible in net/http/server.go.
This explains the common report that Slowloris "still works" after adding ReadHeaderTimeout. Frequently the test is no longer a header attack. The client completes a valid header promptly, advertises a body with Content-Length, and then sends that body one byte at a time. The header timeout has correctly expired from relevance.
The body-shaped hole
Leaving ReadTimeout at zero can be reasonable. A single whole-request deadline is awkward when one route accepts a 2 KiB JSON document and another accepts a large backup. Give both the same short limit and legitimate uploads fail; give both an hour and the JSON endpoint becomes an excellent place to park connections.
A better arrangement is often a short global header timeout plus route-specific body size and time limits:
func acceptJSON(w http.ResponseWriter, r *http.Request) {
const maxBody = 1 << 20
r.Body = http.MaxBytesReader(w, r.Body, maxBody)
controller := http.NewResponseController(w)
if err := controller.SetReadDeadline(time.Now().Add(10 * time.Second)); err != nil {
http.Error(w, "cannot apply request deadline", http.StatusInternalServerError)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "request body too large or too slow", http.StatusRequestTimeout)
return
}
if !json.Valid(body) {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
}
http.MaxBytesReader bounds storage and parsing work. ResponseController.SetReadDeadline gives this route an absolute body-reading deadline. They are complementary: a one-megabyte limit alone still permits a client to deliver that megabyte over several days.
The status response is best effort. Once a socket deadline has fired, writing a neat HTTP error to an uncooperative peer may fail, and that is fine. Releasing the connection is the security property that matters.
For large uploads, an absolute ten-second deadline is crude. An application may instead refresh a deadline while making meaningful progress, effectively enforcing a minimum transfer rate. That requires deliberate handler logic and limits on how often progress buys more time. Resetting the deadline after every byte merely rebuilds the original problem with extra code.
WriteTimeout is not the mirror image
It is tempting to configure ReadTimeout: 10s and WriteTimeout: 10s and declare the connection symmetrical. It is not. WriteTimeout is an absolute response-writing limit, which makes it troublesome for streaming responses, server-sent events and large downloads. A legitimate stream can be active and healthy when the deadline arrives.
For ordinary JSON APIs, a bounded WriteTimeout is usually sensible. Streaming handlers need a consciously different policy, commonly per-write deadlines set through ResponseController, plus cancellation when the client disappears. One server-wide value cannot express both behaviours particularly well.
Timeouts limit duration, not population
Even a five-second ReadHeaderTimeout allows an attacker to occupy a connection for five seconds. If new connections arrive faster than old ones expire, file descriptors, TLS handshakes, memory or scheduler time can still be exhausted. A sufficiently distributed attack can also send complete requests within every configured deadline.
This is where connection limits and infrastructure controls enter. Put a finite bound on concurrent connections at a reverse proxy or load balancer, apply per-source limits where they make operational sense, set realistic process file-descriptor limits, and monitor connections stuck in header-reading states. TLS termination also needs handshake limits and resource controls because an attacker need not reach HTTP at all.
Timeouts should therefore be tested by phase. Open a socket and never finish the headers. Finish the headers and stall the body. Keep a connection idle after a response. Read a large response painfully slowly. Each experiment exercises a different field, and each should leave the process with a bounded number of lingering connections.
A properly applied ReadHeaderTimeout does stop the textbook slow-header trick. What gets through is usually the neighbouring phase, an unused server configuration, or an arrival rate that a duration limit was never designed to control. The names look like a security checklist; the useful model is a state machine with a separate budget at each edge.