fix(security): rate-limit batch-measurements ingest (60/min default)

The Apple Health batch endpoint accepted 500-row payloads with no
per-user write cap. A leaked wildcard iOS token could sustain
unbounded inserts (500 rows × N batches/sec) and degrade the
database for every other user on the host.

Gate the write path with the shared `checkRateLimit` helper at
60 batches/min/user — generous for legitimate iOS sync (the
HealthKit observer coalesces well below one batch/minute on a
quiet device, and 60/min × 500 rows = 30 000 rows/min headroom)
and tight enough that a misbehaving client bottoms out within a
minute. Returns 429 with an unambiguous message so the iOS
client can back off and retry rather than assume the row landed.

Integration test exercises the over-limit path by pre-seeding the
`rate_limits` counter at the cap.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 16:54:45 +02:00
1 parent 3dff9345d2
commit 04dd972358
2 files changed
+69

No files matched your search

@@ -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");