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
@@ -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([]),
|
||||
|
||||
Reference in New Issue
Block a user