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
+3
-1
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user