fix: observe request timeout failures
The sanitized operational line promised for a header/request timeout was never actually emitted. Driving a real slow-header and slow-body connection through a real socket against the pinned Node 24 runtime shows the general server 'timeout' event never fires for headersTimeout/requestTimeout — that event belongs to server.setTimeout()'s idle-socket timer, which this server never arms. Both surface as 'clientError' carrying err.code === "ERR_HTTP_REQUEST_TIMEOUT". bindServer now observes 'clientError'. Because registering any such listener suppresses Node's own default response entirely, the handler reproduces that default byte-for-byte — 408 for a timeout, 431 for a header overflow, 400 otherwise — writing it only when the socket is still writable and no response has already begun, then destroying the socket exactly as Node would. Exactly one fixed sanitized line is logged per event, never the caught error's own message, which for a parse failure derives from attacker-controlled input. Proof moves to the real transport boundary: raw-socket scenarios assert the exact response bytes, exactly one status line, socket closure, handler non-invocation, and exactly one sanitized log for a slow-header connection, a slow-body connection, a malformed request line, and an oversized header block. The prior unit test, which manually emitted 'timeout', is removed as misleading. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1161f5a076
commit
3d6c85b0f8
+55
-7
@@ -1,3 +1,4 @@
|
||||
import type { Socket } from "node:net";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { ModuleRuntimeServices } from "@ekklesia/contracts";
|
||||
@@ -64,9 +65,48 @@ const PRODUCTION_NODE_SERVER_TIMEOUTS: NodeServerTimeouts = Object.freeze({
|
||||
requestTimeoutMs: 30_000,
|
||||
});
|
||||
|
||||
/** Never carries the remote address, request path, headers, or any other connection detail. */
|
||||
/**
|
||||
* The two fixed operational lines a transport-level client failure may produce. Neither ever
|
||||
* carries the remote address, request path, query, headers, cookies, body, forwarding chain, the
|
||||
* caught error's own message, or the malformed bytes that caused it — an attacker controls all of
|
||||
* those, and a parse failure's message in particular is derived directly from the rejected input.
|
||||
*/
|
||||
const NODE_SERVER_TIMEOUT_LOG_MESSAGE =
|
||||
"[server] A connection was closed for exceeding a configured header/request timeout.";
|
||||
const NODE_SERVER_CLIENT_ERROR_LOG_MESSAGE =
|
||||
"[server] A connection was closed after a malformed or oversized client request.";
|
||||
|
||||
/**
|
||||
* Node's own documented default `clientError` responses, reproduced byte-for-byte (verified
|
||||
* directly against the pinned Node 24 runtime by driving each case through a real socket with no
|
||||
* listener attached). Registering *any* `clientError` listener suppresses that default entirely —
|
||||
* `_http_server.js` only falls back when `server.emit("clientError", ...)` returns `false` — so a
|
||||
* listener that merely observes would silently turn every 408/400/431 into a bare connection reset.
|
||||
* Reproducing the exact bytes is what lets the platform log the event without changing what a
|
||||
* caller actually receives.
|
||||
*/
|
||||
const NODE_CLIENT_ERROR_RESPONSES: Readonly<Record<string, string>> = Object.freeze({
|
||||
ERR_HTTP_REQUEST_TIMEOUT: "HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n",
|
||||
HPE_HEADER_OVERFLOW: "HTTP/1.1 431 Request Header Fields Too Large\r\nConnection: close\r\n\r\n",
|
||||
});
|
||||
const NODE_CLIENT_ERROR_DEFAULT_RESPONSE = "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n";
|
||||
|
||||
/**
|
||||
* Mirrors Node's own `socketOnError` fallback exactly: write the code-appropriate response only
|
||||
* when the socket is still writable *and* no response has already begun for the in-flight request,
|
||||
* then destroy the socket. The `_httpMessage` guard is what prevents a second, corrupting status
|
||||
* line when a handler already responded and only the (unread) request body subsequently timed out.
|
||||
*/
|
||||
function writeNodeClientErrorResponse(error: NodeJS.ErrnoException, socket: Socket): void {
|
||||
const inFlightResponse = (socket as Socket & { _httpMessage?: { headersSent?: boolean } })
|
||||
._httpMessage;
|
||||
const alreadyResponding = inFlightResponse?.headersSent === true;
|
||||
if (socket.writable && !alreadyResponding) {
|
||||
const code = typeof error.code === "string" ? error.code : "";
|
||||
socket.write(NODE_CLIENT_ERROR_RESPONSES[code] ?? NODE_CLIENT_ERROR_DEFAULT_RESPONSE);
|
||||
}
|
||||
socket.destroy();
|
||||
}
|
||||
|
||||
export interface RunningApi {
|
||||
readonly app: Hono;
|
||||
@@ -150,12 +190,20 @@ function bindServer(
|
||||
}
|
||||
},
|
||||
);
|
||||
// A sanitized operational observation only — Node's own headersTimeout/requestTimeout
|
||||
// enforcement already closes the connection (and, for requestTimeout, sends a 408) regardless
|
||||
// of whether anything is listening here; this never carries the remote address, headers, or any
|
||||
// other request detail.
|
||||
server.on("timeout", () => {
|
||||
console.error(NODE_SERVER_TIMEOUT_LOG_MESSAGE);
|
||||
// `headersTimeout`/`requestTimeout` do NOT surface as the server's general `'timeout'` event —
|
||||
// that event only fires for `server.setTimeout()`'s idle-socket timer, which this server never
|
||||
// arms. Verified directly against the pinned Node 24 runtime by driving a real slow-header and
|
||||
// slow-body connection through a real socket: the only server-level event either produces is
|
||||
// `'clientError'`, carrying `err.code === "ERR_HTTP_REQUEST_TIMEOUT"`. Observing the failure
|
||||
// therefore has to happen here, and — because registering a listener suppresses Node's own
|
||||
// default response — this handler must reproduce that default itself.
|
||||
server.on("clientError", (error: NodeJS.ErrnoException, socket: Socket) => {
|
||||
console.error(
|
||||
error.code === "ERR_HTTP_REQUEST_TIMEOUT"
|
||||
? NODE_SERVER_TIMEOUT_LOG_MESSAGE
|
||||
: NODE_SERVER_CLIENT_ERROR_LOG_MESSAGE,
|
||||
);
|
||||
writeNodeClientErrorResponse(error, socket);
|
||||
});
|
||||
server.once("error", () => {
|
||||
if (!settled) {
|
||||
|
||||
Reference in New Issue
Block a user