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
@@ -15,6 +15,7 @@ import {
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import { Hono } from "hono";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||
import { createGrantsRoutes } from "../src/grants-routes.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
@@ -378,3 +379,136 @@ describe("POST /api/v1/grants/:grantId/revoke", () => {
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_REVOKE_FAILED" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Core grant mutation request body limit", () => {
|
||||
it("accepts a comfortably-under-limit grant-creation body and rejects one byte over the exact limit", async () => {
|
||||
const store = fakeStore();
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const overSizedBody = "a".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1);
|
||||
const overResponse = await app.request("/api/v1/grants", {
|
||||
body: overSizedBody,
|
||||
headers: {
|
||||
"content-length": String(overSizedBody.length),
|
||||
"content-type": "application/json",
|
||||
origin: trustedOrigin,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
expect(overResponse.status).toBe(413);
|
||||
expect(await overResponse.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
expect(store.createGrant).not.toHaveBeenCalled();
|
||||
|
||||
// A well-formed, comfortably-under-limit payload still succeeds through the same route.
|
||||
const underResponse = await app.request("/api/v1/grants", {
|
||||
body: JSON.stringify({ permission: registeredPermission, subjectUserId }),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
expect(underResponse.status).not.toBe(413);
|
||||
});
|
||||
|
||||
it("rejects an oversized declared Content-Length on grant creation without invoking permission evaluation", async () => {
|
||||
const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: true });
|
||||
const evaluator: AuthorizationEvaluator = { authorize: evaluatorAuthorize };
|
||||
const store = fakeStore();
|
||||
const app = new Hono();
|
||||
app.route(
|
||||
"/",
|
||||
createGrantsRoutes({
|
||||
evaluator,
|
||||
registry: CORE_PERMISSION_REGISTRY,
|
||||
runtime: fakeRuntime(),
|
||||
store,
|
||||
trustedOrigins: new Set([trustedOrigin]),
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await app.request("/api/v1/grants", {
|
||||
body: "a",
|
||||
headers: {
|
||||
"content-length": String(CORE_MUTATION_MAX_BODY_BYTES * 10),
|
||||
"content-type": "application/json",
|
||||
origin: trustedOrigin,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(evaluatorAuthorize).not.toHaveBeenCalled();
|
||||
expect(store.createGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an oversized streamed grant-creation body with no declared Content-Length", async () => {
|
||||
const store = fakeStore();
|
||||
const { app } = buildApp({ store });
|
||||
const chunk = new Uint8Array(1024).fill(97);
|
||||
let enqueued = 0;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (enqueued > CORE_MUTATION_MAX_BODY_BYTES) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
enqueued += chunk.length;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/grants", {
|
||||
body: stream,
|
||||
duplex: "half",
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
expect(store.createGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not read an oversized body from an untrusted Origin: the trusted-origin rejection wins first", async () => {
|
||||
const store = fakeStore();
|
||||
const { app } = buildApp({ store });
|
||||
const chunk = new Uint8Array(1024).fill(97);
|
||||
let pullCount = 0;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
pullCount += 1;
|
||||
if (pullCount > 3) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/grants", {
|
||||
body: stream,
|
||||
duplex: "half",
|
||||
headers: { "content-type": "application/json", origin: untrustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
expect(store.createGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("also bounds the revoke mutation body", async () => {
|
||||
const store = fakeStore();
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
|
||||
body: "a".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1),
|
||||
headers: {
|
||||
"content-length": String(CORE_MUTATION_MAX_BODY_BYTES + 1),
|
||||
origin: trustedOrigin,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(store.revokeGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user