Skip to content

Commit 7d8a2ff

Browse files
authored
feat(v0.2): maxBodySize cap on direct upload endpoint (#200)
Rejects requests with Content-Length exceeding maxBodySize with 413 before authorize/validators run, so oversized bodies are not buffered into memory. Closes #195.
1 parent 2d28eb0 commit 7d8a2ff

3 files changed

Lines changed: 49 additions & 4 deletions

File tree

src/runtime/server/handlers/direct.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { defineEventHandler, readMultipartFormData, createError } from "h3"
1+
import { defineEventHandler, readMultipartFormData, createError, getRequestHeader } from "h3"
22
// @ts-expect-error virtual user-config import
33
import userConfig from "#upload-kit-user-config"
44
import type { UploadServerConfig, UploadFileDescriptor, ServerHookContext } from "../types"
@@ -29,6 +29,17 @@ export default defineEventHandler(async (event) => {
2929
})
3030
}
3131

32+
if (config.maxBodySize != null) {
33+
const contentLength = Number(getRequestHeader(event, "content-length"))
34+
if (Number.isFinite(contentLength) && contentLength > config.maxBodySize) {
35+
throw createError({
36+
statusCode: 413,
37+
statusMessage: "Payload Too Large",
38+
message: `Request body exceeds maxBodySize (${config.maxBodySize} bytes).`,
39+
})
40+
}
41+
}
42+
3243
const parts = await readMultipartFormData(event)
3344
const filePart = parts?.find((p) => p.name === "file" && p.filename)
3445
if (!filePart || !filePart.filename || !filePart.data) {

src/runtime/server/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ export interface UploadServerConfig {
7272
storage?: StorageAdapter
7373
authorize?: (event: H3Event, op: AuthorizeOp) => AuthorizeContext | Promise<AuthorizeContext>
7474
validators?: ServerValidator[]
75+
/**
76+
* Maximum request body size (in bytes) accepted by the `/direct` upload endpoint.
77+
* Enforced against the `Content-Length` header before the body is read into memory,
78+
* so authorize/validators never see oversized requests. Rely on an upstream
79+
* proxy/CDN request-size cap for chunked transfer encoding, which has no Content-Length.
80+
*/
81+
maxBodySize?: number
7582
hooks?: {
7683
beforePresign?: (file: UploadFileDescriptor, ctx: ServerHookContext) => void | Promise<void>
7784
afterUpload?: (file: UploadFileDescriptor, ctx: ServerHookContext) => void | Promise<void>

test/unit/server/direct-handler.test.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,21 @@ const callHandler = async () => {
2525
return mod.default
2626
}
2727

28-
const fakeEvent = () =>
28+
const fakeEvent = (headers: Record<string, string> = {}) =>
2929
({
30-
node: { req: { method: "POST", headers: { "content-type": "multipart/form-data" } } },
30+
node: { req: { method: "POST", headers: { "content-type": "multipart/form-data", ...headers } } },
3131
context: {},
3232
}) as unknown as Parameters<Awaited<ReturnType<typeof callHandler>>>[0]
3333

3434
const mockMultipart = (parts: Array<{ name: string; filename?: string; type?: string; data: Buffer }>) => {
3535
vi.doMock("h3", async (importOriginal) => {
3636
const actual = await importOriginal<typeof import("h3")>()
37-
return { ...actual, readMultipartFormData: async () => parts }
37+
return {
38+
...actual,
39+
readMultipartFormData: async () => parts,
40+
getRequestHeader: (event: { node: { req: { headers: Record<string, string> } } }, name: string) =>
41+
event.node.req.headers[name.toLowerCase()],
42+
}
3843
})
3944
}
4045

@@ -139,6 +144,28 @@ describe("direct handler", () => {
139144
await expect(handler(fakeEvent())).rejects.toMatchObject({ statusCode: 500 })
140145
})
141146

147+
it("rejects with 413 when Content-Length exceeds maxBodySize, before authorize/read", async () => {
148+
const storage = stubStorage()
149+
const authorize = vi.fn(async () => ({}))
150+
userConfig = { storage, authorize, maxBodySize: 100 }
151+
152+
mockMultipart([{ name: "file", filename: "big.bin", type: "application/octet-stream", data: Buffer.from("x") }])
153+
const handler = await callHandler()
154+
await expect(handler(fakeEvent({ "content-length": "500" }))).rejects.toMatchObject({ statusCode: 413 })
155+
expect(authorize).not.toHaveBeenCalled()
156+
expect(storage.put).not.toHaveBeenCalled()
157+
})
158+
159+
it("passes when Content-Length is within maxBodySize", async () => {
160+
const storage = stubStorage()
161+
userConfig = { storage, maxBodySize: 1000 }
162+
163+
mockMultipart([{ name: "file", filename: "small.png", type: "image/png", data: Buffer.from("pix") }])
164+
const handler = await callHandler()
165+
const result = await handler(fakeEvent({ "content-length": "50" }))
166+
expect(result.fileId).toMatch(/^uploads\//)
167+
})
168+
142169
it("falls back to uploads/{fileId} when adapter has no resolveKey", async () => {
143170
const storage: StorageAdapter & { put: ReturnType<typeof vi.fn> } = {
144171
id: "stub",

0 commit comments

Comments
 (0)