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
+3 -1
View File
@@ -12,6 +12,7 @@ import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/co
import { Hono } from "hono";
import type { Context, MiddlewareHandler } from "hono";
import { cors } from "hono/cors";
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
import { createGrantsRoutes } from "./grants-routes.js";
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
@@ -117,7 +118,8 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
app.use("/api/auth/*", corsMiddleware);
app.use("/api/v1/*", corsMiddleware);
app.on(["POST", "GET"], "/api/auth/*", async (c) => {
const authBodyLimit = platformBodyLimit(AUTH_REQUEST_MAX_BODY_BYTES);
app.on(["POST", "GET"], "/api/auth/*", authBodyLimit, async (c) => {
try {
const response = await runtime.handleAuthRequest(c.req.raw);
// Every Better Auth response — including a session cookie — must never be cached by an
+48
View File
@@ -0,0 +1,48 @@
import { MODULE_ROUTE_MAX_BODY_BYTES_CEILING } from "@ekklesia/contracts";
import type { Context } from "hono";
import { bodyLimit } from "hono/body-limit";
/**
* The platform's one request-body policy, centralized here rather than scattered as magic numbers
* across every route file. Every limit below is deliberately small relative to the actual payload
* shapes it guards:
*
* - `AUTH_REQUEST_MAX_BODY_BYTES` (8 KiB): every enabled Better Auth route
* (`/api/auth/sign-up/email`, `/sign-in/email`, `/sign-out`, ...) accepts only an email, a
* password (bounded to 128 characters — `core/src/identity/runtime.ts`), and an optional short
* display name; the pinned release adds no other enabled provider or field (ADR 0011). A real
* payload is well under 200 bytes; 8 KiB leaves generous headroom for a verbose email address or
* name while remaining two orders of magnitude below the module ceiling.
* - `CORE_MUTATION_MAX_BODY_BYTES` (16 KiB): the one Core JSON mutation body, explicit-grant
* creation (`POST /api/v1/grants`), accepts `subjectUserId` (<=128 chars), `permission` (a bounded
* identifier), an optional `scope` (<=256 chars), and an optional ISO `expiresAt` string — every
* field individually bounded well under 1 KiB combined; 16 KiB leaves headroom without inviting
* abuse.
* - `MODULE_ROUTE_MAX_BODY_BYTES_CEILING` (1 MiB, re-exported from `@ekklesia/contracts` — the same
* constant a module's own route declaration is validated against at registration) is the hard
* ceiling no module route may exceed and doubles as the platform's overall request-body ceiling:
* nothing in this platform ever declares, or enforces, a larger single request body than this.
* Large media transfer is explicitly out of scope for ordinary module HTTP bodies — it belongs to
* a future dedicated storage design, not to widening this ceiling.
*/
export const AUTH_REQUEST_MAX_BODY_BYTES = 8 * 1024;
export const CORE_MUTATION_MAX_BODY_BYTES = 16 * 1024;
export { MODULE_ROUTE_MAX_BODY_BYTES_CEILING };
/** The one stable, sanitized public contract for every request-body-size rejection across the
* platform — auth, Core, and module routes alike. Never leaks the configured limit, the offending
* size, a declared Content-Length, or any body content. */
export function requestBodyTooLargeResponse(c: Context): Response {
c.header("Cache-Control", "no-store");
return c.json({ code: "REQUEST_BODY_TOO_LARGE" }, 413);
}
/** A single shared `hono/body-limit` factory so every call site uses the identical sanitized 413
* contract rather than redefining `onError` per route. Covers both a declared oversized
* `Content-Length` and an oversized chunked/streamed body with no declared length — see
* `hono/body-limit`'s pinned implementation, which checks `Content-Length` synchronously before
* ever reading the stream, and otherwise reads and counts the stream incrementally, rejecting as
* soon as the running total exceeds `maxSize` rather than buffering the whole body first. */
export function platformBodyLimit(maxSize: number) {
return bodyLimit({ maxSize, onError: requestBodyTooLargeResponse });
}
+51 -39
View File
@@ -21,6 +21,7 @@ import {
requirePermission,
requireTrustedOrigin,
} from "./authorization-middleware.js";
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
export interface CreateGrantsRoutesOptions extends AuthorizationMiddlewareOptions {
readonly store: AuthorizationStore;
@@ -129,6 +130,10 @@ export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
const routes = new Hono();
const requireManagePermission = requirePermission(options, CORE_AUTHORIZATION_GRANTS_MANAGE);
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
// Origin is checked first (a rejected cross-origin mutation must never have its body read), then
// the body-size limit runs before permission evaluation, so an oversized mutation never reaches
// authorization work or the store.
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
routes.get("/api/v1/grants", requireManagePermission, async (c) => {
c.header("Cache-Control", "no-store");
@@ -146,52 +151,59 @@ export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
}
});
routes.post("/api/v1/grants", requireOrigin, requireManagePermission, async (c) => {
c.header("Cache-Control", "no-store");
const principal = getAuthorizedPrincipal(c);
if (!isAuthenticatedPrincipal(principal)) {
return c.json({ code: "AUTHORIZATION_DENIED" }, 403);
}
routes.post(
"/api/v1/grants",
requireOrigin,
mutationBodyLimit,
requireManagePermission,
async (c) => {
c.header("Cache-Control", "no-store");
const principal = getAuthorizedPrincipal(c);
if (!isAuthenticatedPrincipal(principal)) {
return c.json({ code: "AUTHORIZATION_DENIED" }, 403);
}
let rawBody: unknown;
try {
rawBody = await c.req.json();
} catch {
return c.json({ code: "INVALID_REQUEST" }, 400);
}
const parsed = parseCreateGrantBody(rawBody);
if (!parsed) {
return c.json({ code: "INVALID_REQUEST" }, 400);
}
let rawBody: unknown;
try {
rawBody = await c.req.json();
} catch {
return c.json({ code: "INVALID_REQUEST" }, 400);
}
const parsed = parseCreateGrantBody(rawBody);
if (!parsed) {
return c.json({ code: "INVALID_REQUEST" }, 400);
}
// A well-formed PermissionId is not necessarily a *registered* one. Only the trusted registry
// (never the request) decides what may be granted — the same registry `requireManagePermission`
// already consults for the caller's own `core.authorization.grants.manage` check — so a caller
// cannot pre-create a dormant grant for an invented identifier that later becomes effective the
// moment someone registers it. Sanitized identically to any other denial: this must never reveal
// whether the identifier was malformed (400, above) or simply unregistered (403, here).
if (!options.registry.has(parsed.permission)) {
return c.json({ code: "AUTHORIZATION_DENIED" }, 403);
}
// A well-formed PermissionId is not necessarily a *registered* one. Only the trusted registry
// (never the request) decides what may be granted — the same registry `requireManagePermission`
// already consults for the caller's own `core.authorization.grants.manage` check — so a caller
// cannot pre-create a dormant grant for an invented identifier that later becomes effective the
// moment someone registers it. Sanitized identically to any other denial: this must never reveal
// whether the identifier was malformed (400, above) or simply unregistered (403, here).
if (!options.registry.has(parsed.permission)) {
return c.json({ code: "AUTHORIZATION_DENIED" }, 403);
}
try {
const grant = await options.store.createGrant({
actor: createUserActor(principal.userId),
...(parsed.expiresAt === undefined ? {} : { expiresAt: parsed.expiresAt }),
permission: parsed.permission,
...(parsed.scope === undefined ? {} : { scope: parsed.scope }),
sourceKind: "explicit",
userId: parsed.subjectUserId,
});
return c.json({ grant: serializeGrant(grant) }, 201);
} catch (error) {
return sanitizedStoreErrorResponse(c, error);
}
});
try {
const grant = await options.store.createGrant({
actor: createUserActor(principal.userId),
...(parsed.expiresAt === undefined ? {} : { expiresAt: parsed.expiresAt }),
permission: parsed.permission,
...(parsed.scope === undefined ? {} : { scope: parsed.scope }),
sourceKind: "explicit",
userId: parsed.subjectUserId,
});
return c.json({ grant: serializeGrant(grant) }, 201);
} catch (error) {
return sanitizedStoreErrorResponse(c, error);
}
},
);
routes.post(
"/api/v1/grants/:grantId/revoke",
requireOrigin,
mutationBodyLimit,
requireManagePermission,
async (c) => {
c.header("Cache-Control", "no-store");
+14 -2
View File
@@ -11,6 +11,7 @@ import {
requirePermission,
requireTrustedOrigin,
} from "./authorization-middleware.js";
import { platformBodyLimit } from "./body-limits.js";
const MODULE_ROUTE_PREFIX = "/api/v1/modules";
const STATE_CHANGING_METHODS: ReadonlySet<string> = new Set(["DELETE", "PATCH", "POST", "PUT"]);
@@ -187,6 +188,10 @@ export function mountModuleHttpRoutes(
}
const requireRoutePermission = requirePermission(middlewareOptions, route.permission);
// The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at
// all) is enforced before permission evaluation ever runs, so an oversized request never
// reaches authorization work, let alone the handler or the module database.
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes);
const path = moduleRoutePath(route.moduleId, route.path);
const finalHandler = async (c: Context): Promise<Response> => {
const context: ModuleRouteHandlerContext = Object.freeze({
@@ -212,9 +217,16 @@ export function mountModuleHttpRoutes(
// array — each branch below is therefore a distinct, fixed-arity call rather than one call
// spreading a variable-length handlers array.
if (STATE_CHANGING_METHODS.has(route.method)) {
app.on(route.method, path, requireOrigin, requireRoutePermission, finalHandler);
app.on(
route.method,
path,
requireOrigin,
routeBodyLimit,
requireRoutePermission,
finalHandler,
);
} else {
app.on(route.method, path, requireRoutePermission, finalHandler);
app.on(route.method, path, routeBodyLimit, requireRoutePermission, finalHandler);
}
}
}
+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);
+133 -1
View File
@@ -1,5 +1,6 @@
import { randomBytes } from "node:crypto";
import { request as httpRequest } from "node:http";
import net from "node:net";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { parseEkklesiaUserId, parsePermissionId, SYSTEM_ACTOR } from "@ekklesia/contracts";
@@ -20,7 +21,7 @@ import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it, vi } from "vitest";
import type { ClientAddressConfigInput } from "../src/client-address.js";
import { createApiRuntimeConfig } from "../src/config.js";
import { startApi, type RunningApi } from "../src/server.js";
import { startApi, type NodeServerTimeouts, type RunningApi } from "../src/server.js";
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
const coreMigrationsDirectory = path.join(
@@ -275,6 +276,7 @@ async function createFixtureModule(
method: "GET",
path: "/events/:eventId",
permission: "calendar.events.read",
maxBodyBytes: 0,
async handler(request, context) {
return Response.json({ eventId: context.params.eventId, principal: context.principal });
},
@@ -283,6 +285,7 @@ async function createFixtureModule(
method: "POST",
path: "/events",
permission: "calendar.events.read",
maxBodyBytes: 1024,
async handler(request, context) {
return Response.json({ created: true, principal: context.principal }, { status: 201 });
},
@@ -329,6 +332,7 @@ function apiOrigin(running: RunningApi): string {
interface StartConfiguredApiOptions {
readonly clientAddress?: ClientAddressConfigInput;
readonly modulesDirectoryOverride?: string;
readonly nodeServerTimeoutOverride?: Partial<NodeServerTimeouts>;
/** Defaults to `true`: almost every existing scenario in this suite signs accounts up over HTTP
* and needs it enabled. Pass `false` explicitly for the dedicated signup-gating scenarios. */
readonly publicSignupEnabled?: boolean;
@@ -364,6 +368,9 @@ async function startConfiguredApi(
...(options.modulesDirectoryOverride === undefined
? {}
: { modulesDirectoryOverride: options.modulesDirectoryOverride }),
...(options.nodeServerTimeoutOverride === undefined
? {}
: { nodeServerTimeoutOverride: options.nodeServerTimeoutOverride }),
});
}
@@ -1929,3 +1936,128 @@ describe.sequential("live API — client-address resolution and auth rate-limit
await dropDatabase(database.name);
});
});
describe.sequential("live API — Node header/request timeouts", () => {
it("closes a connection whose headers never complete within the configured headersTimeout, never invoking a handler", async () => {
const database = await createDisposableDatabase("node_timeouts");
await applyCoreMigrations(database.url);
const config = createApiRuntimeConfig(
{
databaseUrl: database.url,
host: "127.0.0.1",
identity: {
baseUrl: identityBaseUrl,
publicSignupEnabled: false,
secret: "integration-test-secret-0123456789abcdefgh",
trustedOrigins: [identityBaseUrl],
},
port: 0,
},
{ environment: "production" },
);
// Deliberately tiny, test-only timeouts — production composition (server.ts's `main`) never
// sets this override — so this test observes the real transport-event close deterministically,
// in well under a second, rather than waiting out the production values.
const running = await startApi(config, {
nodeServerTimeoutOverride: {
connectionsCheckingIntervalMs: 100,
headersTimeoutMs: 200,
keepAliveTimeoutMs: 200,
requestTimeoutMs: 300,
},
});
try {
const address = running.server.address();
if (!address || typeof address !== "object") {
throw harnessError("The API integration server has no bound address.");
}
const socket = net.connect({ host: "127.0.0.1", port: address.port });
await new Promise<void>((resolve, reject) => {
socket.once("connect", () => resolve());
socket.once("error", reject);
});
const receivedChunks: Buffer[] = [];
// A `net.Socket` stays paused (and never surfaces the peer's eventual FIN as `close`) until
// something actively consumes its readable side — attaching this listener is what makes the
// socket start flowing, not merely a way to capture the response for the assertion below.
socket.on("data", (chunk) => receivedChunks.push(chunk as Buffer));
const closed = new Promise<void>((resolve) => socket.once("close", resolve));
// An incomplete request: a request line and one header, deliberately never terminated by the
// blank line that would mark the end of the headers, and no further data ever sent.
socket.write("GET /api/v1/principal HTTP/1.1\r\nHost: 127.0.0.1\r\n");
await closed;
expect(socket.destroyed).toBe(true);
const received = Buffer.concat(receivedChunks).toString("utf8");
expect(received).toContain("408");
} finally {
await running.shutdown().catch(() => undefined);
await dropDatabase(database.name);
}
});
it("closes a connection whose body never completes within the configured requestTimeout, never invoking a handler or writing a second response", async () => {
const database = await createDisposableDatabase("node_timeouts_body");
await applyCoreMigrations(database.url);
const config = createApiRuntimeConfig(
{
databaseUrl: database.url,
host: "127.0.0.1",
identity: {
baseUrl: identityBaseUrl,
publicSignupEnabled: false,
secret: "integration-test-secret-0123456789abcdefgh",
trustedOrigins: [identityBaseUrl, dashboardOrigin],
},
port: 0,
},
{ environment: "production" },
);
const running = await startApi(config, {
nodeServerTimeoutOverride: {
connectionsCheckingIntervalMs: 100,
headersTimeoutMs: 300,
keepAliveTimeoutMs: 200,
requestTimeoutMs: 400,
},
});
try {
const address = running.server.address();
if (!address || typeof address !== "object") {
throw harnessError("The API integration server has no bound address.");
}
const socket = net.connect({ host: "127.0.0.1", port: address.port });
await new Promise<void>((resolve, reject) => {
socket.once("connect", () => resolve());
socket.once("error", reject);
});
const receivedChunks: Buffer[] = [];
socket.on("data", (chunk) => receivedChunks.push(chunk as Buffer));
const closed = new Promise<void>((resolve) => socket.once("close", resolve));
// Complete, well-formed headers declaring a body that never actually arrives.
socket.write(
"POST /api/auth/sign-in/email HTTP/1.1\r\n" +
"Host: 127.0.0.1\r\n" +
"Content-Type: application/json\r\n" +
"Content-Length: 1000\r\n" +
"\r\n" +
'{"partial":',
);
await closed;
const received = Buffer.concat(receivedChunks).toString("utf8");
// Node's own documented requestTimeout behavior: a 408, never a real sign-in attempt (which
// would need the complete body to ever reach Better Auth), and never a second response.
expect(received).toContain("408");
expect(received).not.toContain("Set-Cookie");
expect(received.match(/^HTTP\/1\.1 \d{3}/gmu)?.length ?? 0).toBeLessThanOrEqual(1);
} finally {
await running.shutdown().catch(() => undefined);
await dropDatabase(database.name);
}
});
});
+111
View File
@@ -14,6 +14,7 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { IdentityRuntimeError } from "@ekklesia/core/identity";
import { describe, expect, it, vi } from "vitest";
import { createApiApp, type CreateApiAppAuthorizationOptions } from "../src/app.js";
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
const trustedOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test";
@@ -226,6 +227,116 @@ describe("Ekklesia API Hono application", () => {
expect(response.headers.get("cache-control")).toBe("no-store");
});
describe("auth request body limit", () => {
it("accepts a request exactly at the auth body limit", async () => {
const runtime = fakeRuntime();
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/auth/sign-in/email", {
body: "a".repeat(AUTH_REQUEST_MAX_BODY_BYTES),
headers: { "content-length": String(AUTH_REQUEST_MAX_BODY_BYTES) },
method: "POST",
});
expect(response.status).toBe(200);
expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1);
});
it("rejects a request one byte over the auth body limit with a sanitized 413, never invoking Better Auth", async () => {
const runtime = fakeRuntime();
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/auth/sign-in/email", {
body: "a".repeat(AUTH_REQUEST_MAX_BODY_BYTES + 1),
headers: { "content-length": String(AUTH_REQUEST_MAX_BODY_BYTES + 1) },
method: "POST",
});
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
expect(response.headers.get("cache-control")).toBe("no-store");
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
});
it("rejects an oversized declared Content-Length from its header alone, without invoking Better Auth", async () => {
// A declared-oversized Content-Length is rejected synchronously from the header value itself
// (`hono/body-limit`'s pinned implementation never reads the stream for this case) — proven
// here by the outcome (413, Better Auth never invoked) rather than by instrumenting the
// stream, since this in-memory test harness's own `Request`/body plumbing may touch the
// stream for reasons unrelated to `bodyLimit`'s own logic.
const runtime = fakeRuntime();
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode("a"));
controller.close();
},
});
const response = await app.request("/api/auth/sign-in/email", {
body: stream,
duplex: "half",
headers: { "content-length": String(AUTH_REQUEST_MAX_BODY_BYTES * 10) },
method: "POST",
} as RequestInit);
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
});
it("rejects an oversized streamed body with no declared Content-Length", async () => {
const runtime = fakeRuntime();
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
const chunk = new Uint8Array(1024).fill(97);
let enqueued = 0;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
if (enqueued > AUTH_REQUEST_MAX_BODY_BYTES) {
controller.close();
return;
}
enqueued += chunk.length;
controller.enqueue(chunk);
},
});
const response = await app.request("/api/auth/sign-in/email", {
body: stream,
duplex: "half",
method: "POST",
} as RequestInit);
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
});
});
it("has deterministic 404 behavior for an unknown route", async () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
+134
View File
@@ -15,6 +15,7 @@ import {
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { Hono } from "hono";
import { describe, expect, it, vi } from "vitest";
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
import { createGrantsRoutes } from "../src/grants-routes.js";
const trustedOrigin = "https://dashboard.example.test";
@@ -378,3 +379,136 @@ describe("POST /api/v1/grants/:grantId/revoke", () => {
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_REVOKE_FAILED" });
});
});
describe("Core grant mutation request body limit", () => {
it("accepts a comfortably-under-limit grant-creation body and rejects one byte over the exact limit", async () => {
const store = fakeStore();
const { app } = buildApp({ store });
const overSizedBody = "a".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1);
const overResponse = await app.request("/api/v1/grants", {
body: overSizedBody,
headers: {
"content-length": String(overSizedBody.length),
"content-type": "application/json",
origin: trustedOrigin,
},
method: "POST",
});
expect(overResponse.status).toBe(413);
expect(await overResponse.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
expect(store.createGrant).not.toHaveBeenCalled();
// A well-formed, comfortably-under-limit payload still succeeds through the same route.
const underResponse = await app.request("/api/v1/grants", {
body: JSON.stringify({ permission: registeredPermission, subjectUserId }),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
});
expect(underResponse.status).not.toBe(413);
});
it("rejects an oversized declared Content-Length on grant creation without invoking permission evaluation", async () => {
const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: true });
const evaluator: AuthorizationEvaluator = { authorize: evaluatorAuthorize };
const store = fakeStore();
const app = new Hono();
app.route(
"/",
createGrantsRoutes({
evaluator,
registry: CORE_PERMISSION_REGISTRY,
runtime: fakeRuntime(),
store,
trustedOrigins: new Set([trustedOrigin]),
}),
);
const response = await app.request("/api/v1/grants", {
body: "a",
headers: {
"content-length": String(CORE_MUTATION_MAX_BODY_BYTES * 10),
"content-type": "application/json",
origin: trustedOrigin,
},
method: "POST",
});
expect(response.status).toBe(413);
expect(evaluatorAuthorize).not.toHaveBeenCalled();
expect(store.createGrant).not.toHaveBeenCalled();
});
it("rejects an oversized streamed grant-creation body with no declared Content-Length", async () => {
const store = fakeStore();
const { app } = buildApp({ store });
const chunk = new Uint8Array(1024).fill(97);
let enqueued = 0;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
if (enqueued > CORE_MUTATION_MAX_BODY_BYTES) {
controller.close();
return;
}
enqueued += chunk.length;
controller.enqueue(chunk);
},
});
const response = await app.request("/api/v1/grants", {
body: stream,
duplex: "half",
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
expect(store.createGrant).not.toHaveBeenCalled();
});
it("does not read an oversized body from an untrusted Origin: the trusted-origin rejection wins first", async () => {
const store = fakeStore();
const { app } = buildApp({ store });
const chunk = new Uint8Array(1024).fill(97);
let pullCount = 0;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
pullCount += 1;
if (pullCount > 3) {
controller.close();
return;
}
controller.enqueue(chunk);
},
});
const response = await app.request("/api/v1/grants", {
body: stream,
duplex: "half",
headers: { "content-type": "application/json", origin: untrustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect(store.createGrant).not.toHaveBeenCalled();
});
it("also bounds the revoke mutation body", async () => {
const store = fakeStore();
const { app } = buildApp({ store });
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
body: "a".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1),
headers: {
"content-length": String(CORE_MUTATION_MAX_BODY_BYTES + 1),
origin: trustedOrigin,
},
method: "POST",
});
expect(response.status).toBe(413);
expect(store.revokeGrant).not.toHaveBeenCalled();
});
});
+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 });
+264 -2
View File
@@ -49,8 +49,14 @@ function fakeStore(): AuthorizationStore {
};
}
function moduleRoute(fields: RegisteredModuleHttpRoute): RegisteredModuleHttpRoute {
return Object.freeze({ ...fields });
/** Defaults `maxBodyBytes` to a generous value for tests that are not themselves exercising the
* body-size contract, so the many pre-existing routing/authorization/CORS scenarios below do not
* each need to restate an unrelated body-size choice; the dedicated "module route body limits"
* suite further down overrides it explicitly to prove the contract itself. */
function moduleRoute(
fields: Omit<RegisteredModuleHttpRoute, "maxBodyBytes"> & { maxBodyBytes?: number },
): RegisteredModuleHttpRoute {
return Object.freeze({ maxBodyBytes: 1024, ...fields });
}
/** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting,
@@ -1147,6 +1153,262 @@ describe("module route HEAD semantics", () => {
});
});
describe("module route body limits", () => {
it("rejects a non-empty declared body on a route declaring maxBodyBytes: 0", async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
const { app } = buildModuleApp({
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
routes: [
moduleRoute({
handler,
maxBodyBytes: 0,
method: "POST",
moduleId: "calendar",
path: "/events",
permission: writePermission,
}),
],
});
const response = await app.request("/api/v1/modules/calendar/events", {
body: "a",
headers: { "content-length": "1", origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
expect(handler).not.toHaveBeenCalled();
});
it("rejects a non-empty streamed body on a route declaring maxBodyBytes: 0", async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
const { app } = buildModuleApp({
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
routes: [
moduleRoute({
handler,
maxBodyBytes: 0,
method: "POST",
moduleId: "calendar",
path: "/events",
permission: writePermission,
}),
],
});
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode("a"));
controller.close();
},
});
const response = await app.request("/api/v1/modules/calendar/events", {
body: stream,
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(413);
expect(handler).not.toHaveBeenCalled();
});
it("accepts a genuinely empty body on a route declaring maxBodyBytes: 0", async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
const { app } = buildModuleApp({
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
routes: [
moduleRoute({
handler,
maxBodyBytes: 0,
method: "POST",
moduleId: "calendar",
path: "/events",
permission: writePermission,
}),
],
});
const response = await app.request("/api/v1/modules/calendar/events", {
headers: { origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(200);
expect(handler).toHaveBeenCalledTimes(1);
});
it("accepts a body exactly at an explicit maxBodyBytes limit and rejects one byte over", async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
const limit = 32;
const { app } = buildModuleApp({
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
routes: [
moduleRoute({
handler,
maxBodyBytes: limit,
method: "POST",
moduleId: "calendar",
path: "/events",
permission: writePermission,
}),
],
});
const atLimit = "a".repeat(limit);
const atLimitResponse = await app.request("/api/v1/modules/calendar/events", {
body: atLimit,
headers: { "content-length": String(limit), origin: trustedOrigin },
method: "POST",
});
expect(atLimitResponse.status).toBe(200);
const overLimit = "a".repeat(limit + 1);
const overLimitResponse = await app.request("/api/v1/modules/calendar/events", {
body: overLimit,
headers: { "content-length": String(limit + 1), origin: trustedOrigin },
method: "POST",
});
expect(overLimitResponse.status).toBe(413);
expect(await overLimitResponse.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
expect(handler).toHaveBeenCalledTimes(1);
});
it("rejects an oversized streamed body on a route with an explicit limit, with no declared Content-Length", async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
const limit = 64;
const { app } = buildModuleApp({
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
routes: [
moduleRoute({
handler,
maxBodyBytes: limit,
method: "POST",
moduleId: "calendar",
path: "/events",
permission: writePermission,
}),
],
});
const chunk = new Uint8Array(16).fill(97);
let enqueued = 0;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
if (enqueued > limit) {
controller.close();
return;
}
enqueued += chunk.length;
controller.enqueue(chunk);
},
});
const response = await app.request("/api/v1/modules/calendar/events", {
body: stream,
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(413);
expect(handler).not.toHaveBeenCalled();
});
it("enforces the body limit before authorization runs, so an oversized denied request never reaches the evaluator", async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
const evaluator: AuthorizationEvaluator = {
authorize: vi.fn().mockResolvedValue({ allowed: false }),
};
const { app } = buildModuleApp({
evaluator,
registry: permissionRegistry([{ id: writePermission, baseline: "restricted" }]),
routes: [
moduleRoute({
handler,
maxBodyBytes: 8,
method: "POST",
moduleId: "calendar",
path: "/events",
permission: writePermission,
}),
],
});
const response = await app.request("/api/v1/modules/calendar/events", {
body: "a".repeat(9),
headers: { "content-length": "9", origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
expect(evaluator.authorize).not.toHaveBeenCalled();
expect(handler).not.toHaveBeenCalled();
});
it("never reads an oversized body from an untrusted Origin: the trusted-origin rejection wins first", async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
const { app } = buildModuleApp({
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
routes: [
moduleRoute({
handler,
maxBodyBytes: 8,
method: "POST",
moduleId: "calendar",
path: "/events",
permission: writePermission,
}),
],
});
const chunk = new Uint8Array(16).fill(97);
let pullCount = 0;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
pullCount += 1;
if (pullCount > 3) {
controller.close();
return;
}
controller.enqueue(chunk);
},
});
const response = await app.request("/api/v1/modules/calendar/events", {
body: stream,
duplex: "half",
headers: { origin: untrustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect(handler).not.toHaveBeenCalled();
});
it("applies the route's own declared limit to a GET route too", async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
const { app } = buildModuleApp({
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
routes: [
moduleRoute({
handler,
maxBodyBytes: 0,
method: "GET",
moduleId: "calendar",
path: "/events",
permission: readPermission,
}),
],
});
const response = await app.request("/api/v1/modules/calendar/events");
expect(response.status).toBe(200);
expect(handler).toHaveBeenCalledTimes(1);
});
});
describe("module routes and the rest of the API", () => {
it("does not invoke a handler for an unmatched module path", async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));