feat(api): cap request-body size on batch and export ingest routes

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-03 08:56:19 +02:00
1 parent 81b9606f6f
commit 48f20f4622
4 files changed
+99 -3

No files matched your search

+8 -1
View File
@@ -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);
+12 -1
View File
@@ -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<Response> {
return apiError("Too many batch submissions, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson<unknown>(request);
const { data: rawBody, error: jsonError } = await safeJson<unknown>(request, {
maxBytes: MAX_BODY_BYTES,
});
if (jsonError) return jsonError;
// Distinguish "too many entries" from "validation failed" so the
+10 -1
View File
@@ -175,7 +175,16 @@ async function postBatch(request: NextRequest): Promise<Response> {
return apiError("Too many batch submissions, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson<unknown>(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<unknown>(request, {
maxBytes: MAX_BODY_BYTES,
});
if (jsonError) return jsonError;
// Distinguish "too many entries" from "validation failed" so the