diff --git a/docs/2.utils/1.request.md b/docs/2.utils/1.request.md index f142cb1e7..5ef53ca5e 100644 --- a/docs/2.utils/1.request.md +++ b/docs/2.utils/1.request.md @@ -10,6 +10,21 @@ icon: material-symbols-light:input +### `assertBodySize(event, limit)` + +Asserts that request body size is within the specified limit. + +If body size exceeds the limit, throws a `413` Request Entity Too Large response error. + +**Example:** + +```ts +app.get("/", async (event) => { + await assertBodySize(event, 10 * 1024 * 1024); // 10MB + const data = await event.req.formData(); +}); +``` + ### `readBody(event)` Reads request body and tries to parse using JSON.parse or URLSearchParams. @@ -31,10 +46,11 @@ You can use a simple function to validate the body or use a Standard-Schema comp **Example:** ```ts +function validateBody(body: any) { + return typeof body === "object" && body !== null; +} app.get("/", async (event) => { - const body = await readValidatedBody(event, (body) => { - return typeof body === "object" && body !== null; - }); + const body = await readValidatedBody(event, validateBody); }); ``` @@ -42,11 +58,11 @@ app.get("/", async (event) => { ```ts import { z } from "zod"; +const objectSchema = z.object({ + name: z.string().min(3).max(20), + age: z.number({ coerce: true }).positive().int(), +}); app.get("/", async (event) => { - const objectSchema = z.object({ - name: z.string().min(3).max(20), - age: z.number({ coerce: true }).positive().int(), - }); const body = await readValidatedBody(event, objectSchema); }); ``` diff --git a/docs/2.utils/9.more.md b/docs/2.utils/9.more.md index f97770cd3..925922ca6 100644 --- a/docs/2.utils/9.more.md +++ b/docs/2.utils/9.more.md @@ -49,6 +49,12 @@ Checks if the input is an object with `{ req: Request }` signature. +### `bodyLimit(limit)` + +Define a middleware that checks whether request body size is within specified limit. + +If body size exceeds the limit, throws a `413` Request Entity Too Large response error. If you need custom handling for this case, use `assertBodySize` instead. + ### `onError(hook)` Define a middleware that runs when an error occurs. diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 001631acb..03f65f577 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,6 @@ packages: - - "playground" - - "examples" + - playground + - examples + +ignoredBuiltDependencies: + - esbuild diff --git a/src/index.ts b/src/index.ts index 23b686706..6967f7895 100644 --- a/src/index.ts +++ b/src/index.ts @@ -115,7 +115,12 @@ export { // Middleware -export { onError, onRequest, onResponse } from "./utils/middleware.ts"; +export { + onError, + onRequest, + onResponse, + bodyLimit, +} from "./utils/middleware.ts"; // Proxy @@ -129,7 +134,7 @@ export { // Body -export { readBody, readValidatedBody } from "./utils/body.ts"; +export { readBody, readValidatedBody, assertBodySize } from "./utils/body.ts"; // Cookie diff --git a/src/utils/body.ts b/src/utils/body.ts index d73b06c4f..0c552a2db 100644 --- a/src/utils/body.ts +++ b/src/utils/body.ts @@ -69,19 +69,22 @@ export async function readValidatedBody< * You can use a simple function to validate the body or use a Standard-Schema compatible library like `zod` to define a schema. * * @example + * function validateBody(body: any) { + * return typeof body === "object" && body !== null; + * } + * * app.get("/", async (event) => { - * const body = await readValidatedBody(event, (body) => { - * return typeof body === "object" && body !== null; - * }); + * const body = await readValidatedBody(event, validateBody); * }); * @example * import { z } from "zod"; * + * const objectSchema = z.object({ + * name: z.string().min(3).max(20), + * age: z.number({ coerce: true }).positive().int(), + * }); + * * app.get("/", async (event) => { - * const objectSchema = z.object({ - * name: z.string().min(3).max(20), - * age: z.number({ coerce: true }).positive().int(), - * }); * const body = await readValidatedBody(event, objectSchema); * }); * @@ -98,3 +101,60 @@ export async function readValidatedBody( const _body = await readBody(event); return validateData(_body, validate); } + +/** + * Asserts that request body size is within the specified limit. + * + * If body size exceeds the limit, throws a `413` Request Entity Too Large response error. + * + * @example + * app.get("/", async (event) => { + * await assertBodySize(event, 10 * 1024 * 1024); // 10MB + * const data = await event.req.formData(); + * }); + * + * @param event HTTP event + * @param limit Body size limit in bytes + */ +export async function assertBodySize( + event: HTTPEvent, + limit: number, +): Promise { + const isWithin = await isBodySizeWithin(event, limit); + if (!isWithin) { + throw new HTTPError({ + status: 413, + statusText: "Request Entity Too Large", + message: `Request body size exceeds the limit of ${limit} bytes`, + }); + } +} + +// Internal util for now. We can export later if needed +async function isBodySizeWithin( + event: HTTPEvent, + limit: number, +): Promise { + const req = event.req; + if (req.body === null) { + return true; + } + + const bodyLen = req.headers.get("content-length"); + if (bodyLen !== null && !req.headers.has("transfer-encoding")) { + return +bodyLen <= limit; + } + + const reader = req.clone().body!.getReader(); + let chunk = await reader.read(); + let size = 0; + while (!chunk.done) { + size += chunk.value.byteLength; + if (size > limit) { + return false; + } + chunk = await reader.read(); + } + + return true; +} diff --git a/src/utils/middleware.ts b/src/utils/middleware.ts index 6373dc570..010e51ed1 100644 --- a/src/utils/middleware.ts +++ b/src/utils/middleware.ts @@ -4,6 +4,7 @@ import type { MaybePromise } from "../types/_utils.ts"; import type { H3Event } from "../event.ts"; import type { Middleware } from "../types/handler.ts"; +import { assertBodySize } from "./body.ts"; /** * Define a middleware that runs on each request. @@ -63,3 +64,19 @@ export function onError( } }; } + +/** + * Define a middleware that checks whether request body size is within specified limit. + * + * If body size exceeds the limit, throws a `413` Request Entity Too Large response error. + * If you need custom handling for this case, use `assertBodySize` instead. + * + * @param limit Body size limit in bytes + * @see {assertBodySize} + */ +export function bodyLimit(limit: number): Middleware { + return async (event, next) => { + await assertBodySize(event, limit); + return next(); + }; +} diff --git a/test/unit/body-limit.test.ts b/test/unit/body-limit.test.ts new file mode 100644 index 000000000..cf6dd141e --- /dev/null +++ b/test/unit/body-limit.test.ts @@ -0,0 +1,80 @@ +import { expect, it, describe } from "vitest"; +import { mockEvent, assertBodySize, HTTPError } from "../../src/index.ts"; + +describe("body limit (unit)", () => { + const streamBytesFrom = (it: Iterable) => + new ReadableStream({ + start(c) { + for (const part of it) c.enqueue(part); + c.close(); + }, + }).pipeThrough(new TextEncoderStream()); + + describe("assertBodySize", () => { + it("buffered body", async () => { + const BODY = "a small request body"; + + const eventMock = mockEvent("/", { + method: "POST", + body: BODY, + }); + + await expect( + assertBodySize(eventMock, BODY.length), + ).resolves.toBeUndefined(); + await expect( + assertBodySize(eventMock, BODY.length + 10), + ).resolves.toBeUndefined(); + await expect(assertBodySize(eventMock, BODY.length - 2)).rejects.toThrow( + HTTPError, + ); + }); + + it("streaming body", async () => { + const BODY_PARTS = [ + "parts", + "of", + "the", + "body", + "that", + "are", + "streamed", + "in", + ]; + + const eventMock = mockEvent("/", { + method: "POST", + body: streamBytesFrom(BODY_PARTS), + }); + + await expect(assertBodySize(eventMock, 100)).resolves.toBeUndefined(); + await expect(assertBodySize(eventMock, 10)).rejects.toThrow(HTTPError); + }); + + it("streaming body with content-length header", async () => { + const BODY_PARTS = [ + "parts", + "of", + "the", + "body", + "that", + "are", + "streamed", + "in", + ]; + + const eventMock = mockEvent("/", { + method: "POST", + body: streamBytesFrom(BODY_PARTS), + headers: { + // Should ignore content-length + "content-length": "7", + "transfer-encoding": "chunked", + }, + }); + + await expect(assertBodySize(eventMock, 100)).resolves.toBeUndefined(); + await expect(assertBodySize(eventMock, 10)).rejects.toThrow(HTTPError); + }); + }); +}); diff --git a/test/unit/package.test.ts b/test/unit/package.test.ts index 4ce43b41b..c75dda652 100644 --- a/test/unit/package.test.ts +++ b/test/unit/package.test.ts @@ -19,8 +19,10 @@ describe("h3 package", () => { "appendHeaders", "appendResponseHeader", "appendResponseHeaders", + "assertBodySize", "assertMethod", "basicAuth", + "bodyLimit", "callMiddleware", "clearResponseHeaders", "clearSession",