Skip to content
Draft
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
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
86 changes: 86 additions & 0 deletions src/utils/body-limit.ts
Original file line number Diff line number Diff line change
@@ -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<string | RegExp>;
/**
* Routes to exclude from the limit (optional)
*/
exclude?: Array<string | RegExp>;
}

/**
* 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(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be a middleware not a plugin to apply in places needed.

With middleware, pattern and route matching can be defined also on definition therefore we do not need to (re)implement logic inside this utility.

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");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not a safe implementation as @OskarLebuda pointed out. content-length can be faked.

We need to both check header and transform body stream into another controller that force-stops reading body as soon as max length is reached.

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`,
});
}
}
}),
);
};
}
162 changes: 162 additions & 0 deletions test/body-limit.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
1 change: 1 addition & 0 deletions test/unit/package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ describe("h3 package", () => {
"createEventStream",
"createRouter",
"defaultContentType",
"defineBodySizeLimitPlugin",
"defineEventHandler",
"defineHandler",
"defineLazyEventHandler",
Expand Down