The Hidden Danger of Go's Default HTTP Server: Defending Against Slowloris
Why we mandate ReadHeaderTimeout on all http.Server instantiations in Mezite, and how a one-line oversight can expose your infrastructure to catastrophic resource exhaustion.
Go’s net/http package is a marvel of modern engineering. It is fast, concurrent, and robust enough to power massive microservices architectures out of the box. However, it harbors a deceptive default behavior that can bring an entire system to its knees if you are not careful.
When building Mezite, we audit every connection point. The central proxy (mezhub) terminates TLS, routes gRPC, and serves the HTTPS API. Because it acts as the gateway to the entire infrastructure, it must be impenetrable to resource exhaustion attacks.
This requirement leads to a strict, non-negotiable rule in our codebase: Every http.Server instantiation must explicitly define a ReadHeaderTimeout.
Here is a technical breakdown of why this rule exists, the mechanics of the attack it prevents, and how we enforce it.
The Slowloris Attack Vector
To understand the vulnerability, we must look at how HTTP servers handle incoming connections.
When a client initiates an HTTP request, it sends headers followed by a blank line (\r\n\r\n) to indicate the end of the header section. A standard server waits to read these headers before it can dispatch the request to the application logic (the handler).
A Slowloris attack exploits this waiting period. The attacker opens hundreds or thousands of connections to the server. Instead of sending complete requests, the attacker sends partial headers incredibly slowly—perhaps one byte every few seconds.
The attacker never sends the terminating blank line.
To the server, these look like legitimate clients with terrible network connections. The server patiently holds the connections open, waiting for the headers to finish.
The Default Goroutine Leak
In Go, the net/http server spawns a new goroutine for every accepted connection. This concurrent model is why Go web servers perform so well. But in the context of a Slowloris attack, it becomes a liability.
If you initialize an HTTP server without configuring timeouts, like this:
// DO NOT DO THIS IN PRODUCTION
server := &http.Server{
Addr: ":8080",
Handler: mux,
}
server.ListenAndServe() Go places absolutely no limit on how long it waits to read the request headers.
As the attacker trickles bytes, Go spawns thousands of goroutines. Each goroutine consumes memory (starting at 2KB and growing) and holds an open file descriptor. Eventually, the operating system hits its open file limit (ulimit -n), or the process exhausts available memory and crashes.
The application is completely starved of resources and stops responding to legitimate traffic. You have suffered a catastrophic Denial of Service (DoS) without the attacker ever needing to send a high volume of traffic.
The Fix: Bounding Resource Allocation
The solution is straightforward but requires deliberate configuration. You must tell the Go HTTP server to abort the connection if the client takes too long to send its headers.
In Mezite, we enforce this by mandating ReadHeaderTimeout on every HTTP listener.
server := &http.Server{
Addr: ":8080",
Handler: mux,
// Critical: Defends against Slowloris attacks
ReadHeaderTimeout: 10 * time.Second,
}
server.ListenAndServe() When ReadHeaderTimeout is set, the underlying connection logic starts a timer as soon as the connection is accepted. If the entire HTTP header is not read within that window (e.g., 10 seconds), the server forcefully closes the connection.
The attacker’s slow-drip connection is severed, the goroutine exits, and the file descriptor is released. The resource exhaustion is prevented at the edge, long before the request reaches the routing logic.
Why Not Just Use ReadTimeout?
Go also provides a ReadTimeout field on http.Server. You might wonder why we specify ReadHeaderTimeout instead of, or in addition to, ReadTimeout.
ReadTimeout covers the time from connection acceptance to the end of reading the entire request body. If you are building an endpoint that accepts large file uploads, setting a strict ReadTimeout might prematurely terminate legitimate, slow uploads.
ReadHeaderTimeout is far more precise. It only bounds the time spent reading the headers. Regardless of how large the payload is, a legitimate client can send its HTTP headers in a fraction of a second. A 10-second header timeout provides a massive buffer for network latency while still aggressively killing malicious connections.
Enforcing Security by Design
In a large codebase, it is easy for a developer to spin up a quick debug server or internal listener and forget the timeout fields. We prevent regressions through automated static analysis.
Our CI pipeline utilizes golangci-lint with the gosec linter enabled. gosec specifically flags http.Server initializations missing timeout configurations (Rule G112). We treat these warnings as build failures.
By pushing this check into the CI pipeline, we guarantee that no new HTTP listeners can be introduced without explicitly defending against resource exhaustion.
Conclusion
Operational security requires understanding the edge cases of your language’s standard library. Go’s net/http package defaults to prioritizing long-lived connections over resource protection. When you build infrastructure access tools, you must reverse that priority.
A single line of configuration—ReadHeaderTimeout: 10 * time.Second—is the difference between a resilient gateway and a fragile target. At Mezite, we prefer the former.
Mezite Team
Engineering