Go's ReadHeaderTimeout: The http.Server Setting Everyone Skips
If you've run gosec or golangci-lint against a Go HTTP service in the last few years, you've probably met rule G112: "Potential Slowloris Attack" or something close to it, complaining that your http.Server literal has no ReadHeaderTimeout. Most people add the field, the warning goes away, and they move on assuming the server is now safe from slow-client attacks. That assumption is wrong, and the reason why is a genuinely interesting quirk of how net/http computes read deadlines.
Two timeouts, not one
The confusion starts because http.Server has both a ReadTimeout and a ReadHeaderTimeout, and most tutorials only mention the first:
type Server struct {
// ReadTimeout is the maximum duration for reading the entire
// request, including the body. A zero or negative value means
// there will be no timeout.
ReadTimeout time.Duration
// ReadHeaderTimeout is the amount of time allowed to read
// request headers. The connection's read deadline is reset
// after reading the headers and the Handler can decide what
// is considered too slow for the body. If ReadHeaderTimeout
// is zero, the value of ReadTimeout is used. If both are
// zero, there is no timeout.
ReadHeaderTimeout time.Duration
// ...
}
That last sentence in the ReadHeaderTimeout comment is the whole article in one line: if you don't set it, Go quietly falls back to whatever ReadTimeout is. If neither is set, which is the default for a bare &http.Server{}, headers can trickle in at one byte every few seconds forever, each stalled connection sitting on a goroutine and a file descriptor. That's classic Slowloris, and it's why the linters flag it.
Internally, net/http resolves the two fields through a small helper before a connection starts reading anything:
func (s *Server) readHeaderTimeout() time.Duration {
if s.ReadHeaderTimeout != 0 {
return s.ReadHeaderTimeout
}
return s.ReadTimeout
}
That value becomes the read deadline on the raw connection before a single header byte is parsed. Fine so far. The part that trips people up happens next, once the headers are actually in.
What happens after the headers arrive
Once readRequest has finished parsing the header block, it resets the connection's read deadline for the body. But it does not reuse ReadHeaderTimeout for that, and it does not extend the header deadline. It computes a completely separate deadline, straight from ReadTimeout, measured from when the connection started being served:
t0 := time.Now()
var wholeReqDeadline time.Time
if d := c.server.ReadTimeout; d > 0 {
wholeReqDeadline = t0.Add(d)
}
// ... headers get read using the header deadline ...
c.rwc.SetReadDeadline(wholeReqDeadline)
Read that carefully: wholeReqDeadline only gets a value if ReadTimeout is greater than zero. ReadHeaderTimeout never enters into it. So if you did exactly what the linter told you to do and added ReadHeaderTimeout without also setting ReadTimeout, the deadline that gets applied to the body is the zero time.Time. Calling SetReadDeadline with a zero value clears any deadline on the connection. The body read now has no timeout at all.
Which means a server configured like this:
srv := &http.Server{
Addr: ":8443",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second, // satisfies the linter
}
is genuinely protected against a client that trickles headers in slowly. It is not protected against a client that sends a complete, well-formed header block immediately, declares a huge Content-Length, and then trickles the body in one byte at a time. Something as blunt as this against the server above will hold a connection open indefinitely:
conn, err := net.Dial("tcp", "target:8443")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
fmt.Fprint(conn, "POST /upload HTTP/1.1\r\n")
fmt.Fprint(conn, "Host: target\r\n")
fmt.Fprint(conn, "Content-Length: 1000000000\r\n")
fmt.Fprint(conn, "\r\n")
for {
conn.Write([]byte("x"))
time.Sleep(20 * time.Second)
}
The header block arrives well inside the 5 second header deadline, so that check passes cleanly. From that point on, the connection has no read deadline whatsoever, and the handler (which is presumably calling io.ReadAll(r.Body) or similar, waiting for the full declared content length) just sits there. Run enough of these in parallel and you're back to a Slowloris-shaped resource exhaustion problem, just further along the request lifecycle than the one ReadHeaderTimeout was added to stop.
Setting both properly
The straightforward fix is to stop treating ReadHeaderTimeout as a standalone checkbox and set ReadTimeout alongside it:
srv := &http.Server{
Addr: ":8443",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
Worth noting: ReadTimeout here covers headers and body together, measured from the moment the connection starts being served, not reset after headers finish. If your header deadline eats 4 of your 30 seconds, the handler only gets 26 seconds to read the body, not a fresh 30. For most APIs that's an acceptable trade, but it's a bad fit for anything that deliberately accepts slow, large uploads, since a legitimate but slow uploader gets killed by the same deadline meant for attackers.
If you need generous, per-request control over body read time while still closing the header gap, the cleaner approach is to keep ReadHeaderTimeout tight (headers should never legitimately take more than a few seconds) and leave body timing to the handler itself, either via http.MaxBytesReader to cap the size regardless of speed, or by resetting the connection deadline explicitly inside the handler once you know the request is one you're willing to wait on.
It's also worth setting MaxHeaderBytes if you haven't touched it, since ReadHeaderTimeout only bounds time, not size: a client that sends a very large header block quickly, well within the timeout, is a memory problem rather than a slow-connection problem. Go's default cap is 1 MiB across all header keys and values including the request line, which is usually generous enough that people never think about it, but it's a separate knob from the one this article is about, and worth checking rather than assuming.
None of this is exotic behaviour, it's exactly what the doc comment says if you read the whole thing rather than skimming for the field name a linter mentioned. But "the linter stopped complaining" and "this connection can't be held open indefinitely" are different claims, and it's easy to walk away thinking you've satisfied the second when you've only done the first.