From a4b53b185262775e63a0b690f1e6e73a4d41cdd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 27 May 2026 00:12:27 +0200 Subject: [PATCH] feat(api): add an opt-in body-size cap to safeJson MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `safeJson()` previously called `request.json()` directly, which buffers the entire body before parsing. Next.js imposes a runtime cap of around 4 MB on App Router handlers, so the worst case is bounded — but routes that legitimately accept large batches can't tighten the cap through the helper, and a 3.9 MB payload to a single-record route still lands in heap before any Zod check fires. The new `opts.maxBytes` parameter routes the request through `request.text()`, checks the byte length first, and only then runs `JSON.parse`. A 413 envelope returns when the cap trips. Existing callers (every route that doesn't pass `opts`) keep their current behaviour — the helper falls through to `request.json()` exactly as before, no regression risk on the broader codebase. The CSP-report endpoint inlines the same pattern with a 10 KB cap; this generalises it so single-record routes can pass `{ maxBytes: 16_384 }` and batch routes can pass `{ maxBytes: 262_144 }` (or whatever fits the schema's max array length × per-row budget). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/api-response.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/lib/api-response.ts b/src/lib/api-response.ts index 0aeb74ad5..ff8832485 100644 --- a/src/lib/api-response.ts +++ b/src/lib/api-response.ts @@ -199,14 +199,46 @@ export function apiError( /** * Safely parse JSON body from a request. * Returns the parsed body or a 400 error response if parsing fails. + * + * `opts.maxBytes` opts a route into a hard body-size cap. The check + * runs against the raw text rather than the parsed object, so a + * gigabyte payload never reaches `JSON.parse` and never lands in + * heap. Single-record routes can pass a tight cap (a few KB); batch + * routes that legitimately accept large payloads can pass a larger + * one (a few hundred KB). When `maxBytes` is omitted the route + * inherits the Next.js runtime default — pre-existing behaviour, no + * regression risk for callers that haven't adopted the parameter. */ export async function safeJson( request: Request, + opts?: { maxBytes?: number }, ): Promise<{ data: T; error?: never } | { data?: never; error: Response }> { const ct = request.headers.get("content-type"); if (!ct || !ct.includes("application/json")) { return { error: apiError("Content-Type must be application/json", 415) }; } + if (opts?.maxBytes !== undefined) { + let raw: string; + try { + raw = await request.text(); + } catch { + return { error: apiError("Invalid request body", 400) }; + } + if (raw.length > opts.maxBytes) { + return { + error: apiError( + `Request body exceeds ${opts.maxBytes} bytes`, + 413, + ), + }; + } + try { + const data = JSON.parse(raw) as T; + return { data }; + } catch { + return { error: apiError("Invalid JSON body", 400) }; + } + } try { const data = (await request.json()) as T; return { data };