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
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user