feat(api): add an opt-in body-size cap to safeJson

`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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-27 00:12:27 +02:00
1 parent 909c572bfd
commit a4b53b1852
1 file changed
+32
+32
View File
@@ -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<T = unknown>(
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 };