From 48f20f46221deb77fd28cbfbb1445f2ec3aa4806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 3 Jun 2026 08:45:45 +0200 Subject: [PATCH] feat(api): cap request-body size on batch and export ingest routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply safeJson's opt-in maxBytes ceiling to the body-accepting batch and export routes that lacked one. The cap measures the raw text before JSON.parse, so an over-limit payload returns a clean 413 without buffering onto the heap. - workouts/batch: reuse the existing 5 MB ceiling so chunked uploads with no Content-Length are bounded too. - measurements/batch: 4 MB — an order of magnitude above a 500-entry batch's few-hundred-KB worst case. - export/health-record: 64 KB — the selection payload is small and bounded. Each cap is a DoS ceiling sized well above any legitimate payload, never a tight bound on a real batch. --- src/app/api/export/health-record/route.ts | 9 ++- src/app/api/measurements/batch/route.ts | 13 +++- src/app/api/workouts/batch/route.ts | 11 ++- .../__tests__/api-response-safe-json.test.ts | 69 +++++++++++++++++++ 4 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 src/lib/__tests__/api-response-safe-json.test.ts diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts index b8f14a602..543156eb4 100644 --- a/src/app/api/export/health-record/route.ts +++ b/src/app/api/export/health-record/route.ts @@ -58,7 +58,14 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Maximum 10 exports per hour", 429); } - const { data: body, error: jsonError } = await safeJson(request); + // The selection payload is small and bounded — format, range, + // section toggles, a few flags, an optional practice-name string. + // 64 KB is far above any legitimate selection while still rejecting + // a multi-megabyte body before it reaches `JSON.parse`. A DoS + // ceiling, not a tight bound. + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = exportSelectionSchema.safeParse(body); diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 152435efa..a0c2a7af1 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -62,6 +62,15 @@ const PR_DETECTION_SILENT_THRESHOLD = 50; const MAX_BATCH_ENTRIES = 500; +// Body-size ceiling backstopping the per-entry cap. A worst-case entry +// (max-length hkIdentifier + unit + externalId + two ISO dates + the +// numeric fields) serialises to well under 1 KB, so a legitimate +// 500-entry batch tops out a few hundred KB. 4 MB leaves an order of +// magnitude of headroom above any real batch while still rejecting a +// multi-megabyte payload before it reaches `JSON.parse` and the heap. +// The cap is a DoS ceiling, not a tight bound. +const MAX_BODY_BYTES = 4 * 1024 * 1024; + // v1.4.25 W10 reconcile (security H-2): cap batch ingest at 60 // batches per user per minute. Healthy iOS sync drains its // HealthKit observer queue in well under one batch per minute (the @@ -177,7 +186,9 @@ async function postBatch(request: NextRequest): Promise { return apiError("Too many batch submissions, try again later", 429); } - const { data: rawBody, error: jsonError } = await safeJson(request); + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: MAX_BODY_BYTES, + }); if (jsonError) return jsonError; // Distinguish "too many entries" from "validation failed" so the diff --git a/src/app/api/workouts/batch/route.ts b/src/app/api/workouts/batch/route.ts index 5ee00fb99..8982eb914 100644 --- a/src/app/api/workouts/batch/route.ts +++ b/src/app/api/workouts/batch/route.ts @@ -175,7 +175,16 @@ async function postBatch(request: NextRequest): Promise { return apiError("Too many batch submissions, try again later", 429); } - const { data: rawBody, error: jsonError } = await safeJson(request); + // The Content-Length pre-flight above bounds the common case, but a + // chunked upload carries no Content-Length. The `safeJson` body cap + // backstops that gap: the raw text is measured before `JSON.parse`, + // so an over-limit chunked body returns a clean 413 rather than + // buffering an unbounded payload onto the heap. Reuses the same 5 MB + // ceiling — the route's 100-workout × 20 000-point worst case sits + // well below it, so a real iOS batch is never rejected. + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: MAX_BODY_BYTES, + }); if (jsonError) return jsonError; // Distinguish "too many entries" from "validation failed" so the diff --git a/src/lib/__tests__/api-response-safe-json.test.ts b/src/lib/__tests__/api-response-safe-json.test.ts new file mode 100644 index 000000000..0b01d545b --- /dev/null +++ b/src/lib/__tests__/api-response-safe-json.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; + +import { safeJson } from "../api-response"; + +// `safeJson`'s opt-in `maxBytes` cap is the DoS ceiling the batch / +// export ingest routes lean on. The check runs against the raw text +// before `JSON.parse`, so an over-limit body never lands in heap and +// the caller gets a clean 413 envelope. + +function jsonRequest(body: string): Request { + return new Request("https://example.test/api/x", { + method: "POST", + headers: { "content-type": "application/json" }, + body, + }); +} + +describe("safeJson maxBytes", () => { + it("parses a body at or under the cap", async () => { + const payload = JSON.stringify({ entries: [1, 2, 3] }); + const result = await safeJson(jsonRequest(payload), { + maxBytes: 1024, + }); + expect(result.error).toBeUndefined(); + expect(result.data).toEqual({ entries: [1, 2, 3] }); + }); + + it("rejects an over-limit body with a 413 envelope", async () => { + const payload = JSON.stringify({ blob: "x".repeat(2048) }); + const result = await safeJson(jsonRequest(payload), { + maxBytes: 1024, + }); + expect(result.data).toBeUndefined(); + expect(result.error).toBeDefined(); + expect(result.error!.status).toBe(413); + + const body = (await result.error!.json()) as { + data: null; + error: string; + }; + expect(body.data).toBeNull(); + expect(body.error).toContain("1024"); + }); + + it("returns 400 on invalid JSON within the cap", async () => { + const result = await safeJson(jsonRequest("{not json"), { + maxBytes: 1024, + }); + expect(result.data).toBeUndefined(); + expect(result.error!.status).toBe(400); + }); + + it("still rejects a non-JSON content-type before measuring bytes", async () => { + const request = new Request("https://example.test/api/x", { + method: "POST", + headers: { "content-type": "text/plain" }, + body: "x".repeat(4096), + }); + const result = await safeJson(request, { maxBytes: 1024 }); + expect(result.error!.status).toBe(415); + }); + + it("parses unbounded when maxBytes is omitted", async () => { + const payload = JSON.stringify({ ok: true }); + const result = await safeJson(jsonRequest(payload)); + expect(result.error).toBeUndefined(); + expect(result.data).toEqual({ ok: true }); + }); +});