Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/request-body-limit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@objectstack/plugin-hono-server': patch
'@objectstack/hono': patch
'@objectstack/service-storage': patch
'@objectstack/spec': patch
---

fix(plugin-hono-server, hono)!: a request body over the server's limit is answered 413 PAYLOAD_TOO_LARGE and is not read past the limit

Clause-②: yes (narrowing) — a JSON, form or other body over 10 MiB that both HTTP doors read today is answered `413 PAYLOAD_TOO_LARGE`, unread past the limit, unless an operator raises the limit. The published surface also widens: `HonoPluginOptions.maxRequestBodyBytes`, `createHonoApp`'s `maxRequestBodyBytes` option, `HonoHttpServer.setMaxRequestBodyBytes()` / `getMaxRequestBodyBytes()`, the `OS_HTTP_MAX_BODY_BYTES` environment variable, and the `body-limit` exports of `@objectstack/plugin-hono-server`.
Metadata-protocol: none
Skills-docs: follow-up owed — content/docs/api/error-catalog.mdx (the `VALIDATION_ERROR` callout) and content/docs/protocol/kernel/error-handling.mdx (the 413 row) list the causes of `413 PAYLOAD_TOO_LARGE` and do not yet name a request body over the server's limit, nor `maxRequestBodyBytes` / `OS_HTTP_MAX_BODY_BYTES`; no file under `skills/**` teaches a request-body limit.

<!-- adr-0087: not-required (no-migration-prescription) no metadata an author writes changes and no stored row is rewritten: the limit bounds what the HTTP doors read from a request, and an operator raises it with an option or an environment variable -->

**BREAKING** for a client that sends a JSON, form or other request body over 10 MiB to a data, API or auth route: that request is now answered `413 PAYLOAD_TOO_LARGE` instead of being read.

- **Who moves.** The operator of a deployment whose clients send bodies that large. Raise the limit: `maxRequestBodyBytes` (in bytes) on `HonoServerPlugin` or `createHonoApp`, `HonoHttpServer.setMaxRequestBodyBytes()` on an embedded adapter, or the `OS_HTTP_MAX_BODY_BYTES` environment variable. Or send the content as a file through a storage upload door, which keeps its own limit.
- **File uploads** through the storage doors (the presigned raw PUT, the chunk PUT and the upload link, one file or several) see no change: they stay bounded by `max_upload_mb`.

Both HTTP doors, the self-hosted server (`@objectstack/plugin-hono-server`) and `createHonoApp` (`@objectstack/hono`), now hold one request-body limit, 10 MiB by default. A request that declares a `Content-Length` over the limit is not read at all. A body with no declared length is counted while it is read, and reading stops once it passes the limit. In both cases the request is answered `413` with the registered code `PAYLOAD_TOO_LARGE`. A body of exactly the limit is read as before. The limit covers every reader of the body, not only the doors' own JSON and form parsing: routes mounted on the raw Hono app, and requests the doors forward whole to the auth service or to a domain, read through it as well. A route that never reads the body still reads nothing.

To change the limit, set `maxRequestBodyBytes` (in bytes) on `HonoServerPlugin`, call `HonoHttpServer.setMaxRequestBodyBytes()` on an embedded adapter, or pass `maxRequestBodyBytes` to `createHonoApp`. Without an option, the `OS_HTTP_MAX_BODY_BYTES` environment variable sets it. A value that is not a positive whole number of bytes fails the boot instead of serving with no limit or the wrong one.

Upload doors keep their own limit. The storage byte doors (the presigned raw PUT, the chunk PUT and the upload-link PUT) read the raw bytes through `IHttpRequest.rawBody()`, which still serves the whole body, so a file is bounded by the storage `max_upload_mb` setting exactly as before, even when that is larger than the request-body limit.

The upload link's several-file form is bounded by that door's own form limit (each file within `max_upload_mb`, times the files the link takes, plus the form's framing), not by the request-body limit. A route that is refused `req.body` for size and then reads `rawBody()` takes the read over under its own bound, and its own answer stands.
172 changes: 172 additions & 0 deletions packages/adapters/hono/src/hono-body-refusal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,3 +292,175 @@ describe('[#23914, #24042] both Hono doors answer one body alike, media type by
}
});
});

/**
* The request-body limit (`@objectstack/plugin-hono-server`'s
* `src/body-limit.ts`), on both doors. The client's body is a stream that
* counts what the server pulled from it, so "not read" and "not read past the
* limit" are measured, not inferred from the status.
*/
describe('a request body over the transport limit is answered 413 and not read past the limit, on both doors', () => {
const LIMIT = 64;
const CHUNK = 16;

/** A client body that yields `text` in `CHUNK`-byte pieces and counts the bytes the server pulled. */
function countedBody(text: string) {
const bytes = new TextEncoder().encode(text);
let sent = 0;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
if (sent >= bytes.byteLength) {
controller.close();
return;
}
const piece = bytes.slice(sent, sent + CHUNK);
sent += piece.byteLength;
controller.enqueue(piece);
},
}, { highWaterMark: 0 });
return { stream, sent: () => sent, total: bytes.byteLength };
}

/** A JSON document of exactly `size` bytes. */
const jsonOfSize = (size: number) => `{"a":"${'x'.repeat(size - 8)}"}`;

/** `declared` sends `Content-Length: size`; `chunked` sends `Transfer-Encoding` and no length. */
function init(method: string, contentType: string, stream: ReadableStream<Uint8Array>, framing: { declared?: number; chunked?: boolean }): RequestInit {
const headers: Record<string, string> = { 'content-type': contentType };
if (framing.declared !== undefined) headers['content-length'] = String(framing.declared);
if (framing.chunked) headers['transfer-encoding'] = 'chunked';
return { method, headers, body: stream, duplex: 'half' } as RequestInit;
}

const hostedApp = (limit = LIMIT) => createHonoApp({ kernel, prefix: PREFIX, cors: false, maxRequestBodyBytes: limit });

/** The self-hosted door: a reading route, a byte door that reads `rawBody()`, a raw-app reader, and a route that refuses before it reads. */
function directServer(limit = LIMIT) {
const server = new HonoHttpServer(0);
server.setMaxRequestBodyBytes(limit);
server.post(`${PREFIX}/data/:object/query`, (req, res) => { res.json({ success: true, data: req.body }); });
server.put(`${PREFIX}/bytes/:id`, async (req, res) => {
const bytes = await req.rawBody!();
res.json({ success: true, data: { size: bytes.byteLength, text: bytes.toString('utf8') } });
});
server.post(`${PREFIX}/signed-in-only`, (_req, res) => {
res.status(401).json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in first.' } });
});
// A raw-app route that reads the request itself, as the auth service's mount does.
server.getRawApp().post(`${PREFIX}/auth/sign-in/email`, async (c) => {
try {
return c.json({ success: true, data: await c.req.raw.json() });
} catch {
return c.json({ success: false, error: { code: 'INTERNAL_ERROR', message: 'reader fault' } }, 500);
}
});
return server;
}

const hosted = (path: string, method: string, contentType: string, stream: ReadableStream<Uint8Array>, framing: { declared?: number; chunked?: boolean }, limit?: number) =>
hostedApp(limit).request(`http://hosted.test${PREFIX}${path}`, init(method, contentType, stream, framing));
const direct = (path: string, method: string, contentType: string, stream: ReadableStream<Uint8Array>, framing: { declared?: number; chunked?: boolean }, limit?: number) =>
directServer(limit).getRawApp().fetch(new Request(`http://direct.test${PREFIX}${path}`, init(method, contentType, stream, framing)));

async function expectTooLarge(res: Response) {
const json = await res.json() as any;
expect(res.status, JSON.stringify(json)).toBe(413);
expect(json).toEqual({ success: false, error: { code: 'PAYLOAD_TOO_LARGE', message: expect.stringContaining(`(${LIMIT} bytes)`) } });
return json;
}

/** [label, the door, the path, the method] — every reader of a body on each door. */
const READERS: Array<[string, typeof hosted, string, string]> = [
['the hosted door, a domain that reads the parsed body', hosted, QUERY, 'POST'],
['the hosted door, a domain that reads the forwarded request', hosted, '/webhooks/redeliver', 'POST'],
['the hosted door, the auth fallback that reads the parsed body', hosted, '/auth/sign-in/email', 'POST'],
['the self-hosted door, a route that reads req.body', direct, QUERY, 'POST'],
['the self-hosted door, a raw-app route that reads the request itself', direct, '/auth/sign-in/email', 'POST'],
];

it.each(READERS)('%s: a declared length over the limit is answered 413 and no byte of it is read', async (_label, door, path, method) => {
const body = countedBody(jsonOfSize(LIMIT + 1));
await expectTooLarge(await door(path, method, 'application/json', body.stream, { declared: body.total }));
expect(body.sent()).toBe(0);
expect(seen).toEqual([]);
});

it.each(READERS)('%s: a body with no declared length is answered 413 once it passes the limit, and is not read further', async (_label, door, path, method) => {
const body = countedBody(jsonOfSize(LIMIT * 10));
await expectTooLarge(await door(path, method, 'application/json', body.stream, { chunked: true }));
expect(body.sent()).toBeLessThanOrEqual(LIMIT + 2 * CHUNK);
expect(body.sent()).toBeLessThan(body.total);
expect(seen).toEqual([]);
});

it.each([
['declared', { declared: LIMIT }],
['with no declared length', { chunked: true }],
] as const)('a body of exactly the limit, %s, is read as before on both doors', async (_label, framing) => {
const text = jsonOfSize(LIMIT);
const hostedRes = await hosted(QUERY, 'POST', 'application/json', countedBody(text).stream, framing);
const directRes = await direct(QUERY, 'POST', 'application/json', countedBody(text).stream, framing);
expect(hostedRes.status).toBe(200);
expect(directRes.status).toBe(200);
expect(await hostedRes.json()).toEqual({ success: true, data: JSON.parse(text) });
expect(await directRes.json()).toEqual({ success: true, data: JSON.parse(text) });
});

it('an operator-raised limit admits the larger body, on both doors', async () => {
const text = jsonOfSize(LIMIT * 3);
const hostedRes = await hosted(QUERY, 'POST', 'application/json', countedBody(text).stream, { declared: LIMIT * 3 }, LIMIT * 4);
const directRes = await direct(QUERY, 'POST', 'application/json', countedBody(text).stream, { chunked: true }, LIMIT * 4);
expect(hostedRes.status).toBe(200);
expect(directRes.status).toBe(200);
expect((await directRes.json() as any).data).toEqual(JSON.parse(text));
});

it.each([
['declared, sent as bytes', 'application/octet-stream', { declared: LIMIT * 4 }],
['declared, sent as the JSON file it is', 'application/json', { declared: LIMIT * 4 }],
['with no declared length, sent as bytes', 'application/octet-stream', { chunked: true }],
['with no declared length, sent as the JSON file it is', 'application/json', { chunked: true }],
] as const)('a byte door that reads rawBody() keeps its own bound: a body over the transport limit, %s, arrives whole', async (_label, contentType, framing) => {
const text = jsonOfSize(LIMIT * 4);
const res = await direct('/bytes/f_1', 'PUT', contentType, countedBody(text).stream, framing);
expect(res.status, await res.clone().text()).toBe(200);
expect(await res.json()).toEqual({ success: true, data: { size: LIMIT * 4, text } });
});

it('a route that refuses an anonymous caller before reading keeps its answer, and reads nothing', async () => {
const body = countedBody(jsonOfSize(LIMIT * 10));
const res = await direct('/signed-in-only', 'POST', 'application/json', body.stream, { declared: body.total });
expect(res.status).toBe(401);
expect(body.sent()).toBe(0);
});

it('CONTROL: a JSON and a form body within the limit are read as before, on both doors', async () => {
for (const [contentType, text, expected] of [
['application/json', '{"where":{"status":"churned"}}', { where: { status: 'churned' } }],
['application/x-www-form-urlencoded', 'token=abc&x=1', { token: 'abc', x: '1' }],
] as const) {
for (const framing of [{ declared: Buffer.byteLength(text) }, { chunked: true }]) {
seen.length = 0;
const hostedRes = await hosted(QUERY, 'POST', contentType, countedBody(text).stream, framing);
expect(hostedRes.status).toBe(200);
expect(seen).toEqual([{ path: QUERY, read: expected }]);
const directRes = await direct(QUERY, 'POST', contentType, countedBody(text).stream, framing);
expect(directRes.status).toBe(200);
expect(await directRes.json()).toEqual({ success: true, data: expected });
}
}
});

it('the default limit is 10 MiB on both doors, and a declared length over it is not read', async () => {
const TEN_MIB = 10 * 1024 * 1024;
expect(new HonoHttpServer(0).getMaxRequestBodyBytes()).toBe(TEN_MIB);
const server = new HonoHttpServer(0);
server.post(`${PREFIX}/data/:object/query`, (req, res) => { res.json({ success: true, data: req.body }); });
for (const app of [createHonoApp({ kernel, prefix: PREFIX, cors: false }), server.getRawApp()]) {
const body = countedBody(jsonOfSize(LIMIT));
const res = await app.request(`http://default.test${PREFIX}${QUERY}`, init('POST', 'application/json', body.stream, { declared: TEN_MIB + 1 }));
expect(res.status).toBe(413);
expect(body.sent()).toBe(0);
}
});
});
52 changes: 48 additions & 4 deletions packages/adapters/hono/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ import {
DEFAULT_CORS_ALLOW_HEADERS,
DEFAULT_CORS_EXPOSE_HEADERS,
declaredEnvelopeForThrow,
installRequestBodyLimit,
requestBodyGuardOf,
requestBodyTooLargeAnswer,
resolveMaxRequestBodyBytes,
RequestBodyTooLarge,
} from '@objectstack/plugin-hono-server';

export interface ObjectStackHonoCorsOptions {
Expand Down Expand Up @@ -83,6 +88,18 @@ export interface ObjectStackHonoOptions {
prefix?: string;
/** CORS configuration. Set to `false` to disable entirely. */
cors?: ObjectStackHonoCorsOptions | false;
/**
* The request-body limit, in bytes: `@objectstack/plugin-hono-server`'s
* rule (`src/body-limit.ts`), on this door too. A body over it is not read
* past the limit and is answered `413 PAYLOAD_TOO_LARGE`; a declared length
* over it is not read at all, by this adapter or by a domain or auth service
* the request is forwarded to.
*
* Unset, the limit is `OS_HTTP_MAX_BODY_BYTES`, else 10 MiB. A value that is
* not a positive whole number of bytes makes `createHonoApp` throw.
* @default 10485760
*/
maxRequestBodyBytes?: number;
/**
* @deprecated RETIRED (ADR-0006 Phase 5) — ignored. Multi-tenant routing is
* owned by the host's `KernelResolver` (registered as the
Expand Down Expand Up @@ -403,6 +420,15 @@ export function objectStackMiddleware(kernel: ObjectKernel) {
* ahead of a route handler on `plugin-hono-server`. No list of routes is kept
* here: whether a domain reads the body is answered by the domain reading it.
*
* ## A body over the request-body limit
*
* `createHonoApp` installs `@objectstack/plugin-hono-server`'s limit
* (`src/body-limit.ts`) ahead of every route, so this reading, the auth
* service and every domain the request is forwarded to read through it. A
* declared length over the limit is not read here: the body handed on refuses
* with `413 PAYLOAD_TOO_LARGE` when read. A body with no declared length is
* read up to the limit, and a read the guard stopped is refused as too large.
*
* `@objectstack/plugin-hono-server` answers the same body with the same
* `status`, `code` and `message` (`hono-body-refusal.test.ts` drives both
* doors over one table).
Expand All @@ -411,12 +437,20 @@ async function readDispatchBody(c: Context): Promise<DispatchBody> {
const lowered = (c.req.header('content-type') ?? '').toLowerCase();
const raw = c.req.raw;

// The request-body limit (`@objectstack/plugin-hono-server`'s
// `src/body-limit.ts`): a declared length over it is never read, and a read
// the guard stopped past it is refused as too large, not as unreadable.
const guard = requestBodyGuardOf(c);
if (guard?.declaredOverLimit) return refusedOnRead(new RequestBodyTooLarge(guard.limit));
const tooLargeOr = (refusal: RequestBodyRefusal): DispatchBody =>
refusedOnRead(guard?.tripped() ? new RequestBodyTooLarge(guard.limit) : refusal);

if (lowered.includes('application/json')) {
let text: string;
try {
text = raw.bodyUsed ? await c.req.text() : await raw.clone().text();
} catch {
return refusedOnRead(new RequestBodyRefusal(UNREADABLE_BODY_MESSAGE));
return tooLargeOr(new RequestBodyRefusal(UNREADABLE_BODY_MESSAGE));
}
if (text.trim() === '') return readable({});
try {
Expand All @@ -431,7 +465,7 @@ async function readDispatchBody(c: Context): Promise<DispatchBody> {
try {
return readable(raw.bodyUsed ? await c.req.parseBody() : await parseBody(raw.clone()));
} catch {
return refusedOnRead(new RequestBodyRefusal(malformedFormMessage(mediaType)));
return tooLargeOr(new RequestBodyRefusal(malformedFormMessage(mediaType)));
}
}

Expand All @@ -446,7 +480,7 @@ interface DispatchBody {
/** The `body` argument for `dispatch()` / `handleAuth()`. */
readonly value: any;
/** The refusal, once something READ a body this adapter could not parse; `undefined` otherwise. */
readonly refusalRead: () => RequestBodyRefusal | undefined;
readonly refusalRead: () => RequestBodyRefusal | RequestBodyTooLarge | undefined;
}

const readable = (value: any): DispatchBody => ({ value, refusalRead: () => undefined });
Expand All @@ -458,7 +492,7 @@ const readable = (value: any): DispatchBody => ({ value, refusalRead: () => unde
* is not a read: `typeof` is `'object'`, `Array.isArray` is `false`, and
* passing it along, comparing it or testing it with `??` touch nothing.
*/
function refusedOnRead(refusal: RequestBodyRefusal): DispatchBody {
function refusedOnRead(refusal: RequestBodyRefusal | RequestBodyTooLarge): DispatchBody {
let read = false;
const refuse = (): never => {
read = true;
Expand Down Expand Up @@ -628,6 +662,12 @@ function dispatchedMethod(method: string): string {
*/
export function createHonoApp(options: ObjectStackHonoOptions): Hono {
const app = new Hono();
// The request-body limit, first: every reader of a request body below (this
// adapter's own `readDispatchBody`, the auth service and every domain the
// request is forwarded to) reads through it. Resolved now, so a bad value
// refuses the app instead of serving it with no bound.
const maxRequestBodyBytes = resolveMaxRequestBodyBytes(options.maxRequestBodyBytes);
installRequestBodyLimit(app, () => maxRequestBodyBytes);
const prefix = options.prefix || '/api';
// [#16025] Where `/auth/*` is mounted, and the boot refusal that guards it.
// Computed BEFORE any route is registered so a misaligned composition never
Expand Down Expand Up @@ -769,6 +809,10 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono {
* unchanged; only the message is replaced.
*/
const thrownJson = (c: any, err: any) => {
// A body over the request-body limit: `413` is a status
// `declaredEnvelopeForThrow` does not render, so it is answered here.
const tooLarge = requestBodyTooLargeAnswer(err);
if (tooLarge) return c.json(tooLarge.body, tooLarge.status);
const declared = declaredEnvelopeForThrow(err);
if (declared) return c.json(declared.body, declared.status);
const status = err.statusCode || 500;
Expand Down
Loading
Loading