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
+54 -13
View File
@@ -21,6 +21,23 @@ export function isModuleHttpMethod(value: unknown): value is ModuleHttpMethod {
return typeof value === "string" && moduleHttpMethods.has(value);
}
/**
* The platform-owned hard ceiling on any single module route's declared request-body allowance, in
* bytes. No module route may declare a `maxBodyBytes` above this value — validated at registration
* (`safeParseModuleRouteDefinition`, below), not merely documented. This is a security contract,
* not a manifest capability: it does not gate on `manifestVersion` and is enforced identically for
* every installed module regardless of its declared compatibility range. Large media transfer
* belongs to a future dedicated storage design, never to widening this ceiling.
*/
export const MODULE_ROUTE_MAX_BODY_BYTES_CEILING = 1_048_576;
const moduleRouteMaxBodyBytesSchema = z
.number()
.int()
.finite()
.min(0)
.max(MODULE_ROUTE_MAX_BODY_BYTES_CEILING);
const MODULE_ROUTE_PATH_MAX_LENGTH = 256;
const staticSegmentPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
const parameterSegmentPattern = /^:[a-z][a-zA-Z0-9]*$/u;
@@ -151,15 +168,21 @@ export type ModuleRouteHandler = (
/**
* What a module passes to `http.route(...)` during `register`. `permission` is a plain string here
* (never the branded `PermissionId`) so a module can write a literal permission id without an
* explicit parse call first; `http.route` validates and brands it. The type has exactly these four
* fields — a module cannot specify a baseline, mount prefix, absolute path, CORS/origin policy,
* authentication behavior, grant/platform-owner state, middleware, audit metadata, another module's
* namespace, or an unprotected/no-permission route.
* explicit parse call first; `http.route` validates and brands it. `maxBodyBytes` is the route's
* conscious request-body contract — `0` means the route accepts no body at all (a non-empty
* declared or streamed body is rejected), and any other value is the exact maximum number of bytes
* accepted, capped at `MODULE_ROUTE_MAX_BODY_BYTES_CEILING`. This is a security contract the
* platform enforces before a handler ever runs, not a manifest capability: it is not optional and
* has no implicit default, so a module author must make an explicit choice for every route. The
* type has exactly these five fields — a module cannot specify a baseline, mount prefix, absolute
* path, CORS/origin policy, authentication behavior, grant/platform-owner state, middleware, audit
* metadata, another module's namespace, or an unprotected/no-permission route.
*/
export interface ModuleRouteDefinitionInput {
readonly method: ModuleHttpMethod;
readonly path: string;
readonly permission: string;
readonly maxBodyBytes: number;
readonly handler: ModuleRouteHandler;
}
@@ -169,6 +192,7 @@ export interface ModuleRouteDefinition {
readonly method: ModuleHttpMethod;
readonly path: ModuleRoutePath;
readonly permission: PermissionId;
readonly maxBodyBytes: number;
readonly handler: ModuleRouteHandler;
}
@@ -187,18 +211,29 @@ export interface RegisteredModuleHttpRoute {
readonly method: ModuleHttpMethod;
readonly path: ModuleRoutePath;
readonly permission: PermissionId;
readonly maxBodyBytes: number;
readonly handler: ModuleRouteHandler;
}
const moduleRouteDefinitionKeys = new Set(["method", "path", "permission", "handler"]);
const moduleRouteDefinitionKeys = new Set([
"method",
"path",
"permission",
"maxBodyBytes",
"handler",
]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function hasModuleRouteDefinitionShape(
value: unknown,
): value is { method: unknown; path: unknown; permission: unknown; handler: unknown } {
function hasModuleRouteDefinitionShape(value: unknown): value is {
method: unknown;
path: unknown;
permission: unknown;
maxBodyBytes: unknown;
handler: unknown;
} {
if (!isRecord(value)) {
return false;
}
@@ -214,7 +249,8 @@ export type ModuleRouteDefinitionIssueCode =
| "MODULE_ROUTE_DEFINITION_INVALID"
| "MODULE_ROUTE_METHOD_INVALID"
| "MODULE_ROUTE_PATH_INVALID"
| "MODULE_ROUTE_PERMISSION_INVALID";
| "MODULE_ROUTE_PERMISSION_INVALID"
| "MODULE_ROUTE_MAX_BODY_BYTES_INVALID";
export type ModuleRouteDefinitionParseResult =
| { readonly success: true; readonly data: ModuleRouteDefinition }
@@ -222,10 +258,10 @@ export type ModuleRouteDefinitionParseResult =
/**
* Validates every field-level rule a route definition must satisfy on its own — exact shape,
* canonical method, canonical path, well-formed permission id — but never whether the permission is
* actually declared by the registering module. That ownership check needs the module's own manifest
* and belongs to the caller (Core's module runtime), not this framework-neutral contract. Never
* echoes the rejected input in the returned code.
* canonical method, canonical path, well-formed permission id, well-formed body-size contract — but
* never whether the permission is actually declared by the registering module. That ownership check
* needs the module's own manifest and belongs to the caller (Core's module runtime), not this
* framework-neutral contract. Never echoes the rejected input in the returned code.
*/
export function safeParseModuleRouteDefinition(value: unknown): ModuleRouteDefinitionParseResult {
if (!hasModuleRouteDefinitionShape(value)) {
@@ -242,12 +278,17 @@ export function safeParseModuleRouteDefinition(value: unknown): ModuleRouteDefin
if (!parsedPermission.success) {
return { success: false, code: "MODULE_ROUTE_PERMISSION_INVALID" };
}
const parsedMaxBodyBytes = moduleRouteMaxBodyBytesSchema.safeParse(value.maxBodyBytes);
if (!parsedMaxBodyBytes.success) {
return { success: false, code: "MODULE_ROUTE_MAX_BODY_BYTES_INVALID" };
}
return {
success: true,
data: Object.freeze({
method: value.method,
path: parsedPath.data,
permission: parsedPermission.data,
maxBodyBytes: parsedMaxBodyBytes.data,
handler: value.handler as ModuleRouteHandler,
}),
};
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
isModuleHttpMethod,
MODULE_HTTP_METHODS,
MODULE_ROUTE_MAX_BODY_BYTES_CEILING,
type ModuleRouteDefinitionInput,
moduleRoutePathSchema,
moduleRouteShapeKey,
@@ -22,6 +23,7 @@ function validDefinition(
method: "GET",
path: "/events/:eventId",
permission: "calendar.events.read",
maxBodyBytes: 0,
handler: noopHandler,
...overrides,
};
@@ -153,6 +155,7 @@ describe("safeParseModuleRouteDefinition", () => {
method: "GET",
path: "/events/:eventId",
permission: "calendar.events.read",
maxBodyBytes: 0,
handler: noopHandler,
});
expect(Object.isFrozen(result.data)).toBe(true);
@@ -216,6 +219,44 @@ describe("safeParseModuleRouteDefinition", () => {
});
});
it("accepts a maxBodyBytes of exactly zero (no body accepted) and exactly the platform ceiling", () => {
expect(safeParseModuleRouteDefinition(validDefinition({ maxBodyBytes: 0 })).success).toBe(true);
const atCeiling = safeParseModuleRouteDefinition(
validDefinition({ maxBodyBytes: MODULE_ROUTE_MAX_BODY_BYTES_CEILING }),
);
expect(atCeiling.success).toBe(true);
if (atCeiling.success) {
expect(atCeiling.data.maxBodyBytes).toBe(MODULE_ROUTE_MAX_BODY_BYTES_CEILING);
}
});
it("rejects a negative, non-integer, non-finite, or over-ceiling maxBodyBytes", () => {
for (const maxBodyBytes of [
-1,
1.5,
Number.NaN,
Number.POSITIVE_INFINITY,
Number.NEGATIVE_INFINITY,
MODULE_ROUTE_MAX_BODY_BYTES_CEILING + 1,
]) {
expect(safeParseModuleRouteDefinition(validDefinition({ maxBodyBytes }))).toEqual({
success: false,
code: "MODULE_ROUTE_MAX_BODY_BYTES_INVALID",
});
}
});
it("rejects a non-numeric or missing maxBodyBytes", () => {
expect(
safeParseModuleRouteDefinition({ ...validDefinition(), maxBodyBytes: "0" }),
).toMatchObject({ success: false, code: "MODULE_ROUTE_MAX_BODY_BYTES_INVALID" });
const { maxBodyBytes: _maxBodyBytes, ...withoutMaxBodyBytes } = validDefinition();
expect(safeParseModuleRouteDefinition(withoutMaxBodyBytes)).toEqual({
success: false,
code: "MODULE_ROUTE_DEFINITION_INVALID",
});
});
it("does not retain the caller's mutable definition object: mutating it afterward cannot change the parsed result", () => {
const source = { ...validDefinition() };
const result = safeParseModuleRouteDefinition(source);