From 2a5fd3c9996e17fba7448076fb22815d9cad6165 Mon Sep 17 00:00:00 2001 From: huseeiin Date: Fri, 18 Jul 2025 12:36:05 +0300 Subject: [PATCH 1/6] check for multipart/form-data --- src/utils/body.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/utils/body.ts b/src/utils/body.ts index 0bdd9fdb9..929695781 100644 --- a/src/utils/body.ts +++ b/src/utils/body.ts @@ -26,11 +26,15 @@ export async function readBody< _T = InferEventInput<"body", _Event, T>, >(event: _Event): Promise { const text = await event.req.text(); + const contentType = event.req.headers.get("content-type") || ""; + + if (contentType.startsWith("multipart/form-data")) + return Object.fromEntries(await event.req.formData()) as _T; + if (!text) { return undefined; } - const contentType = event.req.headers.get("content-type") || ""; if (contentType.startsWith("application/x-www-form-urlencoded")) { return parseURLEncodedBody(text) as _T; } From d01ef6d4e3fd264f30dcb2b8b2af8b1d93f81af8 Mon Sep 17 00:00:00 2001 From: huseeiin Date: Tue, 22 Jul 2025 22:03:57 +0300 Subject: [PATCH 2/6] fix --- src/utils/body.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/utils/body.ts b/src/utils/body.ts index 929695781..142075f6b 100644 --- a/src/utils/body.ts +++ b/src/utils/body.ts @@ -25,12 +25,13 @@ export async function readBody< _Event extends HTTPEvent = HTTPEvent, _T = InferEventInput<"body", _Event, T>, >(event: _Event): Promise { - const text = await event.req.text(); const contentType = event.req.headers.get("content-type") || ""; if (contentType.startsWith("multipart/form-data")) return Object.fromEntries(await event.req.formData()) as _T; + const text = await event.req.text(); + if (!text) { return undefined; } From 0f31cad3193a8450753fbdd30bae84eacced938e Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Sun, 5 Jul 2026 20:16:42 +0000 Subject: [PATCH 3/6] refactor(readBody): make non-JSON body parsing opt-in via `type` Multipart form-data was previously parsed automatically based on the `Content-Type` header. Per #875 this is now strictly opt-in via `readBody(event, { type: "formData" })` so an untrusted request can't drive readBody into (potentially expensive) multipart parsing. Also: - wrap `formData()` in try/catch -> HTTPError 400, matching the JSON path - preserve repeated form fields as arrays instead of dropping earlier values (shared with url-encoded parsing) - add readBody-based multipart tests (opt-in, no auto-parse, malformed 400) Co-Authored-By: Claude Opus 4.8 --- docs/2.utils/1.request.md | 14 ++++++++-- src/utils/body.ts | 53 ++++++++++++++++++++++++++++++++------ src/utils/internal/body.ts | 29 ++++++++++++++------- test/body.test.ts | 50 +++++++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 20 deletions(-) diff --git a/docs/2.utils/1.request.md b/docs/2.utils/1.request.md index 2898df7f6..c9645a2ef 100644 --- a/docs/2.utils/1.request.md +++ b/docs/2.utils/1.request.md @@ -25,18 +25,28 @@ app.get("/", async (event) => { }); ``` -### `readBody(event)` +### `readBody(event, options?: { type?: "json")` Reads request body and tries to parse using JSON.parse or URLSearchParams. +By default the body is parsed as JSON (falling back to URL-encoded parsing when the `Content-Type` is `application/x-www-form-urlencoded`). Other body types, such as `multipart/form-data`, must be opted into explicitly via `options.type` and are never auto-detected from the request headers. + **Example:** ```ts -app.get("/", async (event) => { +app.post("/", async (event) => { const body = await readBody(event); }); ``` +**Example:** + +```ts +app.post("/upload", async (event) => { + const body = await readBody(event, { type: "formData" }); +}); +``` + ### `readValidatedBody(event, validate)` Tries to read the request body via `readBody`, then uses the provided validation schema or function and either throws a validation error or returns the result. diff --git a/src/utils/body.ts b/src/utils/body.ts index 142075f6b..02365205c 100644 --- a/src/utils/body.ts +++ b/src/utils/body.ts @@ -1,6 +1,6 @@ import { type ErrorDetails, HTTPError } from "../error.ts"; import { type OnValidateError, validateData } from "./internal/validate.ts"; -import { parseURLEncodedBody } from "./internal/body.ts"; +import { parseURLEncodedBody, parseFormData } from "./internal/body.ts"; import type { HTTPEvent } from "../event.ts"; import type { InferEventInput } from "../types/handler.ts"; @@ -10,25 +10,54 @@ import type { StandardSchemaV1, FailureResult, InferOutput } from "./internal/st /** * Reads request body and tries to parse using JSON.parse or URLSearchParams. * + * By default the body is parsed as JSON (falling back to URL-encoded parsing + * when the `Content-Type` is `application/x-www-form-urlencoded`). Other body + * types, such as `multipart/form-data`, must be opted into explicitly via + * `options.type` and are never auto-detected from the request headers. + * * @example - * app.get("/", async (event) => { + * app.post("/", async (event) => { * const body = await readBody(event); * }); + * @example + * app.post("/upload", async (event) => { + * const body = await readBody(event, { type: "formData" }); + * }); * * @param event H3 event passed by h3 handler - * @param encoding The character encoding to use, defaults to 'utf-8'. + * @param options Parsing options. Set `type` to force a parser instead of + * inferring it from the request `Content-Type`. * - * @return {*} The `Object`, `Array`, `String`, `Number`, `Boolean`, or `null` value corresponding to the request JSON body + * @return {*} The `Object`, `Array`, `String`, `Number`, `Boolean`, or `null` value corresponding to the request body */ export async function readBody< T, _Event extends HTTPEvent = HTTPEvent, _T = InferEventInput<"body", _Event, T>, ->(event: _Event): Promise { +>( + event: _Event, + options?: { type?: "json" | "text" | "urlencoded" | "formData" }, +): Promise { const contentType = event.req.headers.get("content-type") || ""; + const type = options?.type; - if (contentType.startsWith("multipart/form-data")) - return Object.fromEntries(await event.req.formData()) as _T; + // `formData` (multipart or url-encoded) is strictly opt-in: unlike JSON it + // is never auto-detected from the `Content-Type` header, so an untrusted + // request cannot push readBody into (potentially expensive) multipart + // parsing without the handler explicitly asking for it. See #875. + if (type === "formData") { + let form: FormData; + try { + form = await event.req.formData(); + } catch { + throw new HTTPError({ + status: 400, + statusText: "Bad Request", + message: "Invalid form data body", + }); + } + return parseFormData(form) as _T; + } const text = await event.req.text(); @@ -36,10 +65,18 @@ export async function readBody< return undefined; } - if (contentType.startsWith("application/x-www-form-urlencoded")) { + if (type === "text") { + return text as _T; + } + + if ( + type === "urlencoded" || + (!type && contentType.startsWith("application/x-www-form-urlencoded")) + ) { return parseURLEncodedBody(text) as _T; } + // Default, and explicit `type: "json"`. try { return JSON.parse(text) as _T; } catch { diff --git a/src/utils/internal/body.ts b/src/utils/internal/body.ts index 0331bd25f..718f4c6ed 100644 --- a/src/utils/internal/body.ts +++ b/src/utils/internal/body.ts @@ -1,18 +1,27 @@ import { EmptyObject } from "./obj.ts"; import { hasProp } from "./object.ts"; -export function parseURLEncodedBody(body: string) { - const form = new URLSearchParams(body); - const parsedForm: Record = new EmptyObject(); - for (const [key, value] of form.entries()) { - if (hasProp(parsedForm, key)) { - if (!Array.isArray(parsedForm[key])) { - parsedForm[key] = [parsedForm[key]]; +export function parseURLEncodedBody(body: string): unknown { + return collectEntries(new URLSearchParams(body).entries()); +} + +export function parseFormData(form: FormData): unknown { + return collectEntries(form.entries()); +} + +// Collect key/value entries into an object, keeping repeated keys as arrays +// (e.g. multi-selects or `foo=1&foo=2`) instead of dropping earlier values. +function collectEntries(entries: IterableIterator<[string, unknown]>): unknown { + const parsed: Record = new EmptyObject(); + for (const [key, value] of entries) { + if (hasProp(parsed, key)) { + if (!Array.isArray(parsed[key])) { + parsed[key] = [parsed[key]]; } - parsedForm[key].push(value); + parsed[key].push(value); } else { - parsedForm[key] = value; + parsed[key] = value; } } - return parsedForm as unknown; + return parsed as unknown; } diff --git a/test/body.test.ts b/test/body.test.ts index 9baf101dd..5b0934572 100644 --- a/test/body.test.ts +++ b/test/body.test.ts @@ -219,6 +219,56 @@ describeMatrix("body", (t, { it, expect, describe }) => { ]); }); + describe("readBody with multipart/form-data", () => { + // Reusable multipart payload with a repeated `number` field. + const multipart = { + headers: { + "content-type": + "multipart/form-data; boundary=---------------------------12537827810750053901680552518", + }, + body: '-----------------------------12537827810750053901680552518\r\nContent-Disposition: form-data; name="field"\r\n\r\nvalue\r\n-----------------------------12537827810750053901680552518\r\nContent-Disposition: form-data; name="number"\r\n\r\n20\r\n-----------------------------12537827810750053901680552518\r\nContent-Disposition: form-data; name="number"\r\n\r\n30\r\n-----------------------------12537827810750053901680552518--\r\n', + }; + + it("parses form data into an object when opted in via type: formData", async () => { + let _body: any; + t.app.all("/api/test", async (event) => { + _body = await readBody(event, { type: "formData" }); + return "200"; + }); + const result = await t.fetch("/api/test", { method: "POST", ...multipart }); + // Repeated keys are preserved as an array, not collapsed to the last value. + expect(_body).toMatchObject({ field: "value", number: ["20", "30"] }); + expect(await result.text()).toBe("200"); + }); + + it("does NOT auto-parse multipart without an explicit opt-in", async () => { + // Without `type: "formData"` a multipart body falls through to the JSON + // parser and is rejected — parsing form data is never header-driven. + t.app.all("/api/test", async (event) => { + await readBody(event); + return "200"; + }); + const result = await t.fetch("/api/test", { method: "POST", ...multipart }); + expect(result.status).toBe(400); + }); + + it("throws a 400 on a malformed multipart body", async () => { + t.app.all("/api/test", async (event) => { + await readBody(event, { type: "formData" }); + return "200"; + }); + const result = await t.fetch("/api/test", { + method: "POST", + headers: { + "content-type": "multipart/form-data; boundary=----broken", + }, + // Body does not match the declared boundary → formData() throws. + body: "not a valid multipart body", + }); + expect(result.status).toBe(400); + }); + }); + it("returns empty string if body is not present with text/plain", async () => { let _body: string | undefined; t.app.all("/api/test", async (event) => { From 4bb0594317042c996673eeaa25ff33665b2de344 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Sun, 5 Jul 2026 21:37:16 +0000 Subject: [PATCH 4/6] docs: fix malformed readBody heading Co-Authored-By: Claude Opus 4.8 --- docs/2.utils/1.request.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/2.utils/1.request.md b/docs/2.utils/1.request.md index c9645a2ef..2dd7bfa80 100644 --- a/docs/2.utils/1.request.md +++ b/docs/2.utils/1.request.md @@ -25,7 +25,7 @@ app.get("/", async (event) => { }); ``` -### `readBody(event, options?: { type?: "json")` +### `readBody(event, options?)` Reads request body and tries to parse using JSON.parse or URLSearchParams. From 468e7ce141ffc7e97f5e668b837d175c6d622f03 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:37:46 +0000 Subject: [PATCH 5/6] chore: apply automated updates --- docs/2.utils/1.request.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/2.utils/1.request.md b/docs/2.utils/1.request.md index 2dd7bfa80..c9645a2ef 100644 --- a/docs/2.utils/1.request.md +++ b/docs/2.utils/1.request.md @@ -25,7 +25,7 @@ app.get("/", async (event) => { }); ``` -### `readBody(event, options?)` +### `readBody(event, options?: { type?: "json")` Reads request body and tries to parse using JSON.parse or URLSearchParams. From 0198322d20fbfd1adb2c6212f53d3a43483db2c5 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Sun, 5 Jul 2026 21:38:27 +0000 Subject: [PATCH 6/6] fix(readBody): empty-body text handling, named options type, explicit-type tests - `type: "text"` now returns an empty string for an empty body instead of `undefined`, matching the raw-string contract. - Extract a named `ReadBodyOptions` type (exported) so automd renders a clean `readBody(event, options?)` heading instead of mangling the inline string-literal union. - Add tests for the explicit `text`, `urlencoded`, and `json` modes. Co-Authored-By: Claude Opus 4.8 --- docs/2.utils/1.request.md | 2 +- src/index.ts | 2 +- src/utils/body.ts | 26 ++++++++++----- test/body.test.ts | 70 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 10 deletions(-) diff --git a/docs/2.utils/1.request.md b/docs/2.utils/1.request.md index c9645a2ef..2dd7bfa80 100644 --- a/docs/2.utils/1.request.md +++ b/docs/2.utils/1.request.md @@ -25,7 +25,7 @@ app.get("/", async (event) => { }); ``` -### `readBody(event, options?: { type?: "json")` +### `readBody(event, options?)` Reads request body and tries to parse using JSON.parse or URLSearchParams. diff --git a/src/index.ts b/src/index.ts index db8b62d28..ccbef8654 100644 --- a/src/index.ts +++ b/src/index.ts @@ -127,7 +127,7 @@ export { // Body -export { readBody, readValidatedBody, assertBodySize } from "./utils/body.ts"; +export { readBody, readValidatedBody, assertBodySize, type ReadBodyOptions } from "./utils/body.ts"; // Cookie diff --git a/src/utils/body.ts b/src/utils/body.ts index 02365205c..c00da3300 100644 --- a/src/utils/body.ts +++ b/src/utils/body.ts @@ -7,6 +7,18 @@ import type { InferEventInput } from "../types/handler.ts"; import type { ValidateResult } from "./internal/validate.ts"; import type { StandardSchemaV1, FailureResult, InferOutput } from "./internal/standard-schema.ts"; +export interface ReadBodyOptions { + /** + * Force a parser instead of inferring it from the request `Content-Type`. + * + * - `"json"` (default): parse as JSON. + * - `"text"`: return the raw string body. + * - `"urlencoded"`: parse as `application/x-www-form-urlencoded`. + * - `"formData"`: parse as `multipart/form-data` (or url-encoded) form data. + */ + type?: "json" | "text" | "urlencoded" | "formData"; +} + /** * Reads request body and tries to parse using JSON.parse or URLSearchParams. * @@ -34,10 +46,7 @@ export async function readBody< T, _Event extends HTTPEvent = HTTPEvent, _T = InferEventInput<"body", _Event, T>, ->( - event: _Event, - options?: { type?: "json" | "text" | "urlencoded" | "formData" }, -): Promise { +>(event: _Event, options?: ReadBodyOptions): Promise { const contentType = event.req.headers.get("content-type") || ""; const type = options?.type; @@ -61,14 +70,15 @@ export async function readBody< const text = await event.req.text(); - if (!text) { - return undefined; - } - + // Text is returned verbatim, including an empty body as `""`. if (type === "text") { return text as _T; } + if (!text) { + return undefined; + } + if ( type === "urlencoded" || (!type && contentType.startsWith("application/x-www-form-urlencoded")) diff --git a/test/body.test.ts b/test/body.test.ts index 5b0934572..bf2633b3c 100644 --- a/test/body.test.ts +++ b/test/body.test.ts @@ -269,6 +269,76 @@ describeMatrix("body", (t, { it, expect, describe }) => { }); }); + describe("readBody with an explicit type", () => { + it("type: text returns the raw string, ignoring the content-type", async () => { + let _body: any; + t.app.all("/api/test", async (event) => { + _body = await readBody(event, { type: "text" }); + return "200"; + }); + // JSON content-type, but `type: text` forces the raw string. + await t.fetch("/api/test", { + method: "POST", + headers: { "content-type": "application/json" }, + body: '{ "hello": true }', + }); + expect(_body).toBe('{ "hello": true }'); + }); + + it("type: text returns an empty string for an empty body", async () => { + let _body: any = "unset"; + t.app.all("/api/test", async (event) => { + _body = await readBody(event, { type: "text" }); + return "200"; + }); + await t.fetch("/api/test", { method: "POST" }); + expect(_body).toBe(""); + }); + + it("type: urlencoded parses regardless of the content-type", async () => { + let _body: any; + t.app.all("/api/test", async (event) => { + _body = await readBody(event, { type: "urlencoded" }); + return "200"; + }); + // No urlencoded content-type, but `type: urlencoded` forces the parser. + await t.fetch("/api/test", { + method: "POST", + headers: { "content-type": "text/plain" }, + body: "field=value&number=20&number=30", + }); + expect(_body).toMatchObject({ field: "value", number: ["20", "30"] }); + }); + + it("type: json parses even when the content-type is urlencoded", async () => { + let _body: any; + t.app.all("/api/test", async (event) => { + _body = await readBody(event, { type: "json" }); + return "200"; + }); + // urlencoded content-type is ignored in favor of the forced JSON parser. + await t.fetch("/api/test", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: '{ "hello": true }', + }); + expect(_body).toMatchObject({ hello: true }); + }); + + it("type: json throws a 400 on an invalid JSON body", async () => { + t.app.all("/api/test", async (event) => { + await readBody(event, { type: "json" }); + return "200"; + }); + const result = await t.fetch("/api/test", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "field=value", + }); + expect(result.status).toBe(400); + }); + }); + it("returns empty string if body is not present with text/plain", async () => { let _body: string | undefined; t.app.all("/api/test", async (event) => {