diff --git a/docs/2.utils/1.request.md b/docs/2.utils/1.request.md index 2898df7f6..2dd7bfa80 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?)` 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/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 0bdd9fdb9..c00da3300 100644 --- a/src/utils/body.ts +++ b/src/utils/body.ts @@ -1,40 +1,92 @@ 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"; 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. * + * 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?: ReadBodyOptions): Promise { + const contentType = event.req.headers.get("content-type") || ""; + const type = options?.type; + + // `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(); + + // Text is returned verbatim, including an empty body as `""`. + if (type === "text") { + return text as _T; + } + if (!text) { return undefined; } - const contentType = event.req.headers.get("content-type") || ""; - if (contentType.startsWith("application/x-www-form-urlencoded")) { + 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..bf2633b3c 100644 --- a/test/body.test.ts +++ b/test/body.test.ts @@ -219,6 +219,126 @@ 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); + }); + }); + + 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) => {