Compatibility
Published
A server's resistance to a slow client is not written in its handlers. It is written once, in the options object passed to createServer, in five properties nobody reads again after the day they were added. They are the reason a request that dribbles its body forever gets cut off, and the reason a header block the size of a page gets refused. They are also names node owns.
What was measured
One file against node's HTTP server, driven through five protection profiles for the base column and both member-renaming profiles for the rest. It speaks HTTP by hand over a raw socket, because the point is what the server does with requests an ordinary client would never send: an eight kilobyte header block against a two kilobyte cap, a POST that dribbles its body four bytes at a time with 220 milliseconds between chunks, and a connection left idle after its response.
Every guard in the file is set well below node's default: a two kilobyte header cap against node's sixteen, a 700 millisecond request timeout against node's 300 seconds, a 500 millisecond headers timeout, a 400 millisecond keep-alive and a 60 millisecond connection sweep against node's 30 seconds.
The base column is clean. All five profiles refused the oversized header block with a 431, cut the dribbling upload off with a 408 in under a second and a half, and closed the idle socket in the same window. Protecting an HTTP server does not change how it handles a hostile client.
An oversized header block gets a 200
The first arm renamed the header size cap. Before, the eight kilobyte header block was refused with a 431 status. After, the same request was served, with a 200.
Node's default cap is sixteen kilobytes, so a service that deliberately narrowed it to two has silently widened it eightfold. Nothing errors, and the only observable difference is that requests the server was configured to refuse now get answers.
This is the mildest result on the page and it is still an eightfold increase in what an unauthenticated client can make the server buffer per connection. Header caps are usually narrowed for a specific reason, most often an upstream proxy with its own limit, or a memory budget per connection that was calculated once and written down nowhere. Whatever the reason was, it is no longer being enforced, and the request that would have proved it succeeds instead.
There is a second-order effect worth noting for anyone running node behind a proxy. When the origin's cap is larger than the proxy's, requests that the proxy would refuse are still refused, and nothing changes. When it is smaller, as it deliberately was here, the origin was the component doing the enforcing. Renaming the option quietly moves the enforcement point to whatever else happens to have a limit, which may be nothing.
The slow-drip upload runs to completion
The second arm renamed the request timeout and the headers timeout together. Before, the dribbling upload was cut off with a 408 in well under a second and a half. After, it was served with a 200, and the elapsed check flipped: the connection now outlived the window entirely.
That is the classic slow-client denial of service, and the defence against it is exactly these two names. Node's defaults are 300 seconds and 60 seconds, so a server tuned to sub-second deadlines reverts to holding connections for minutes. Multiply by the number of sockets an attacker can open and the arithmetic is the whole attack.
The keep-alive arm is the quieter sibling. Renaming it left the idle connection open past the measurement window, where a 400 millisecond keep-alive had closed it promptly. Idle sockets accumulate, and the resource they consume is the one that is hardest to attribute after the fact.
What makes the timeout pair particularly hard to catch in review is that both names read as tuning rather than defence. A reviewer scanning a rename pattern for security-relevant names is looking for words like auth, token, secret and verify. Nothing in requestTimeout announces that it is the only thing preventing a category of denial of service, and a pattern written to catch, say, every property ending in Timeout will sweep all of them up in one line.
The other thing worth stating plainly: this measurement produced a 200 response where a 408 belonged. Every status-code-based alert in the world is looking for the opposite. An attack that succeeds returns success, so the observability that would normally surface a problem is pointed in exactly the wrong direction.
The option nobody thinks of as a security control
The result worth carrying away from this page is the third arm, and it renames a name most engineers have never typed.
Node does not enforce the request timeout continuously. It sweeps connections on an interval, and the interval is itself an option. The file sets it to 60 milliseconds so that a 700 millisecond deadline is actually enforced near 700 milliseconds; node's default is 30 seconds.
Renaming that interval alone, leaving both timeouts perfectly intact, produced exactly the same outcome as renaming the timeouts themselves: the dribbling upload was served with a 200 and ran past the window. The deadlines are still configured. They are still correct. They are simply not checked often enough to fire.
That is a general shape worth naming. A guard can be composed of more than one option, and the ones that are not obviously the guard are the ones a rename pattern is most likely to reach, because nobody thinks to exclude them. An audit that lists the timeouts as configured is telling the truth and still describing a server with no timeouts.
Renaming all five names together produced the union of the individual results, which is what a real broad pattern would do: the oversized header served, the slow upload served, the deadline missed and the idle socket held open, in one run. That combined arm is the realistic one. Nobody writes a pattern that catches exactly one option; patterns are written broadly and then narrowed when something visibly breaks, and nothing here breaks visibly.
It is also the arm that shows how little is left. After it, the server accepts header blocks eight times larger than configured, holds a dribbling request until node's own multi-minute default, keeps idle sockets for node's default keep-alive, and checks its deadlines twice a minute. That is not a server with weakened limits. That is a stock node server, wearing a configuration file that describes a hardened one.
The control, and what it tells you about attribution
The control arm renamed the fields of the file's own result record, the reply line and the elapsed milliseconds it measures for itself. Behaviour was identical, because both ends of that read were renamed together.
That is the expected outcome for a self-owned name, and it is what makes the arms above attributable. It also carries a warning that keeps recurring in this series: the record your own code keeps is the thing that survives a rename intact and correct, which is precisely why it cannot be used to detect one. Your server's own view of its configuration will always agree with itself.
The same reasoning applies to a startup log line that prints the effective configuration, which is a common and otherwise excellent practice. It reads the object your code built, so it prints the hardened values, correctly, in the run where node never saw them. A log line that prints what it passed is evidence about your code. It is not evidence about the server.
What to do about it
Protection alone changed nothing on any of the five profiles measured, so nothing here argues against protecting an HTTP service. Every result required member renaming with a pattern broad enough to reach node's server option names.
Keep requestTimeout, headersTimeout, keepAliveTimeout, connectionsCheckingInterval, maxHeaderSize, maxRequestsPerSocket, joinDuplicateHeaders and insecureHTTPParser out of that pattern. The sweep interval belongs on that list even though it is not a limit, because it is the thing that makes two of the limits real.
Then test the server the way the measurement does. Open a raw socket, send a header block larger than your cap, and require a 431. Send a body slowly enough to cross your deadline, and require a 408. Both are a dozen lines, neither needs a load generator, and they exercise the behaviour rather than the object that describes it. A test that asserts the options object contains a request timeout will pass on both sides of a rename, including the run where the timeout never fires.
One practical note about writing those tests: use a raw socket rather than an HTTP client library. A well-behaved client will not send an eight kilobyte header block or dribble a body four bytes at a time, and if you ask it to, it will usually apply its own timeout first and report that instead. The measurement here speaks HTTP by hand for exactly that reason, and the code to do it is short enough to keep in the test file.
Finally, if you already run a load or resilience test in CI, the cheapest change available is to assert on the status code it receives rather than on throughput. A slow-client scenario that starts returning 200 instead of 408 is the same signal, and you are already paying for the run.
Frequently asked questions
Does obfuscation break HTTP server timeouts?
Not in the default configuration. A server with a narrowed header cap, sub-second request and headers timeouts, a short keep-alive and a fast connection sweep produced identical behaviour against hostile raw-socket clients on all five protection profiles measured. The options become a surface only when member renaming reaches the names node reads.
Can member renaming enable a slowloris attack?
It can remove the defence against one. With the request timeout and headers timeout renamed, a POST dribbling four bytes at a time every 220 milliseconds went from being cut off with a 408 to being served with a 200, outliving the measurement window. Node's defaults are 300 seconds and 60 seconds.
What happens to the maximum header size?
It reverts to node's default of sixteen kilobytes. In the measurement an eight kilobyte header block against a two kilobyte cap went from a 431 refusal to a 200 response, so a deliberately narrowed cap silently widened eightfold.
Why did renaming the connection sweep interval disable the timeouts?
Because node checks request deadlines on an interval rather than continuously, and the interval is its own option with a default of 30 seconds. Renaming it alone, with both timeouts left intact, produced the same result as renaming the timeouts: the slow upload was served and the deadline was missed.
Does our configuration audit catch this?
Not if it reads the configuration. The timeouts are still present and still correct in the source and in the options object; what is missing is the option that makes them fire. An audit that lists them as configured is accurate and still describing a server with no effective deadlines.
Which HTTP server options should stay out of a rename pattern?
requestTimeout, headersTimeout, keepAliveTimeout, connectionsCheckingInterval, maxHeaderSize, maxRequestsPerSocket, joinDuplicateHeaders and insecureHTTPParser. Include the sweep interval even though it is not a limit itself, because two of the limits depend on it.
How do we test server limits on a protected build?
Use a raw socket rather than an HTTP client. Send a header block larger than your cap and require a 431; send a body slowly enough to cross your deadline and require a 408. Both tests are short, neither needs a load generator, and they were the only checks in this measurement that could see anything had changed.
Related reading