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:
co-authored by
Claude Sonnet 5
parent
cafd513019
commit
d3c6bd73d3
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user