From d11f817f1b18d5da6e311bdd0a222ae661efa2c2 Mon Sep 17 00:00:00 2001 From: Tobias Diez Date: Mon, 10 Jul 2023 19:32:27 +0200 Subject: [PATCH] feat(readBody): validate requests with `application/json` content type (#207) Co-authored-by: Pooya Parsa --- src/utils/body.ts | 95 ++++++++++++++++++++++++++++++++-------------- test/body.test.ts | 97 ++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 158 insertions(+), 34 deletions(-) diff --git a/src/utils/body.ts b/src/utils/body.ts index 382d7b99..c53b90a4 100644 --- a/src/utils/body.ts +++ b/src/utils/body.ts @@ -1,6 +1,8 @@ +import type { IncomingMessage } from "node:http"; import destr from "destr"; import type { Encoding, HTTPMethod } from "../types"; import type { H3Event } from "../event"; +import { createError } from "../error"; import { parse as parseMultipartData } from "./internal/multipart"; import { assertMethod, getRequestHeader } from "./request"; @@ -8,11 +10,16 @@ export type { MultiPartData } from "./internal/multipart"; const RawBodySymbol = Symbol.for("h3RawBody"); const ParsedBodySymbol = Symbol.for("h3ParsedBody"); +type InternalRequest = IncomingMessage & { + [RawBodySymbol]?: Promise; + [ParsedBodySymbol]?: T; + body?: string | undefined; +}; const PayloadMethods: HTTPMethod[] = ["PATCH", "POST", "PUT", "DELETE"]; /** - * Reads body of the request and returns encoded raw string (default) or `Buffer` if encoding if falsy. + * Reads body of the request and returns encoded raw string (default), or `Buffer` if encoding is falsy. * @param event {H3Event} H3 event or req passed by h3 handler * @param encoding {Encoding} encoding="utf-8" - The character encoding to use. * @@ -73,45 +80,42 @@ export function readRawBody( } /** - * Reads request body and try to safely parse using [destr](https://github.com/unjs/destr) - * @param event {H3Event} H3 event or req passed by h3 handler + * Reads request body and tries to safely parse using [destr](https://github.com/unjs/destr). + * @param event {H3Event} H3 event passed by h3 handler * @param encoding {Encoding} encoding="utf-8" - The character encoding to use. * * @return {*} The `Object`, `Array`, `String`, `Number`, `Boolean`, or `null` value corresponding to the request JSON body * * ```ts - * const body = await readBody(req) + * const body = await readBody(event) * ``` */ -export async function readBody(event: H3Event): Promise { - if (ParsedBodySymbol in event.node.req) { - return (event.node.req as any)[ParsedBodySymbol]; +export async function readBody( + event: H3Event, + options: { strict?: boolean } = {} +): Promise { + const request = event.node.req as InternalRequest; + if (ParsedBodySymbol in request) { + return request[ParsedBodySymbol]; } - const body = await readRawBody(event, "utf8"); - - if ( - event.node.req.headers["content-type"] === - "application/x-www-form-urlencoded" - ) { - const form = new URLSearchParams(body); - const parsedForm: Record = Object.create(null); - for (const [key, value] of form.entries()) { - if (key in parsedForm) { - if (!Array.isArray(parsedForm[key])) { - parsedForm[key] = [parsedForm[key]]; - } - parsedForm[key].push(value); - } else { - parsedForm[key] = value; - } - } - return parsedForm as unknown as T; + const contentType = request.headers["content-type"] || ""; + const body = await readRawBody(event); + + let parsed: T; + + if (contentType === "application/json") { + parsed = _parseJSON(body, options.strict ?? true) as T; + } else if (contentType === "application/x-www-form-urlencoded") { + parsed = _parseURLEncodedBody(body!) as T; + } else if (contentType.startsWith("text/")) { + parsed = body as T; + } else { + parsed = _parseJSON(body, options.strict ?? false) as T; } - const json = destr(body) as T; - (event.node.req as any)[ParsedBodySymbol] = json; - return json; + request[ParsedBodySymbol] = parsed; + return parsed; } export async function readMultipartFormData(event: H3Event) { @@ -129,3 +133,36 @@ export async function readMultipartFormData(event: H3Event) { } return parseMultipartData(body, boundary); } + +// --- Internal --- + +function _parseJSON(body = "", strict: boolean) { + if (!body) { + return undefined; + } + try { + return destr(body, { strict }); + } catch { + throw createError({ + statusCode: 400, + statusMessage: "Bad Request", + message: "Invalid JSON body", + }); + } +} + +function _parseURLEncodedBody(body: string) { + const form = new URLSearchParams(body); + const parsedForm: Record = Object.create(null); + for (const [key, value] of form.entries()) { + if (key in parsedForm) { + if (!Array.isArray(parsedForm[key])) { + parsedForm[key] = [parsedForm[key]]; + } + parsedForm[key].push(value); + } else { + parsedForm[key] = value; + } + } + return parsedForm as unknown; +} diff --git a/test/body.test.ts b/test/body.test.ts index 24e0b877..98696af6 100644 --- a/test/body.test.ts +++ b/test/body.test.ts @@ -41,7 +41,7 @@ describe("", () => { }); it("returns undefined if body is not present", async () => { - let body: string | undefined = "initial"; + let body = "initial"; app.use( "/", eventHandler(async (request) => { @@ -56,7 +56,7 @@ describe("", () => { }); it("returns an empty string if body is empty", async () => { - let body: string | undefined = "initial"; + let body = "initial"; app.use( "/", eventHandler(async (request) => { @@ -71,7 +71,7 @@ describe("", () => { }); it("returns an empty object string if body is empty object", async () => { - let body: string | undefined = "initial"; + let body = "initial"; app.use( "/", eventHandler(async (request) => { @@ -110,7 +110,7 @@ describe("", () => { }); it("handles non-present body", async () => { - let _body = "initial"; + let _body; app.use( "/", eventHandler(async (request) => { @@ -136,7 +136,7 @@ describe("", () => { .post("/api/test") .set("Content-Type", "text/plain") .send('""'); - expect(_body).toStrictEqual(""); + expect(_body).toStrictEqual('""'); expect(result.text).toBe("200"); }); @@ -265,5 +265,92 @@ describe("", () => { ] `); }); + + it("returns undefined if body is not present with text/plain", async () => { + let body; + app.use( + "/", + eventHandler(async (request) => { + body = await readBody(request); + return "200"; + }) + ); + const result = await request + .post("/api/test") + .set("Content-Type", "text/plain"); + + expect(body).toBeUndefined(); + expect(result.text).toBe("200"); + }); + + it("returns undefined if body is not present with json", async () => { + let body; + app.use( + "/", + eventHandler(async (request) => { + body = await readBody(request); + return "200"; + }) + ); + const result = await request + .post("/api/test") + .set("Content-Type", "application/json"); + + expect(body).toBeUndefined(); + expect(result.text).toBe("200"); + }); + + it("returns the string if content type is text/*", async () => { + let body; + app.use( + "/", + eventHandler(async (request) => { + body = await readBody(request); + return "200"; + }) + ); + const result = await request + .post("/api/test") + .set("Content-Type", "text/*") + .send('{ "hello": true }'); + + expect(body).toBe('{ "hello": true }'); + expect(result.text).toBe("200"); + }); + + it("returns string as is if cannot parse with unknown content type", async () => { + app.use( + "/", + eventHandler(async (request) => { + const _body = await readBody(request); + return _body; + }) + ); + const result = await request + .post("/api/test") + .set("Content-Type", "application/foobar") + .send("{ test: 123 }"); + + expect(result.statusCode).toBe(200); + expect(result.text).toBe("{ test: 123 }"); + }); + + it("fails if json is invalid", async () => { + app.use( + "/", + eventHandler(async (request) => { + const _body = await readBody(request); + return _body; + }) + ); + const result = await request + .post("/api/test") + .set("Content-Type", "application/json") + .send('{ "hello": true'); + + expect(result.statusCode).toBe(400); + expect(result.body.statusMessage).toBe("Bad Request"); + expect(result.body.stack[0]).toBe("Error: Invalid JSON body"); + }); }); });