From 42f262ae191ca29ea39757beb253de154595e9e1 Mon Sep 17 00:00:00 2001 From: productdevbook Date: Mon, 7 Jul 2025 12:16:23 +0300 Subject: [PATCH 1/2] feat: add defineBodySizeLimitPlugin for request body size limiting - Create a plugin-based approach for body size limiting - Support route-specific limits with include/exclude patterns - Add comprehensive tests for the plugin - Addresses #859 --- src/index.ts | 2 + src/utils/body-limit.ts | 86 +++++++++++++++++++++ test/body-limit.test.ts | 162 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 250 insertions(+) create mode 100644 src/utils/body-limit.ts create mode 100644 test/body-limit.test.ts diff --git a/src/index.ts b/src/index.ts index 72e78fc3f..707ca62c1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -111,6 +111,8 @@ export { // Body export { readBody, readValidatedBody } from "./utils/body.ts"; +export { defineBodySizeLimitPlugin } from "./utils/body-limit.ts"; +export type { BodySizeLimitOptions } from "./utils/body-limit.ts"; // Cookie export { diff --git a/src/utils/body-limit.ts b/src/utils/body-limit.ts new file mode 100644 index 000000000..d021f4096 --- /dev/null +++ b/src/utils/body-limit.ts @@ -0,0 +1,86 @@ +import type { H3Event } from "../event.ts"; +import type { H3Plugin } from "../types/h3.ts"; +import { HTTPError } from "../error.ts"; +import { defineMiddleware } from "../middleware.ts"; + +export interface BodySizeLimitOptions { + /** + * Maximum allowed body size in bytes + */ + maxSize: number; + /** + * Routes to apply the limit to (optional) + * If not specified, applies to all routes + */ + routes?: Array; + /** + * Routes to exclude from the limit (optional) + */ + exclude?: Array; +} + +/** + * Define a plugin that limits request body size + * + * @example + * ```js + * import { defineBodySizeLimitPlugin } from "h3"; + * + * const bodySizeLimit = defineBodySizeLimitPlugin({ + * maxSize: 1024 * 1024, // 1MB + * routes: ["/api/upload", /^\/api\/files/], + * exclude: ["/api/large-upload"] + * }); + * + * app.register(bodySizeLimit); + * ``` + */ +export function defineBodySizeLimitPlugin( + options: BodySizeLimitOptions, +): H3Plugin { + return (h3) => { + h3.use( + defineMiddleware(async (event: H3Event) => { + const url = event.req.url; + const path = url ? new URL(url, "http://localhost").pathname : "/"; + + // Check if route should be excluded + if (options.exclude) { + for (const pattern of options.exclude) { + if (typeof pattern === "string" && path === pattern) return; + if (pattern instanceof RegExp && pattern.test(path)) return; + } + } + + // Check if route matches (if routes specified) + if (options.routes) { + let matches = false; + for (const pattern of options.routes) { + if (typeof pattern === "string" && path === pattern) { + matches = true; + break; + } + if (pattern instanceof RegExp && pattern.test(path)) { + matches = true; + break; + } + } + if (!matches) return; + } + + // Check Content-Length header first + const contentLength = event.req.headers.get("content-length"); + if (contentLength) { + const size = Number.parseInt(contentLength, 10); + if (!Number.isNaN(size) && size > options.maxSize) { + throw new HTTPError({ + status: 413, + statusText: "Payload Too Large", + message: `Request body size ${size} exceeds the limit of ${options.maxSize} bytes`, + }); + } + } + }), + ); + }; +} diff --git a/test/body-limit.test.ts b/test/body-limit.test.ts new file mode 100644 index 000000000..d0141e757 --- /dev/null +++ b/test/body-limit.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect } from "vitest"; +import { defineBodySizeLimitPlugin, defineRoute } from "../src/index.ts"; +import { H3 } from "../src/h3.ts"; + +describe("defineBodySizeLimitPlugin", () => { + it("should limit body size for all routes", async () => { + const app = new H3(); + + // Register body size limit plugin + const bodySizeLimit = defineBodySizeLimitPlugin({ + maxSize: 1024, // 1KB + }); + app.register(bodySizeLimit); + + // Register a route + app.on("POST", "/upload", () => "ok"); + + // Test with small body (should succeed) + const smallBody = "a".repeat(500); + const smallRes = await app.fetch("/upload", { + method: "POST", + body: smallBody, + headers: { + "Content-Type": "text/plain", + "Content-Length": String(smallBody.length), + }, + }); + expect(await smallRes.text()).toBe("ok"); + + // Test with large body (should fail) + const largeBody = "a".repeat(2000); + const largeRes = await app.fetch("/upload", { + method: "POST", + body: largeBody, + headers: { + "Content-Type": "text/plain", + "Content-Length": String(largeBody.length), + }, + }); + expect(largeRes.status).toBe(413); + const error = await largeRes.json(); + expect(error.statusText).toBe("Payload Too Large"); + }); + + it("should limit body size only for specified routes", async () => { + const app = new H3(); + + // Register body size limit plugin for specific routes + const bodySizeLimit = defineBodySizeLimitPlugin({ + maxSize: 1024, // 1KB + routes: ["/api/upload", /^\/api\/files/], + }); + app.register(bodySizeLimit); + + // Register routes + app.on("POST", "/api/upload", () => "upload ok"); + app.on("POST", "/api/files/test", () => "files ok"); + app.on("POST", "/other", () => "other ok"); + + const largeBody = "a".repeat(2000); + + // Should fail for /api/upload + const uploadRes = await app.fetch("/api/upload", { + method: "POST", + body: largeBody, + headers: { + "Content-Type": "text/plain", + "Content-Length": String(largeBody.length), + }, + }); + expect(uploadRes.status).toBe(413); + + // Should fail for /api/files/test + const filesRes = await app.fetch("/api/files/test", { + method: "POST", + body: largeBody, + headers: { + "Content-Type": "text/plain", + "Content-Length": String(largeBody.length), + }, + }); + expect(filesRes.status).toBe(413); + + // Should succeed for /other + const otherRes = await app.fetch("/other", { + method: "POST", + body: largeBody, + headers: { + "Content-Type": "text/plain", + }, + }); + expect(await otherRes.text()).toBe("other ok"); + }); + + it("should exclude specified routes from limit", async () => { + const app = new H3(); + + // Register body size limit plugin with exclusions + const bodySizeLimit = defineBodySizeLimitPlugin({ + maxSize: 1024, // 1KB + exclude: ["/api/large-upload"], + }); + app.register(bodySizeLimit); + + // Register routes + app.on("POST", "/api/upload", () => "upload ok"); + app.on("POST", "/api/large-upload", () => "large upload ok"); + + const largeBody = "a".repeat(2000); + + // Should fail for /api/upload + const uploadRes = await app.fetch("/api/upload", { + method: "POST", + body: largeBody, + headers: { + "Content-Type": "text/plain", + "Content-Length": String(largeBody.length), + }, + }); + expect(uploadRes.status).toBe(413); + + // Should succeed for /api/large-upload (excluded) + const largeUploadRes = await app.fetch("/api/large-upload", { + method: "POST", + body: largeBody, + headers: { + "Content-Type": "text/plain", + }, + }); + expect(await largeUploadRes.text()).toBe("large upload ok"); + }); + + it("should work with defineRoute", async () => { + const app = new H3(); + + // Register body size limit plugin + const bodySizeLimit = defineBodySizeLimitPlugin({ + maxSize: 1024, // 1KB + }); + app.register(bodySizeLimit); + + // Register route using defineRoute + const uploadRoute = defineRoute({ + method: "POST", + route: "/upload", + handler: () => "ok", + }); + app.register(uploadRoute); + + // Test with large body (should fail) + const largeBody = "a".repeat(2000); + const largeRes = await app.fetch("/upload", { + method: "POST", + body: largeBody, + headers: { + "Content-Type": "text/plain", + "Content-Length": String(largeBody.length), + }, + }); + expect(largeRes.status).toBe(413); + }); +}); From 30c23e9980511d8611bcc15c992034f6ba6b31dd Mon Sep 17 00:00:00 2001 From: productdevbook Date: Mon, 7 Jul 2025 12:21:01 +0300 Subject: [PATCH 2/2] test: update package exports snapshot --- test/unit/package.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/unit/package.test.ts b/test/unit/package.test.ts index 51c304556..8dd1a5e7a 100644 --- a/test/unit/package.test.ts +++ b/test/unit/package.test.ts @@ -27,6 +27,7 @@ describe("h3 package", () => { "createEventStream", "createRouter", "defaultContentType", + "defineBodySizeLimitPlugin", "defineEventHandler", "defineHandler", "defineLazyEventHandler",