mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
3dff9345d2
commit
04dd972358
2 files changed
+69
No files matched your search
@@ -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<Response> {
|
||||
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<unknown>(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in new issue
Block a user