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
+111 -2
View File
@@ -155,6 +155,7 @@ async function createWorkingFixtureModule(
method: "GET",
path: "/ping",
permission: ${JSON.stringify(permissionId)},
maxBodyBytes: 0,
handler: async () => new Response("pong", { status: 200 }),
});`
: "";
@@ -214,6 +215,7 @@ function fakeDiscoveredModule(
* `triggerError` simulates the server's own `'error'` event (e.g. a bind failure). */
function fakeServer() {
let errorHandler: ((error: Error) => void) | undefined;
let timeoutHandler: (() => void) | undefined;
const server = {
address: () => ({ address: "127.0.0.1", family: "IPv4", port: 4_000 }),
close: vi.fn((callback?: () => void) => {
@@ -221,6 +223,12 @@ function fakeServer() {
return server;
}),
listening: true,
on: vi.fn((event: string, handler: () => void) => {
if (event === "timeout") {
timeoutHandler = handler;
}
return server;
}),
once: vi.fn((event: string, handler: (error: Error) => void) => {
if (event === "error") {
errorHandler = handler;
@@ -231,6 +239,7 @@ function fakeServer() {
return {
server,
triggerError: (error: Error) => errorHandler?.(error),
triggerTimeout: () => timeoutHandler?.(),
};
}
@@ -239,12 +248,12 @@ function fakeServer() {
* resolving synchronously would reference `bindServer`'s own `server` const before it is
* initialized. */
function mockSuccessfulServe() {
const { server, triggerError } = fakeServer();
const { server, triggerError, triggerTimeout } = fakeServer();
serveMock.mockImplementation((_options: unknown, listeningListener?: () => void) => {
queueMicrotask(() => listeningListener?.());
return server;
});
return { server, triggerError };
return { server, triggerError, triggerTimeout };
}
function mockFailingServe(error: Error) {
@@ -465,6 +474,105 @@ describe("Ekklesia API shutdown lifecycle", () => {
});
});
describe("Ekklesia API Node server timeouts", () => {
beforeEach(() => {
poolConstructor.mockReset();
betterAuthMock.mockReset();
drizzleAdapterMock.mockClear();
serveMock.mockReset();
});
it("configures finite, typed header/request/keep-alive timeouts on the underlying Node server", async () => {
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() });
mockSuccessfulServe();
const running = await startApi(validConfig());
expect(serveMock).toHaveBeenCalledTimes(1);
const [options] = serveMock.mock.calls[0] as [
{
serverOptions?: {
connectionsCheckingInterval?: number;
headersTimeout?: number;
keepAliveTimeout?: number;
requestTimeout?: number;
};
},
];
expect(options.serverOptions?.headersTimeout).toBeGreaterThan(0);
expect(options.serverOptions?.requestTimeout).toBeGreaterThan(0);
expect(options.serverOptions?.keepAliveTimeout).toBeGreaterThan(0);
// Node only enforces headersTimeout/requestTimeout when this periodic check runs (verified
// directly against the pinned Node runtime — its own default, 30s, would otherwise let a
// violating connection sit for up to an additional 30s past its actual timeout).
expect(options.serverOptions?.connectionsCheckingInterval).toBeGreaterThan(0);
expect(options.serverOptions?.connectionsCheckingInterval).toBeLessThan(30_000);
// Node requires headersTimeout not to exceed requestTimeout.
expect(options.serverOptions?.headersTimeout).toBeLessThanOrEqual(
options.serverOptions?.requestTimeout ?? 0,
);
await running.shutdown();
});
it("allows a test-only override to narrow the timeouts without changing the production defaults", async () => {
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() });
mockSuccessfulServe();
const running = await startApi(validConfig(), {
nodeServerTimeoutOverride: { headersTimeoutMs: 50, requestTimeoutMs: 100 },
});
const [options] = serveMock.mock.calls[0] as [
{
serverOptions?: {
headersTimeout?: number;
keepAliveTimeout?: number;
requestTimeout?: number;
};
},
];
expect(options.serverOptions?.headersTimeout).toBe(50);
expect(options.serverOptions?.requestTimeout).toBe(100);
// The un-overridden keep-alive timeout keeps its production default.
expect(options.serverOptions?.keepAliveTimeout).toBeGreaterThan(0);
await running.shutdown();
});
it("logs a sanitized operational event, and nothing else, when the underlying server reports a timeout", async () => {
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 errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
try {
const running = await startApi(validConfig());
triggerTimeout();
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);
await running.shutdown();
} finally {
errorSpy.mockRestore();
}
});
});
describe("Ekklesia API module permission composition at startup", () => {
afterEach(async () => {
await Promise.all(
@@ -688,6 +796,7 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
method: "GET",
path: "/ping",
permission: "db-identity.ping.read",
maxBodyBytes: 0,
handler: async (_request, context) => {
${refs.expression}.push(context.services.database);
return new Response(null, { status: 204 });