mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
81b9606f6f
commit
48f20f4622
4 files changed
+99
-3
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user