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
+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");