diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 7c9274fa3..5b8603cba 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -35,10 +35,21 @@ import { withIdempotency } from "@/lib/idempotency"; import { mapAppleHealthEntry } from "@/lib/measurements/apple-health-mapping"; import { validateMeasurementRange } from "@/lib/validations/measurement"; import { deviceTypeEnum } from "@/lib/validations/source-priority"; +import { checkRateLimit } from "@/lib/rate-limit"; import { Prisma } from "@/generated/prisma/client"; const MAX_BATCH_ENTRIES = 500; +// 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 +// observer pattern coalesces), so 60/min × 500 entries/batch = +// 30 000 rows/min headroom — generous for legitimate use, and a +// hard stop for a leaked wildcard token trying to saturate the +// write pipeline. +const BATCH_RATE_LIMIT_MAX = 60; +const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000; + const batchEntrySchema = z.object({ hkIdentifier: z.string().min(1).max(120), value: z.number().finite(), @@ -84,6 +95,25 @@ export const POST = apiHandler(withIdempotency<[NextRequest]>(postBatch)); async function postBatch(request: NextRequest): Promise { const { user } = await requireAuth(); + // v1.4.25 W10 reconcile (security H-2): per-user rate limit. Without + // it, a leaked wildcard iOS token can sustain unbounded writes + // (500 rows/batch × N batches/sec) and degrade the database for + // every other user on the host. 60 batches/min/user is generous + // for healthy iOS sync and tight enough that a misbehaving client + // bottoms out within a minute. + const rl = await checkRateLimit( + `measurements:batch:${user.id}`, + BATCH_RATE_LIMIT_MAX, + BATCH_RATE_LIMIT_WINDOW_MS, + ); + if (!rl.allowed) { + annotate({ + action: { name: "measurement.batch.ingest" }, + meta: { outcome: "rate_limited" }, + }); + return apiError("Too many batch submissions, try again later", 429); + } + const { data: rawBody, error: jsonError } = await safeJson(request); if (jsonError) return jsonError; diff --git a/tests/integration/measurements-batch.test.ts b/tests/integration/measurements-batch.test.ts index 9facd7fbf..6ee8a8b7e 100644 --- a/tests/integration/measurements-batch.test.ts +++ b/tests/integration/measurements-batch.test.ts @@ -325,6 +325,45 @@ describe("POST /api/measurements/batch (real Postgres)", () => { expect(stored.every((r) => r.unit === "minutes")).toBe(true); }); + it("rate-limits a user at 60 batches per minute (security H-2)", async () => { + const { POST } = await import("@/app/api/measurements/batch/route"); + + // Pre-seed the rate-limit counter to the cap so the next call + // exercises the over-limit path without 60 round-trips. + const cap = 60; + const resetAt = new Date(Date.now() + 60 * 1000); + await getPrismaClient().$executeRaw` + INSERT INTO rate_limits (key, count, reset_at) + VALUES (${`measurements:batch:${TEST_USER_ID}`}, ${cap}, ${resetAt}) + `; + + const body = { + entries: [ + { + hkIdentifier: "HKQuantityTypeIdentifierBodyMass", + value: 81.4, + unit: "kg", + startDate: "2026-05-09T07:30:00.000Z", + endDate: "2026-05-09T07:30:00.000Z", + externalId: "uuid-rate-limit-001", + }, + ], + }; + + const response = await POST(makeRequest(body)); + expect(response.status).toBe(429); + const json = (await response.json()) as { error: string }; + expect(json.error).toMatch(/too many/i); + + // The over-limit response must not have written anything to the + // measurement table — the iOS client should retry, not assume the + // row landed. + const stored = await getPrismaClient().measurement.findMany({ + where: { userId: TEST_USER_ID }, + }); + expect(stored).toHaveLength(0); + }); + it("replays a cached response when the same Idempotency-Key is reused", async () => { const { POST } = await import("@/app/api/measurements/batch/route");