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
@@ -1,5 +1,6 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import net from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
import { parseEkklesiaUserId, parsePermissionId, SYSTEM_ACTOR } from "@ekklesia/contracts";
|
||||
@@ -20,7 +21,7 @@ import { Client, type QueryResultRow } from "pg";
|
||||
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||
import type { ClientAddressConfigInput } from "../src/client-address.js";
|
||||
import { createApiRuntimeConfig } from "../src/config.js";
|
||||
import { startApi, type RunningApi } from "../src/server.js";
|
||||
import { startApi, type NodeServerTimeouts, type RunningApi } from "../src/server.js";
|
||||
|
||||
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
|
||||
const coreMigrationsDirectory = path.join(
|
||||
@@ -275,6 +276,7 @@ async function createFixtureModule(
|
||||
method: "GET",
|
||||
path: "/events/:eventId",
|
||||
permission: "calendar.events.read",
|
||||
maxBodyBytes: 0,
|
||||
async handler(request, context) {
|
||||
return Response.json({ eventId: context.params.eventId, principal: context.principal });
|
||||
},
|
||||
@@ -283,6 +285,7 @@ async function createFixtureModule(
|
||||
method: "POST",
|
||||
path: "/events",
|
||||
permission: "calendar.events.read",
|
||||
maxBodyBytes: 1024,
|
||||
async handler(request, context) {
|
||||
return Response.json({ created: true, principal: context.principal }, { status: 201 });
|
||||
},
|
||||
@@ -329,6 +332,7 @@ function apiOrigin(running: RunningApi): string {
|
||||
interface StartConfiguredApiOptions {
|
||||
readonly clientAddress?: ClientAddressConfigInput;
|
||||
readonly modulesDirectoryOverride?: string;
|
||||
readonly nodeServerTimeoutOverride?: Partial<NodeServerTimeouts>;
|
||||
/** Defaults to `true`: almost every existing scenario in this suite signs accounts up over HTTP
|
||||
* and needs it enabled. Pass `false` explicitly for the dedicated signup-gating scenarios. */
|
||||
readonly publicSignupEnabled?: boolean;
|
||||
@@ -364,6 +368,9 @@ async function startConfiguredApi(
|
||||
...(options.modulesDirectoryOverride === undefined
|
||||
? {}
|
||||
: { modulesDirectoryOverride: options.modulesDirectoryOverride }),
|
||||
...(options.nodeServerTimeoutOverride === undefined
|
||||
? {}
|
||||
: { nodeServerTimeoutOverride: options.nodeServerTimeoutOverride }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1929,3 +1936,128 @@ describe.sequential("live API — client-address resolution and auth rate-limit
|
||||
await dropDatabase(database.name);
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("live API — Node header/request timeouts", () => {
|
||||
it("closes a connection whose headers never complete within the configured headersTimeout, never invoking a handler", async () => {
|
||||
const database = await createDisposableDatabase("node_timeouts");
|
||||
await applyCoreMigrations(database.url);
|
||||
const config = createApiRuntimeConfig(
|
||||
{
|
||||
databaseUrl: database.url,
|
||||
host: "127.0.0.1",
|
||||
identity: {
|
||||
baseUrl: identityBaseUrl,
|
||||
publicSignupEnabled: false,
|
||||
secret: "integration-test-secret-0123456789abcdefgh",
|
||||
trustedOrigins: [identityBaseUrl],
|
||||
},
|
||||
port: 0,
|
||||
},
|
||||
{ environment: "production" },
|
||||
);
|
||||
// Deliberately tiny, test-only timeouts — production composition (server.ts's `main`) never
|
||||
// sets this override — so this test observes the real transport-event close deterministically,
|
||||
// in well under a second, rather than waiting out the production values.
|
||||
const running = await startApi(config, {
|
||||
nodeServerTimeoutOverride: {
|
||||
connectionsCheckingIntervalMs: 100,
|
||||
headersTimeoutMs: 200,
|
||||
keepAliveTimeoutMs: 200,
|
||||
requestTimeoutMs: 300,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const address = running.server.address();
|
||||
if (!address || typeof address !== "object") {
|
||||
throw harnessError("The API integration server has no bound address.");
|
||||
}
|
||||
|
||||
const socket = net.connect({ host: "127.0.0.1", port: address.port });
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once("connect", () => resolve());
|
||||
socket.once("error", reject);
|
||||
});
|
||||
const receivedChunks: Buffer[] = [];
|
||||
// A `net.Socket` stays paused (and never surfaces the peer's eventual FIN as `close`) until
|
||||
// something actively consumes its readable side — attaching this listener is what makes the
|
||||
// socket start flowing, not merely a way to capture the response for the assertion below.
|
||||
socket.on("data", (chunk) => receivedChunks.push(chunk as Buffer));
|
||||
const closed = new Promise<void>((resolve) => socket.once("close", resolve));
|
||||
|
||||
// An incomplete request: a request line and one header, deliberately never terminated by the
|
||||
// blank line that would mark the end of the headers, and no further data ever sent.
|
||||
socket.write("GET /api/v1/principal HTTP/1.1\r\nHost: 127.0.0.1\r\n");
|
||||
|
||||
await closed;
|
||||
expect(socket.destroyed).toBe(true);
|
||||
const received = Buffer.concat(receivedChunks).toString("utf8");
|
||||
expect(received).toContain("408");
|
||||
} finally {
|
||||
await running.shutdown().catch(() => undefined);
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
});
|
||||
|
||||
it("closes a connection whose body never completes within the configured requestTimeout, never invoking a handler or writing a second response", async () => {
|
||||
const database = await createDisposableDatabase("node_timeouts_body");
|
||||
await applyCoreMigrations(database.url);
|
||||
const config = createApiRuntimeConfig(
|
||||
{
|
||||
databaseUrl: database.url,
|
||||
host: "127.0.0.1",
|
||||
identity: {
|
||||
baseUrl: identityBaseUrl,
|
||||
publicSignupEnabled: false,
|
||||
secret: "integration-test-secret-0123456789abcdefgh",
|
||||
trustedOrigins: [identityBaseUrl, dashboardOrigin],
|
||||
},
|
||||
port: 0,
|
||||
},
|
||||
{ environment: "production" },
|
||||
);
|
||||
const running = await startApi(config, {
|
||||
nodeServerTimeoutOverride: {
|
||||
connectionsCheckingIntervalMs: 100,
|
||||
headersTimeoutMs: 300,
|
||||
keepAliveTimeoutMs: 200,
|
||||
requestTimeoutMs: 400,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const address = running.server.address();
|
||||
if (!address || typeof address !== "object") {
|
||||
throw harnessError("The API integration server has no bound address.");
|
||||
}
|
||||
|
||||
const socket = net.connect({ host: "127.0.0.1", port: address.port });
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once("connect", () => resolve());
|
||||
socket.once("error", reject);
|
||||
});
|
||||
const receivedChunks: Buffer[] = [];
|
||||
socket.on("data", (chunk) => receivedChunks.push(chunk as Buffer));
|
||||
const closed = new Promise<void>((resolve) => socket.once("close", resolve));
|
||||
|
||||
// Complete, well-formed headers declaring a body that never actually arrives.
|
||||
socket.write(
|
||||
"POST /api/auth/sign-in/email HTTP/1.1\r\n" +
|
||||
"Host: 127.0.0.1\r\n" +
|
||||
"Content-Type: application/json\r\n" +
|
||||
"Content-Length: 1000\r\n" +
|
||||
"\r\n" +
|
||||
'{"partial":',
|
||||
);
|
||||
|
||||
await closed;
|
||||
const received = Buffer.concat(receivedChunks).toString("utf8");
|
||||
// Node's own documented requestTimeout behavior: a 408, never a real sign-in attempt (which
|
||||
// would need the complete body to ever reach Better Auth), and never a second response.
|
||||
expect(received).toContain("408");
|
||||
expect(received).not.toContain("Set-Cookie");
|
||||
expect(received.match(/^HTTP\/1\.1 \d{3}/gmu)?.length ?? 0).toBeLessThanOrEqual(1);
|
||||
} finally {
|
||||
await running.shutdown().catch(() => undefined);
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user