fix: enforce API request limits

Add a single platform-owned request-body policy (hono/body-limit) with
small, named limits for Better Auth and Core's grant-mutation endpoint, and
a new required maxBodyBytes field on the executable module route contract so
every module route makes a conscious no-body-or-bounded-body choice, capped
at a platform ceiling. Every oversized request gets one sanitized 413
contract and never reaches business logic, authorization, or a handler.
Also configure finite Node header/request/keep-alive timeouts, including the
non-obvious connectionsCheckingInterval needed for them to take effect
promptly, so a slow-header or slow-body connection cannot hold a socket
indefinitely.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
ekklesia
2026-07-26 12:29:24 -05:00
co-authored by Claude Sonnet 5
parent cafd513019
commit d3c6bd73d3
24 changed files with 1419 additions and 96 deletions
+76 -5
View File
@@ -27,6 +27,47 @@ import { prepareStartupModuleInventory } from "./platform-permissions.js";
const connectionTimeoutMs = 10_000;
const operationTimeoutMs = 10_000;
export interface NodeServerTimeouts {
readonly headersTimeoutMs: number;
readonly requestTimeoutMs: number;
readonly keepAliveTimeoutMs: number;
/**
* How often Node re-scans open connections for a header/request timeout violation
* (`http.Server`'s `connectionsCheckingInterval`). This is not merely a performance knob: Node
* enforces `headersTimeout`/`requestTimeout` only when this periodic check runs, and its own
* default (30s) is verified (`node --version` 24.15.0, direct `http.createServer` reproduction)
* to let a violating connection sit for up to a full additional interval past its actual
* timeout before Node ever closes it — so leaving this at Node's default would silently widen
* both timeouts above by up to 30s in the worst case. Kept small relative to `headersTimeoutMs`
* so a violation is detected promptly after it actually elapses.
*/
readonly connectionsCheckingIntervalMs: number;
}
/**
* Finite Node HTTP server timeouts for this JSON-only API. Node's own defaults
* (`headersTimeout`: 60s, `requestTimeout`: 300s) are generous enough to let a slow-header or
* slow-body connection hold a socket for minutes with no reverse proxy in front; these values bound
* that exposure while remaining comfortable for the small JSON payloads this API ever accepts (see
* `./body-limits.js`). `headersTimeoutMs` must not exceed `requestTimeoutMs` — Node validates this
* relationship itself at server construction — so headers alone may take up to 20s, and the
* complete request (headers plus body) up to 30s, before Node itself responds `408` and closes the
* connection without ever invoking a handler (subject to `connectionsCheckingIntervalMs`'s own
* detection latency, above). `keepAliveTimeoutMs` matches Node's own default (5s) made explicit and
* typed rather than left implicit; it bounds only the idle window between requests on a reused
* connection, never an in-flight one.
*/
const PRODUCTION_NODE_SERVER_TIMEOUTS: NodeServerTimeouts = Object.freeze({
connectionsCheckingIntervalMs: 5_000,
headersTimeoutMs: 20_000,
keepAliveTimeoutMs: 5_000,
requestTimeoutMs: 30_000,
});
/** Never carries the remote address, request path, headers, or any other connection detail. */
const NODE_SERVER_TIMEOUT_LOG_MESSAGE =
"[server] A connection was closed for exceeding a configured header/request timeout.";
export interface RunningApi {
readonly app: Hono;
readonly server: ServerType;
@@ -45,6 +86,10 @@ export interface StartApiOptions {
* runtime startup. Never set by the executable entry point. See
* `prepareStartupModuleInventory`. */
readonly discoveredModulesOverride?: readonly DiscoveredModule[];
/** Test-only escape hatch: narrows the Node header/request/keep-alive timeouts so a transport
* test can prove real timeout behavior deterministically without waiting out the production
* values. Never set by the executable entry point. */
readonly nodeServerTimeoutOverride?: Partial<NodeServerTimeouts>;
}
async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise<void> {
@@ -68,21 +113,36 @@ async function safeStopModuleRuntime(moduleRuntime: StartedModuleRuntime): Promi
}
/**
* Binds the Node HTTP server. `wrapFetchWithClientAddress` resolves and sanitizes the caller's
* Binds the Node HTTP server. Two platform-owned transport concerns are applied at this exact
* boundary, below Hono entirely: `wrapFetchWithClientAddress` resolves and sanitizes the caller's
* address from the real socket peer before any route (including Better Auth's own rate limiter)
* ever sees the request — see `./client-address.js` for the platform-owned client-address
* resolution policy this depends on.
* ever sees the request (see `./client-address.js`), and `serverOptions` gives Node finite
* header/request/keep-alive timeouts so a slow-header or slow-body connection cannot hold a socket
* indefinitely. A connection Node itself closes for exceeding one of these timeouts never reaches
* `app.fetch` and never receives a second response — both are Node's own documented behavior for
* `headersTimeout`/`requestTimeout`, not something this function re-implements.
*/
function bindServer(
app: Hono,
host: string,
port: number,
clientAddressResolver: ClientAddressResolver,
timeouts: NodeServerTimeouts,
): Promise<ServerType> {
return new Promise((resolve, reject) => {
let settled = false;
const server = serve(
{ fetch: wrapFetchWithClientAddress(app.fetch, clientAddressResolver), hostname: host, port },
{
fetch: wrapFetchWithClientAddress(app.fetch, clientAddressResolver),
hostname: host,
port,
serverOptions: {
connectionsCheckingInterval: timeouts.connectionsCheckingIntervalMs,
headersTimeout: timeouts.headersTimeoutMs,
keepAliveTimeout: timeouts.keepAliveTimeoutMs,
requestTimeout: timeouts.requestTimeoutMs,
},
},
() => {
if (!settled) {
settled = true;
@@ -90,6 +150,13 @@ 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);
});
server.once("error", () => {
if (!settled) {
settled = true;
@@ -199,10 +266,14 @@ export async function startApi(
}
const clientAddressResolver = createClientAddressResolver(config.clientAddress);
const timeouts: NodeServerTimeouts = {
...PRODUCTION_NODE_SERVER_TIMEOUTS,
...options.nodeServerTimeoutOverride,
};
let server: ServerType;
try {
server = await bindServer(app, config.host, config.port, clientAddressResolver);
server = await bindServer(app, config.host, config.port, clientAddressResolver, timeouts);
} catch (error) {
await safeStopModuleRuntime(moduleRuntime);
await safeCloseDatabase(databaseHandle);