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:
ekklesia
2026-07-26 15:59:33 -05:00
co-authored by Claude Opus 5
parent 1161f5a076
commit 3d6c85b0f8
6 changed files with 378 additions and 124 deletions
+112 -14
View File
@@ -213,9 +213,11 @@ function fakeDiscoveredModule(
/** A minimal fake `ServerType`: enough of Node's `http.Server` shape for `bindServer`/`shutdown`
* to drive without a real socket. `close` and `once` are spies so tests can assert call counts;
* `triggerError` simulates the server's own `'error'` event (e.g. a bind failure). */
type ClientErrorHandler = (error: NodeJS.ErrnoException, socket: import("node:net").Socket) => void;
function fakeServer() {
let errorHandler: ((error: Error) => void) | undefined;
let timeoutHandler: (() => void) | undefined;
let clientErrorHandler: ClientErrorHandler | undefined;
const server = {
address: () => ({ address: "127.0.0.1", family: "IPv4", port: 4_000 }),
close: vi.fn((callback?: () => void) => {
@@ -223,9 +225,9 @@ function fakeServer() {
return server;
}),
listening: true,
on: vi.fn((event: string, handler: () => void) => {
if (event === "timeout") {
timeoutHandler = handler;
on: vi.fn((event: string, handler: ClientErrorHandler) => {
if (event === "clientError") {
clientErrorHandler = handler;
}
return server;
}),
@@ -237,9 +239,11 @@ function fakeServer() {
}),
};
return {
/** The `clientError` handler `bindServer` registered — the real event path Node uses for a
* header/request timeout, a parse failure, and a header overflow alike. */
clientErrorHandler: () => clientErrorHandler,
server,
triggerError: (error: Error) => errorHandler?.(error),
triggerTimeout: () => timeoutHandler?.(),
};
}
@@ -248,12 +252,12 @@ function fakeServer() {
* resolving synchronously would reference `bindServer`'s own `server` const before it is
* initialized. */
function mockSuccessfulServe() {
const { server, triggerError, triggerTimeout } = fakeServer();
const { clientErrorHandler, server, triggerError } = fakeServer();
serveMock.mockImplementation((_options: unknown, listeningListener?: () => void) => {
queueMicrotask(() => listeningListener?.());
return server;
});
return { server, triggerError, triggerTimeout };
return { clientErrorHandler, server, triggerError };
}
function mockFailingServe(error: Error) {
@@ -586,23 +590,117 @@ describe("Ekklesia API Node server timeouts", () => {
await running.shutdown();
});
it("logs a sanitized operational event, and nothing else, when the underlying server reports a timeout", async () => {
// Whether Node actually *emits* anything for a real header/request timeout — and that exactly one
// 408 is written, the socket closes, and no handler runs — is proven only at the real transport
// boundary, by the raw-socket scenarios in `apps/api/tests/api-http.integration.ts`. Node's
// general `'timeout'` event is NOT that path (it belongs to `server.setTimeout()`, which this
// server never arms); `'clientError'` is. The tests below therefore deliberately do not claim to
// prove the event path at all — they cover only the response-reproduction logic that Node's own
// default would otherwise have performed, including the duplicate-response guard, which is
// impractical to drive from a real socket.
function fakeErrorSocket(overrides: { headersSent?: boolean; writable?: boolean } = {}) {
const socket = {
_httpMessage:
overrides.headersSent === undefined ? undefined : { headersSent: overrides.headersSent },
destroy: vi.fn(),
destroyed: false,
writable: overrides.writable ?? true,
write: vi.fn(),
};
return socket as unknown as import("node:net").Socket & {
destroy: ReturnType<typeof vi.fn>;
write: ReturnType<typeof vi.fn>;
};
}
async function startAndCaptureClientErrorHandler() {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
const { triggerTimeout } = mockSuccessfulServe();
const { clientErrorHandler } = mockSuccessfulServe();
const running = await startApi(validConfig());
const handler = clientErrorHandler();
if (!handler) {
throw new Error("Expected startApi to register a clientError handler.");
}
return { handler, running };
}
it("reproduces Node's own client-error responses byte-for-byte, since registering a listener suppresses them", async () => {
const { handler, running } = await startAndCaptureClientErrorHandler();
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
try {
const running = await startApi(validConfig());
triggerTimeout();
for (const [code, expected] of [
["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",
],
["HPE_INVALID_METHOD", "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"],
[undefined, "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"],
] as const) {
const socket = fakeErrorSocket();
const error = Object.assign(new Error("parser detail that must never be logged"), { code });
handler(error, socket);
expect(socket.write).toHaveBeenCalledTimes(1);
expect(socket.write).toHaveBeenCalledWith(expected);
expect(socket.destroy).toHaveBeenCalledTimes(1);
}
await running.shutdown();
} finally {
errorSpy.mockRestore();
}
});
it("never writes a second response when a handler already began responding, but still closes the socket", async () => {
const { handler, running } = await startAndCaptureClientErrorHandler();
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
try {
const socket = fakeErrorSocket({ headersSent: true });
handler(Object.assign(new Error("timeout"), { code: "ERR_HTTP_REQUEST_TIMEOUT" }), socket);
expect(socket.write).not.toHaveBeenCalled();
expect(socket.destroy).toHaveBeenCalledTimes(1);
// An unwritable socket is likewise never written to, only destroyed.
const closedSocket = fakeErrorSocket({ writable: false });
handler(
Object.assign(new Error("timeout"), { code: "ERR_HTTP_REQUEST_TIMEOUT" }),
closedSocket,
);
expect(closedSocket.write).not.toHaveBeenCalled();
expect(closedSocket.destroy).toHaveBeenCalledTimes(1);
await running.shutdown();
} finally {
errorSpy.mockRestore();
}
});
it("logs one fixed sanitized line per client error, never the caught error's own message", async () => {
const { handler, running } = await startAndCaptureClientErrorHandler();
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
try {
handler(
Object.assign(new Error("Parse Error: /secret-path?token=abc123"), {
code: "HPE_INVALID_METHOD",
}),
fakeErrorSocket(),
);
expect(errorSpy).toHaveBeenCalledTimes(1);
const [message] = errorSpy.mock.calls[0] as [string];
expect(message).toContain("timeout");
expect(message).not.toMatch(/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/u);
const emitted = errorSpy.mock.calls.flat().map(String).join("\n");
expect(emitted).not.toContain("secret-path");
expect(emitted).not.toContain("abc123");
expect(emitted).not.toContain("Parse Error");
expect(emitted).not.toContain("HPE_");
expect(emitted).not.toMatch(/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/u);
await running.shutdown();
} finally {