From 65677c21128a53be0535c83e44b1f3c23284ab9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 10 Jun 2026 11:36:51 +0200 Subject: [PATCH] fix(api): bound every JSON body read Raising the middleware body cap to 512 MB for archive imports removed the implicit ceiling that protected JSON routes, so any bulk endpoint would buffer an arbitrarily large body before validation ran. Every JSON body read under /api now carries an explicit byte cap sized to its legitimate payload (64 KB for single-record routes up to 16 MB for the measurement import), webhooks cap before signature work, and oversized bodies return 413. --- src/app/api/admin/ai-settings/route.ts | 4 +- .../api/admin/backups/[id]/restore/route.ts | 6 +- src/app/api/admin/data/route.ts | 6 +- .../drain-per-sample-cumulative/route.ts | 13 ++- src/app/api/admin/feedback/[id]/route.ts | 4 +- src/app/api/admin/rollups/recompute/route.ts | 6 +- .../admin/settings/assistant-flags/route.ts | 4 +- src/app/api/admin/settings/route.ts | 4 +- .../admin/users/[id]/reset-password/route.ts | 4 +- src/app/api/admin/users/[id]/route.ts | 4 +- src/app/api/ai/test/route.ts | 7 +- src/app/api/auth/check-user/route.ts | 14 +-- src/app/api/auth/login/route.ts | 10 +- src/app/api/auth/me/devices/[id]/route.ts | 8 +- .../api/auth/me/injection-site-prefs/route.ts | 4 +- src/app/api/auth/me/timezone/route.ts | 4 +- .../api/auth/passkey/login-verify/route.ts | 11 +-- .../api/auth/passkey/register-verify/route.ts | 5 +- src/app/api/auth/password/route.ts | 4 +- src/app/api/auth/profile/route.ts | 4 +- src/app/api/auth/refresh/route.ts | 8 +- src/app/api/auth/register/route.ts | 4 +- src/app/api/bugreport/route.ts | 4 +- src/app/api/consent/ai/route.ts | 10 +- src/app/api/cycle/day-logs/[id]/route.ts | 4 +- .../day-logs/bulk/__tests__/route.test.ts | 96 +++++++++++++++++++ src/app/api/cycle/day-logs/bulk/route.ts | 4 +- src/app/api/cycle/day-logs/route.ts | 4 +- src/app/api/cycle/period/route.ts | 4 +- .../api/cycle/symptoms/custom/[key]/route.ts | 7 +- src/app/api/cycle/symptoms/custom/route.ts | 13 ++- .../dashboard/chart-overlay-prefs/route.ts | 4 +- src/app/api/dashboard/widgets/route.ts | 11 ++- src/app/api/devices/route.ts | 4 +- .../api/doctor-report/availability/route.ts | 4 +- src/app/api/doctor-report/pdf/route.ts | 5 +- src/app/api/doctor-report/route.ts | 4 +- src/app/api/feedback/route.ts | 4 +- src/app/api/fitbit/credentials/route.ts | 4 +- src/app/api/fitbit/sync/route.ts | 7 +- src/app/api/import/__tests__/route.test.ts | 15 +++ src/app/api/import/route.ts | 4 +- src/app/api/ingest/medication/route.ts | 4 +- .../chat/messages/[id]/feedback/route.ts | 4 +- src/app/api/insights/chat/route.ts | 9 +- src/app/api/insights/feedback/route.ts | 4 +- .../insights/generate/__tests__/route.test.ts | 19 +++- src/app/api/insights/generate/route.ts | 15 ++- src/app/api/insights/layout/route.ts | 8 +- src/app/api/insights/provider-chain/route.ts | 4 +- src/app/api/insights/settings/route.ts | 5 +- src/app/api/integrations/healthkit/route.ts | 4 +- .../api/integrations/moodlog/sync/route.ts | 7 +- .../api/integrations/moodlog/webhook/route.ts | 6 +- src/app/api/internal/deploy-webhook/route.ts | 2 +- src/app/api/internal/web-vitals/route.ts | 20 ++-- src/app/api/measurements/[id]/route.ts | 4 +- .../bulk-delete/__tests__/route.test.ts | 9 ++ src/app/api/measurements/bulk-delete/route.ts | 4 +- .../api/measurements/by-external-ids/route.ts | 4 +- src/app/api/measurements/route.ts | 20 ++-- .../medications/[id]/api-endpoint/route.ts | 6 +- src/app/api/medications/[id]/glp1/route.ts | 4 +- .../[id]/intake/[eventId]/route.ts | 4 +- .../[id]/intake/bulk-delete/route.ts | 4 +- .../intake/import/__tests__/route.test.ts | 5 + .../medications/[id]/intake/import/route.ts | 4 +- src/app/api/medications/[id]/intake/route.ts | 14 ++- .../[id]/inventory/[itemId]/route.ts | 4 +- .../api/medications/[id]/inventory/route.ts | 4 +- .../medications/[id]/phase-config/route.ts | 10 +- src/app/api/medications/[id]/route.ts | 45 +++++---- .../medications/[id]/side-effects/route.ts | 4 +- .../intake/bulk/__tests__/route.test.ts | 17 +++- src/app/api/medications/intake/bulk/route.ts | 14 ++- src/app/api/medications/intake/route.ts | 39 ++++++-- src/app/api/medications/route.ts | 12 ++- src/app/api/monitoring/glitchtip/route.ts | 6 +- src/app/api/mood-entries/[id]/route.ts | 4 +- src/app/api/mood-entries/bulk-delete/route.ts | 4 +- .../mood-entries/bulk/__tests__/route.test.ts | 7 ++ src/app/api/mood-entries/bulk/route.ts | 9 +- src/app/api/mood-entries/route.ts | 9 +- src/app/api/mood/tags/[key]/hidden/route.ts | 13 ++- src/app/api/mood/tags/custom/[key]/route.ts | 13 ++- src/app/api/mood/tags/custom/route.ts | 27 +++++- .../api/notifications/preferences/route.ts | 6 +- src/app/api/notifications/status/route.ts | 6 +- src/app/api/notifications/web-push/route.ts | 12 ++- src/app/api/onboarding/complete/route.ts | 4 +- src/app/api/onboarding/step/route.ts | 9 +- src/app/api/onboarding/tour/route.ts | 4 +- src/app/api/settings/account/route.ts | 6 +- src/app/api/settings/data/route.ts | 6 +- src/app/api/settings/moodlog/route.ts | 6 +- src/app/api/settings/ntfy/route.ts | 4 +- src/app/api/settings/telegram/route.ts | 4 +- src/app/api/telegram/webhook/route.ts | 8 +- src/app/api/tokens/route.ts | 4 +- src/app/api/user/ai-provider/route.ts | 6 +- src/app/api/user/profile/route.ts | 4 +- src/app/api/user/thresholds/route.ts | 4 +- src/app/api/whoop/credentials/route.ts | 4 +- src/app/api/whoop/sync/route.ts | 9 +- src/app/api/whoop/webhook/[token]/route.ts | 12 ++- src/app/api/withings/credentials/route.ts | 4 +- src/app/api/withings/sync/route.ts | 9 +- 107 files changed, 689 insertions(+), 226 deletions(-) create mode 100644 src/app/api/cycle/day-logs/bulk/__tests__/route.test.ts diff --git a/src/app/api/admin/ai-settings/route.ts b/src/app/api/admin/ai-settings/route.ts index 90f406b88..59c11fba1 100644 --- a/src/app/api/admin/ai-settings/route.ts +++ b/src/app/api/admin/ai-settings/route.ts @@ -38,7 +38,9 @@ export const PUT = apiHandler(async (request: NextRequest) => { const rl = await checkRateLimit("admin-ai-settings", 10, 60_000); if (!rl.allowed) throw new HttpError(429, "Too many requests"); - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const { apiKey, model, baseUrl } = body as { diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 9c47ad1be..e8171168f 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -120,7 +120,11 @@ const handler = apiHandler( let body: { confirm?: string } = {}; try { - body = (await request.json()) as { confirm?: string }; + const raw = await request.text(); + if (raw.length > 64 * 1024) { + return apiError(`Request body exceeds ${64 * 1024} bytes`, 413); + } + body = JSON.parse(raw) as { confirm?: string }; } catch { return apiError("Invalid JSON body", 400); } diff --git a/src/app/api/admin/data/route.ts b/src/app/api/admin/data/route.ts index c24736982..444965d80 100644 --- a/src/app/api/admin/data/route.ts +++ b/src/app/api/admin/data/route.ts @@ -27,7 +27,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => { let confirm = ""; try { - const body = await request.json(); + const raw = await request.text(); + if (raw.length > 64 * 1024) { + return apiError(`Request body exceeds ${64 * 1024} bytes`, 413); + } + const body = JSON.parse(raw); confirm = typeof body?.confirm === "string" ? body.confirm : ""; } catch { return apiError("Invalid request", 422); diff --git a/src/app/api/admin/drain-per-sample-cumulative/route.ts b/src/app/api/admin/drain-per-sample-cumulative/route.ts index e34c1e7b7..98fbb3185 100644 --- a/src/app/api/admin/drain-per-sample-cumulative/route.ts +++ b/src/app/api/admin/drain-per-sample-cumulative/route.ts @@ -44,10 +44,13 @@ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAdmin(); annotate({ action: { name: "admin.drain.cumulative" } }); - const { data: rawBody, error: jsonError } = await safeJson(request); + const { data: rawBody, error: jsonError } = await safeJson( + request, + { maxBytes: 64 * 1024 }, + ); if (jsonError) return jsonError; - const body: DrainBody = (rawBody && typeof rawBody === "object") ? rawBody : {}; + const body: DrainBody = rawBody && typeof rawBody === "object" ? rawBody : {}; const userId = typeof body.userId === "string" && body.userId.length > 0 ? body.userId @@ -88,7 +91,11 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } - if (summary.totals.bucketsCollapsed === 0 && summary.totals.usersScanned === 0 && userId) { + if ( + summary.totals.bucketsCollapsed === 0 && + summary.totals.usersScanned === 0 && + userId + ) { return apiError("User not found", 404); } diff --git a/src/app/api/admin/feedback/[id]/route.ts b/src/app/api/admin/feedback/[id]/route.ts index 624e21c39..ede8425f6 100644 --- a/src/app/api/admin/feedback/[id]/route.ts +++ b/src/app/api/admin/feedback/[id]/route.ts @@ -21,7 +21,9 @@ export const PATCH = apiHandler( const { user } = await requireAdmin(); const { id } = await ctx.params; - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = updateFeedbackSchema.safeParse(body); diff --git a/src/app/api/admin/rollups/recompute/route.ts b/src/app/api/admin/rollups/recompute/route.ts index 9571b0d40..480b36a97 100644 --- a/src/app/api/admin/rollups/recompute/route.ts +++ b/src/app/api/admin/rollups/recompute/route.ts @@ -48,8 +48,10 @@ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAdmin(); annotate({ action: { name: "admin.rollups.recompute" } }); - const { data: rawBody, error: jsonError } = - await safeJson(request); + const { data: rawBody, error: jsonError } = await safeJson( + request, + { maxBytes: 64 * 1024 }, + ); if (jsonError) return jsonError; const body: RecomputeBody = diff --git a/src/app/api/admin/settings/assistant-flags/route.ts b/src/app/api/admin/settings/assistant-flags/route.ts index 0782555a1..fd4fd451e 100644 --- a/src/app/api/admin/settings/assistant-flags/route.ts +++ b/src/app/api/admin/settings/assistant-flags/route.ts @@ -120,7 +120,9 @@ export const PUT = apiHandler(async (request: NextRequest) => { const { user } = await requireAdmin(); annotate({ action: { name: "admin.settings.assistant-flags.update" } }); - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = assistantFlagsSchema.safeParse(body); diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index 39ea7397b..ac276b313 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -67,7 +67,9 @@ export const PUT = apiHandler(async (request: NextRequest) => { const { user } = await requireAdmin(); annotate({ action: { name: "admin.settings.update" } }); - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = adminSettingsSchema.safeParse(body); diff --git a/src/app/api/admin/users/[id]/reset-password/route.ts b/src/app/api/admin/users/[id]/reset-password/route.ts index 9fa2e5ffd..add5d7cd5 100644 --- a/src/app/api/admin/users/[id]/reset-password/route.ts +++ b/src/app/api/admin/users/[id]/reset-password/route.ts @@ -39,7 +39,9 @@ export const POST = apiHandler( }, }); - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const { password } = body as { password?: string }; diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index 43fb4151c..d682f316f 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -34,7 +34,9 @@ export const PUT = apiHandler( }, }); - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = updateUserSchema.safeParse(body); diff --git a/src/app/api/ai/test/route.ts b/src/app/api/ai/test/route.ts index 1dbf3decb..221e53ec0 100644 --- a/src/app/api/ai/test/route.ts +++ b/src/app/api/ai/test/route.ts @@ -39,7 +39,9 @@ export const POST = apiHandler(async (request: NextRequest) => { let override: AITestOverride = {}; const contentLength = Number(request.headers.get("content-length") ?? "0"); if (contentLength > 0) { - const { data, error } = await safeJson(request); + const { data, error } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (error) return error; const parsed = overrideSchema.safeParse(data); if (!parsed.success) { @@ -140,7 +142,8 @@ function classifyTestFailure( if (status === 401 || status === 403 || (status >= 500 && looksLikeAuth)) { return { reasonCode: "credentials", - reason: "Provider rejected the credentials — re-authenticate in AI settings.", + reason: + "Provider rejected the credentials — re-authenticate in AI settings.", }; } if (status === 429) { diff --git a/src/app/api/auth/check-user/route.ts b/src/app/api/auth/check-user/route.ts index b0a63373a..c79b0a96c 100644 --- a/src/app/api/auth/check-user/route.ts +++ b/src/app/api/auth/check-user/route.ts @@ -42,10 +42,7 @@ import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { hashToken } from "@/lib/auth/hmac"; -import { - checkAuthSurfaceRateLimit, - rateLimitHeaders, -} from "@/lib/rate-limit"; +import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; @@ -77,7 +74,9 @@ export const POST = apiHandler(async (request: NextRequest) => { ); } - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = bodySchema.safeParse(body); @@ -109,7 +108,10 @@ export const POST = apiHandler(async (request: NextRequest) => { const identifierHash = hashToken(identifier); if (!user) { - annotate({ action: { name: "auth.check-user" }, meta: { branch: "not_found" } }); + annotate({ + action: { name: "auth.check-user" }, + meta: { branch: "not_found" }, + }); await auditLog("auth.check-user", { ipAddress: ip, details: { branch: "not_found", identifier_hash: identifierHash }, diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index ea96560cb..c4e727ee0 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -4,11 +4,7 @@ import { verifyPassword } from "@/lib/auth/password"; import { createSession } from "@/lib/auth/session"; import { auditLog } from "@/lib/auth/audit"; import { hashToken } from "@/lib/auth/hmac"; -import { - apiSuccess, - apiError, - safeJson, -} from "@/lib/api-response"; +import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { ensureDbCompatibility } from "@/lib/db-compat"; import { NextRequest, NextResponse } from "next/server"; @@ -45,7 +41,9 @@ export const POST = apiHandler(async (request: NextRequest) => { await ensureDbCompatibility(); - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = loginPasswordSchema.safeParse(body); diff --git a/src/app/api/auth/me/devices/[id]/route.ts b/src/app/api/auth/me/devices/[id]/route.ts index 7d1630300..89f5ca688 100644 --- a/src/app/api/auth/me/devices/[id]/route.ts +++ b/src/app/api/auth/me/devices/[id]/route.ts @@ -54,7 +54,9 @@ export const PATCH = apiHandler( const { user } = await requireAuth(); const { id } = await context.params; - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = devicePatchSchema.safeParse(body); if (!parsed.success) { @@ -82,7 +84,9 @@ export const PATCH = apiHandler( entity_type: "device", entity_id: id, }, - meta: { medication_delivery: parsed.data.medicationDelivery ?? "inherit" }, + meta: { + medication_delivery: parsed.data.medicationDelivery ?? "inherit", + }, }); return apiSuccess({ diff --git a/src/app/api/auth/me/injection-site-prefs/route.ts b/src/app/api/auth/me/injection-site-prefs/route.ts index b265988f0..3a1fb6360 100644 --- a/src/app/api/auth/me/injection-site-prefs/route.ts +++ b/src/app/api/auth/me/injection-site-prefs/route.ts @@ -91,7 +91,9 @@ export const PATCH = apiHandler(async (request: NextRequest) => { return response; } - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = patchBodySchema.safeParse(body); diff --git a/src/app/api/auth/me/timezone/route.ts b/src/app/api/auth/me/timezone/route.ts index e5b849fcb..1ac07297a 100644 --- a/src/app/api/auth/me/timezone/route.ts +++ b/src/app/api/auth/me/timezone/route.ts @@ -34,7 +34,9 @@ export const PUT = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "user.timezone.update" } }); - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const tz = diff --git a/src/app/api/auth/passkey/login-verify/route.ts b/src/app/api/auth/passkey/login-verify/route.ts index ad6241bcb..18178dd64 100644 --- a/src/app/api/auth/passkey/login-verify/route.ts +++ b/src/app/api/auth/passkey/login-verify/route.ts @@ -1,11 +1,7 @@ import { verifyAuthentication } from "@/lib/auth/passkey"; import { createSession } from "@/lib/auth/session"; import { auditLog } from "@/lib/auth/audit"; -import { - apiSuccess, - apiError, - safeJson, -} from "@/lib/api-response"; +import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; import { prisma } from "@/lib/db"; import { ensureDbCompatibility } from "@/lib/db-compat"; import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; @@ -35,8 +31,9 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Too many attempts. Please wait 15 minutes.", 429); } - const { data: body, error: jsonError } = - await safeJson>(request); + const { data: body, error: jsonError } = await safeJson< + Record + >(request, { maxBytes: 64 * 1024 }); if (jsonError) return jsonError; const challengeId = body.challengeId as string | undefined; diff --git a/src/app/api/auth/passkey/register-verify/route.ts b/src/app/api/auth/passkey/register-verify/route.ts index 14c405f2c..1b9ae3b84 100644 --- a/src/app/api/auth/passkey/register-verify/route.ts +++ b/src/app/api/auth/passkey/register-verify/route.ts @@ -14,8 +14,9 @@ import { annotate } from "@/lib/logging/context"; export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const { data: body, error: jsonError } = - await safeJson>(request); + const { data: body, error: jsonError } = await safeJson< + Record + >(request, { maxBytes: 64 * 1024 }); if (jsonError) return jsonError; const challengeId = body.challengeId as string | undefined; diff --git a/src/app/api/auth/password/route.ts b/src/app/api/auth/password/route.ts index 79e4a7868..828304e8a 100644 --- a/src/app/api/auth/password/route.ts +++ b/src/app/api/auth/password/route.ts @@ -32,7 +32,9 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Too many attempts. Please wait 15 minutes.", 429); } - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = changePasswordSchema.safeParse(body); diff --git a/src/app/api/auth/profile/route.ts b/src/app/api/auth/profile/route.ts index c84a59771..8b9d17347 100644 --- a/src/app/api/auth/profile/route.ts +++ b/src/app/api/auth/profile/route.ts @@ -12,7 +12,9 @@ import { applyProfileUpdate } from "@/lib/auth/profile-update"; export const PUT = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const result = await applyProfileUpdate(user.id, body, getClientIp(request)); diff --git a/src/app/api/auth/refresh/route.ts b/src/app/api/auth/refresh/route.ts index 9c7d75633..adb89145f 100644 --- a/src/app/api/auth/refresh/route.ts +++ b/src/app/api/auth/refresh/route.ts @@ -4,11 +4,7 @@ */ import { NextRequest } from "next/server"; import { apiHandler } from "@/lib/api-handler"; -import { - apiSuccess, - apiError, - safeJson, -} from "@/lib/api-response"; +import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { annotate } from "@/lib/logging/context"; import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; @@ -42,7 +38,7 @@ export const POST = apiHandler(async (request: NextRequest) => { const { data: body, error: jsonError } = await safeJson<{ refreshToken?: string; revoke?: boolean; - }>(request); + }>(request, { maxBytes: 64 * 1024 }); if (jsonError) return jsonError; if (!body.refreshToken || typeof body.refreshToken !== "string") { return apiError("refreshToken required", 422); diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 71c327222..3a3080207 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -61,7 +61,9 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Registration is disabled", 403); } - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = registerSchema.safeParse(body); diff --git a/src/app/api/bugreport/route.ts b/src/app/api/bugreport/route.ts index 0a9aedc55..9f2c24fa2 100644 --- a/src/app/api/bugreport/route.ts +++ b/src/app/api/bugreport/route.ts @@ -80,7 +80,9 @@ export const POST = apiHandler(async (request: NextRequest) => { ); } - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 8 * 1024 * 1024, + }); if (jsonError) return jsonError; const parsed = bugReportSchema.safeParse(body); diff --git a/src/app/api/consent/ai/route.ts b/src/app/api/consent/ai/route.ts index fb2d6ab16..8aa2cbb26 100644 --- a/src/app/api/consent/ai/route.ts +++ b/src/app/api/consent/ai/route.ts @@ -9,11 +9,7 @@ */ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { - apiSuccess, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { consentPostBody } from "@/lib/validations/consent"; @@ -22,7 +18,9 @@ import { createReceipt } from "@/lib/consent/receipts"; export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const { data: body, error } = await safeJson(request); + const { data: body, error } = await safeJson(request, { + maxBytes: 128 * 1024, + }); if (error) return error; const parsed = consentPostBody.safeParse(body); diff --git a/src/app/api/cycle/day-logs/[id]/route.ts b/src/app/api/cycle/day-logs/[id]/route.ts index 87d4e32f7..2d72868e0 100644 --- a/src/app/api/cycle/day-logs/[id]/route.ts +++ b/src/app/api/cycle/day-logs/[id]/route.ts @@ -56,7 +56,9 @@ export const PATCH = apiHandler( return apiError("Day-log not found", 404); } - const { data: rawBody, error: jsonError } = await safeJson(request); + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = cycleDayLogPatchSchema.safeParse(rawBody); diff --git a/src/app/api/cycle/day-logs/bulk/__tests__/route.test.ts b/src/app/api/cycle/day-logs/bulk/__tests__/route.test.ts new file mode 100644 index 000000000..bc177e247 --- /dev/null +++ b/src/app/api/cycle/day-logs/bulk/__tests__/route.test.ts @@ -0,0 +1,96 @@ +/** + * POST /api/cycle/day-logs/bulk — body-size cap. + * + * The bulk drain accepts up to 500 entries; the `safeJson` cap (2 MB) + * rejects an oversized body with 413 before `JSON.parse` builds an + * object graph. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + auditLog: { create: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), + rateLimitHeaders: () => ({}), +})); +vi.mock("@/lib/idempotency", () => ({ + withIdempotency: + (fn: (...args: Args) => Promise) => + (...args: Args) => + fn(...args), +})); +vi.mock("@/lib/cycle/gate", () => ({ + requireCycleEnabled: vi.fn(), +})); +vi.mock("@/lib/cycle/day-log-write", () => ({ + upsertCycleDayLog: vi.fn(), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { requireCycleEnabled } from "@/lib/cycle/gate"; +import { upsertCycleDayLog } from "@/lib/cycle/day-log-write"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +function postReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/cycle/day-logs/bulk", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); + vi.mocked(requireCycleEnabled).mockResolvedValue({ + enabled: true, + } as never); +}); + +describe("POST /api/cycle/day-logs/bulk — body cap", () => { + it("rejects a body over the 2 MB cap with 413 before parsing", async () => { + const res = await POST( + postReq({ entries: [], pad: "x".repeat(2 * 1024 * 1024) }), + ); + expect(res.status).toBe(413); + expect(upsertCycleDayLog).not.toHaveBeenCalled(); + }); + + it("still rejects an over-cap entries array with 422 below the byte cap", async () => { + const entries = Array.from({ length: 501 }, (_, i) => ({ + date: "2026-01-01", + externalId: `e${i}`, + })); + const res = await POST(postReq({ entries })); + expect(res.status).toBe(422); + expect(upsertCycleDayLog).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/cycle/day-logs/bulk/route.ts b/src/app/api/cycle/day-logs/bulk/route.ts index d498ccade..5c1b7f225 100644 --- a/src/app/api/cycle/day-logs/bulk/route.ts +++ b/src/app/api/cycle/day-logs/bulk/route.ts @@ -64,7 +64,9 @@ async function postBulk(request: NextRequest): Promise { return apiError("Too many bulk submissions, try again later", 429); } - const { data: rawBody, error: jsonError } = await safeJson(request); + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 2 * 1024 * 1024, + }); if (jsonError) return jsonError; if ( diff --git a/src/app/api/cycle/day-logs/route.ts b/src/app/api/cycle/day-logs/route.ts index 91a7c0f09..05f48effc 100644 --- a/src/app/api/cycle/day-logs/route.ts +++ b/src/app/api/cycle/day-logs/route.ts @@ -77,7 +77,9 @@ async function postDayLog(request: NextRequest): Promise { const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; - const { data: rawBody, error: jsonError } = await safeJson(request); + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = cycleDayLogInputSchema.safeParse(rawBody); diff --git a/src/app/api/cycle/period/route.ts b/src/app/api/cycle/period/route.ts index 908ea889c..08b176672 100644 --- a/src/app/api/cycle/period/route.ts +++ b/src/app/api/cycle/period/route.ts @@ -42,7 +42,9 @@ async function postPeriod(request: NextRequest): Promise { const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; - const { data: rawBody, error: jsonError } = await safeJson(request); + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = cyclePeriodSchema.safeParse(rawBody); diff --git a/src/app/api/cycle/symptoms/custom/[key]/route.ts b/src/app/api/cycle/symptoms/custom/[key]/route.ts index 1d9b74d4b..7db44e80f 100644 --- a/src/app/api/cycle/symptoms/custom/[key]/route.ts +++ b/src/app/api/cycle/symptoms/custom/[key]/route.ts @@ -14,6 +14,7 @@ import { apiError, getClientIp, returnAllZodIssues, + safeJson, } from "@/lib/api-response"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; @@ -52,7 +53,11 @@ export const PATCH = apiHandler( const { key } = await params; if (!isCustomSymptomKey(key)) return apiError("Not a custom symptom", 404); - const parsed = updateCustomSymptomSchema.safeParse(await request.json()); + const { data: rawJsonBody, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + const parsed = updateCustomSymptomSchema.safeParse(rawJsonBody); if (!parsed.success) { return returnAllZodIssues(parsed.error, 422, { errorCode: "cycle.symptom.custom.invalid", diff --git a/src/app/api/cycle/symptoms/custom/route.ts b/src/app/api/cycle/symptoms/custom/route.ts index 956f93c41..b250aeb1f 100644 --- a/src/app/api/cycle/symptoms/custom/route.ts +++ b/src/app/api/cycle/symptoms/custom/route.ts @@ -22,6 +22,7 @@ import { apiError, getClientIp, returnAllZodIssues, + safeJson, } from "@/lib/api-response"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; @@ -74,12 +75,20 @@ export const POST = apiHandler(async (request: NextRequest) => { // Cap mutation rate so a single session can't flood the encrypted-label // catalogue (each create runs an encrypt + audit write). - const rl = await checkRateLimit(`cycle:symptom:custom:${user.id}`, 30, 60_000); + const rl = await checkRateLimit( + `cycle:symptom:custom:${user.id}`, + 30, + 60_000, + ); if (!rl.allowed) { return apiError("Too many requests, try again later", 429); } - const parsed = createCustomSymptomSchema.safeParse(await request.json()); + const { data: rawJsonBody, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + const parsed = createCustomSymptomSchema.safeParse(rawJsonBody); if (!parsed.success) { return returnAllZodIssues(parsed.error, 422, { errorCode: "cycle.symptom.custom.invalid", diff --git a/src/app/api/dashboard/chart-overlay-prefs/route.ts b/src/app/api/dashboard/chart-overlay-prefs/route.ts index 87a8f4050..1a84a9cd1 100644 --- a/src/app/api/dashboard/chart-overlay-prefs/route.ts +++ b/src/app/api/dashboard/chart-overlay-prefs/route.ts @@ -50,7 +50,9 @@ const prefsSchema = z.object({ export const PUT = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = prefsSchema.safeParse(body); diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 5ab6e51d7..692b7ed70 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -143,7 +143,9 @@ export const GET = apiHandler(async () => { export const PUT = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 256 * 1024, + }); if (jsonError) return jsonError; // v1.7.0 W1 — accept-and-ignore widget ids OUTSIDE the 27-id catalogue @@ -165,8 +167,7 @@ export const PUT = apiHandler(async (request: NextRequest) => { const droppedIds = widgetsBody.widgets .map((w) => w?.id) .filter( - (id): id is string => - typeof id === "string" && !knownWidgetIds.has(id), + (id): id is string => typeof id === "string" && !knownWidgetIds.has(id), ); if (droppedIds.length > 0) { widgetsBody.widgets = widgetsBody.widgets.filter( @@ -208,7 +209,9 @@ export const PUT = apiHandler(async (request: NextRequest) => { // future field matching the denylist (password / token / secret / // apiKey / authorization / csrfState / nonce) lands as the literal // `"[redacted]"` instead of its raw value. - const payloadDiagnostic = buildPayloadDiagnostic(redactSensitiveFields(body)); + const payloadDiagnostic = buildPayloadDiagnostic( + redactSensitiveFields(body), + ); annotate({ action: { name: "dashboard.widgets.validation-failed" }, meta: { diff --git a/src/app/api/devices/route.ts b/src/app/api/devices/route.ts index e1f65196a..98dc17e38 100644 --- a/src/app/api/devices/route.ts +++ b/src/app/api/devices/route.ts @@ -100,7 +100,9 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Too many device registrations, please slow down", 429); } - const { data: body, error } = await safeJson(request); + const { data: body, error } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (error) return error; const parsed = deviceSchema.safeParse(body); diff --git a/src/app/api/doctor-report/availability/route.ts b/src/app/api/doctor-report/availability/route.ts index 32bf3e2ac..e2a818a95 100644 --- a/src/app/api/doctor-report/availability/route.ts +++ b/src/app/api/doctor-report/availability/route.ts @@ -36,7 +36,9 @@ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "doctor-report.availability" } }); - const { data: body, error } = await safeJson(request); + const { data: body, error } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (error) return error; const range = normaliseDateRange(body ?? undefined); const { start, end } = range; diff --git a/src/app/api/doctor-report/pdf/route.ts b/src/app/api/doctor-report/pdf/route.ts index 07667d42a..d64f12467 100644 --- a/src/app/api/doctor-report/pdf/route.ts +++ b/src/app/api/doctor-report/pdf/route.ts @@ -146,7 +146,10 @@ async function readOptionalJsonBody( const contentType = request.headers.get("content-type") ?? ""; if (!contentType.includes("application/json")) return null; try { - return (await request.json()) as PdfRequestBody; + const raw = await request.text(); + // Small options object — treat an oversized body as no body. + if (raw.length > 64 * 1024) return null; + return JSON.parse(raw) as PdfRequestBody; } catch { return null; } diff --git a/src/app/api/doctor-report/route.ts b/src/app/api/doctor-report/route.ts index f601aa62f..776009f70 100644 --- a/src/app/api/doctor-report/route.ts +++ b/src/app/api/doctor-report/route.ts @@ -44,7 +44,9 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Maximum 10 reports per hour", 429); } - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const range = normaliseDateRange(body); diff --git a/src/app/api/feedback/route.ts b/src/app/api/feedback/route.ts index e35bb53ce..2547bb3af 100644 --- a/src/app/api/feedback/route.ts +++ b/src/app/api/feedback/route.ts @@ -41,7 +41,9 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Bug reports are disabled by the administrator", 503); } - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = createFeedbackSchema.safeParse(body); diff --git a/src/app/api/fitbit/credentials/route.ts b/src/app/api/fitbit/credentials/route.ts index b2cabfee1..1a57ae5fe 100644 --- a/src/app/api/fitbit/credentials/route.ts +++ b/src/app/api/fitbit/credentials/route.ts @@ -37,7 +37,9 @@ export const PUT = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "fitbit.credentials.update" } }); - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const result = z.safeParse(fitbitCredentialsSchema, body); diff --git a/src/app/api/fitbit/sync/route.ts b/src/app/api/fitbit/sync/route.ts index d37696252..02c1b1b2d 100644 --- a/src/app/api/fitbit/sync/route.ts +++ b/src/app/api/fitbit/sync/route.ts @@ -31,7 +31,12 @@ export const POST = apiHandler(async (request: NextRequest) => { let fullSync = false; try { - const body = await request.json(); + const raw = await request.text(); + // Flag-only payload — cap the parse cost (mirrors safeJson maxBytes). + if (raw.length > 64 * 1024) { + return apiError(`Request body exceeds ${64 * 1024} bytes`, 413); + } + const body = JSON.parse(raw); fullSync = body?.fullSync === true; } catch { // no body provided -> default incremental sync diff --git a/src/app/api/import/__tests__/route.test.ts b/src/app/api/import/__tests__/route.test.ts index b4748cbc0..dacb73085 100644 --- a/src/app/api/import/__tests__/route.test.ts +++ b/src/app/api/import/__tests__/route.test.ts @@ -106,6 +106,21 @@ describe("POST /api/import — rate-limit guard", () => { expect(response.status).toBeLessThan(400); }); + it("rejects a body over the 16 MB cap with 413 before parsing", async () => { + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); + const response = await POST( + new NextRequest("http://localhost/api/import", { + method: "POST", + body: JSON.stringify({ + measurements: [], + pad: "x".repeat(16 * 1024 * 1024), + }), + headers: { "content-type": "application/json" }, + }), + ); + expect(response.status).toBe(413); + }); + it("returns the standard apiError envelope for invalid payloads", async () => { vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true, diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index 5b55362ef..9785616d3 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -76,7 +76,9 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Maximum 5 imports per hour", 429); } - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024 * 1024, + }); if (jsonError) return jsonError; const parsed = importSchema.safeParse(body); diff --git a/src/app/api/ingest/medication/route.ts b/src/app/api/ingest/medication/route.ts index d3a937178..fe75ffda1 100644 --- a/src/app/api/ingest/medication/route.ts +++ b/src/app/api/ingest/medication/route.ts @@ -89,7 +89,9 @@ export const POST = apiHandler(async (request: NextRequest) => { data: { lastUsedAt: new Date() }, }); - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = externalIntakeSchema.safeParse(body); diff --git a/src/app/api/insights/chat/messages/[id]/feedback/route.ts b/src/app/api/insights/chat/messages/[id]/feedback/route.ts index 22d8dd757..7b0342c98 100644 --- a/src/app/api/insights/chat/messages/[id]/feedback/route.ts +++ b/src/app/api/insights/chat/messages/[id]/feedback/route.ts @@ -79,7 +79,9 @@ async function handlePost(request: NextRequest, ctx: RouteContext) { return apiError("Too many feedback submissions, try again later", 429); } - const { data: rawBody, error } = await safeJson(request); + const { data: rawBody, error } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (error) return error; const parsed = coachMessageFeedbackSchema.safeParse(rawBody); if (!parsed.success) { diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 85e2aff5e..90acb8440 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -157,8 +157,13 @@ async function handleChatRequest(request: NextRequest): Promise { let body: unknown; try { - body = await request.json(); - } catch { + const raw = await request.text(); + if (raw.length > 64 * 1024) { + throw new HttpError(413, `Request body exceeds ${64 * 1024} bytes`); + } + body = JSON.parse(raw); + } catch (err) { + if (err instanceof HttpError) throw err; throw new HttpError(400, "Invalid JSON body"); } const parsed = coachChatRequestSchema.safeParse(body); diff --git a/src/app/api/insights/feedback/route.ts b/src/app/api/insights/feedback/route.ts index 938a194eb..a8b30b5e7 100644 --- a/src/app/api/insights/feedback/route.ts +++ b/src/app/api/insights/feedback/route.ts @@ -55,7 +55,9 @@ async function handlePost(request: NextRequest) { return apiError("Too many feedback submissions, try again later", 429); } - const { data: rawBody, error } = await safeJson(request); + const { data: rawBody, error } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (error) return error; const parsed = recommendationFeedbackRequestSchema.safeParse(rawBody); diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index b9fa0b3e0..41f147185 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -99,10 +99,9 @@ vi.mock("@/lib/logging/context", () => ({ })); vi.mock("@/lib/insights/features", async () => { - const actual = - await vi.importActual( - "@/lib/insights/features", - ); + const actual = await vi.importActual< + typeof import("@/lib/insights/features") + >("@/lib/insights/features"); return { ...actual, extractFeatures: vi.fn(async () => ({ stub: true })), @@ -193,6 +192,13 @@ function makeProviderThatThrows( } describe("POST /api/insights/generate — provider error mapping", () => { + it("rejects a body over the 16 KB cap with 413 before parsing", async () => { + const res = await POST( + jsonRequest({ force: true, pad: "x".repeat(16 * 1024) }) as never, + ); + expect(res.status).toBe(413); + }); + it("maps a 401 from the provider to 422 with a readable message", async () => { const err = Object.assign(new Error("OpenAI request failed (401)"), { httpStatus: 401, @@ -421,7 +427,10 @@ describe("POST /api/insights/generate — payload-size hard downgrade (H1)", () (call[0] as { meta?: Record })?.meta ?.insights_payload_too_large === true, ); - expect(annotated, "annotate event with insights_payload_too_large").toBeTruthy(); + expect( + annotated, + "annotate event with insights_payload_too_large", + ).toBeTruthy(); }); }); diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 4b4ded190..5b59b3ad3 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -1,6 +1,11 @@ import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; -import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; +import { + apiSuccess, + apiError, + getClientIp, + safeJson, +} from "@/lib/api-response"; import { extractFeatures, FeaturesPayloadTooLargeError, @@ -267,8 +272,7 @@ export const GET = apiHandler(async () => { const cachedAt = dbUser?.insightsCachedAt ?? null; const isFresh = - cachedAt !== null && - Date.now() - cachedAt.getTime() < BRIEFING_FRESH_MS; + cachedAt !== null && Date.now() - cachedAt.getTime() < BRIEFING_FRESH_MS; // Read-only: never block on the provider. Warm out of band only when the // cached briefing is stale / missing AND a provider is configured (a @@ -341,7 +345,10 @@ export const POST = apiHandler(async (request: NextRequest) => { userLocale: dbUser?.locale ?? user.locale ?? null, }); - const body = await request.json().catch(() => ({})); + const { data: body, error: jsonError } = await safeJson<{ + force?: unknown; + }>(request, { maxBytes: 16 * 1024 }); + if (jsonError) return jsonError; const forceRefresh = body.force === true; if ( diff --git a/src/app/api/insights/layout/route.ts b/src/app/api/insights/layout/route.ts index 551c8aed0..cc59124f1 100644 --- a/src/app/api/insights/layout/route.ts +++ b/src/app/api/insights/layout/route.ts @@ -117,7 +117,9 @@ export const GET = apiHandler(async () => { export const PUT = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 256 * 1024, + }); if (jsonError) return jsonError; const parsed = layoutSchema.safeParse(body); @@ -127,7 +129,9 @@ export const PUT = apiHandler(async (request: NextRequest) => { // round-trip; the wide-event line carries the redacted payload // shape for operator debugging without leaking the raw body. const issues = sanitiseZodIssues(parsed.error.issues); - const payloadDiagnostic = buildPayloadDiagnostic(redactSensitiveFields(body)); + const payloadDiagnostic = buildPayloadDiagnostic( + redactSensitiveFields(body), + ); annotate({ action: { name: "insights.layout.validation-failed" }, meta: { diff --git a/src/app/api/insights/provider-chain/route.ts b/src/app/api/insights/provider-chain/route.ts index 24036d1f1..572cae426 100644 --- a/src/app/api/insights/provider-chain/route.ts +++ b/src/app/api/insights/provider-chain/route.ts @@ -100,7 +100,9 @@ export const PUT = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "insights.provider_chain.update" } }); - const { data: body, error } = await safeJson(request); + const { data: body, error } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (error) return error; const parsed = chainBodySchema.safeParse(body); diff --git a/src/app/api/insights/settings/route.ts b/src/app/api/insights/settings/route.ts index 7f7a5c7f5..6fb8d79f6 100644 --- a/src/app/api/insights/settings/route.ts +++ b/src/app/api/insights/settings/route.ts @@ -42,8 +42,9 @@ export const GET = apiHandler(async () => { export const PUT = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const { data: body, error: jsonError } = - await safeJson>(request); + const { data: body, error: jsonError } = await safeJson< + Record + >(request, { maxBytes: 64 * 1024 }); if (jsonError) return jsonError; const data: Record = {}; diff --git a/src/app/api/integrations/healthkit/route.ts b/src/app/api/integrations/healthkit/route.ts index 44951704f..e0ba191f9 100644 --- a/src/app/api/integrations/healthkit/route.ts +++ b/src/app/api/integrations/healthkit/route.ts @@ -139,7 +139,9 @@ export const GET = apiHandler(async () => { export const PATCH = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const { data: body, error } = await safeJson(request); + const { data: body, error } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (error) return error; const parsed = patchSchema.safeParse(body); diff --git a/src/app/api/integrations/moodlog/sync/route.ts b/src/app/api/integrations/moodlog/sync/route.ts index 7a05fd1d7..d2dea2455 100644 --- a/src/app/api/integrations/moodlog/sync/route.ts +++ b/src/app/api/integrations/moodlog/sync/route.ts @@ -22,7 +22,12 @@ export const POST = apiHandler(async (request: NextRequest) => { let fullSync = false; try { - const body = await request.json(); + const raw = await request.text(); + // Flag-only payload — cap the parse cost (mirrors safeJson maxBytes). + if (raw.length > 64 * 1024) { + return apiError(`Request body exceeds ${64 * 1024} bytes`, 413); + } + const body = JSON.parse(raw); fullSync = body?.fullSync === true; } catch { // No body or invalid JSON — use defaults diff --git a/src/app/api/integrations/moodlog/webhook/route.ts b/src/app/api/integrations/moodlog/webhook/route.ts index c12e5daab..827a66fcf 100644 --- a/src/app/api/integrations/moodlog/webhook/route.ts +++ b/src/app/api/integrations/moodlog/webhook/route.ts @@ -74,7 +74,11 @@ export const POST = apiHandler(async (request: NextRequest) => { // 5. Parse body let body: Record; try { - body = await request.json(); + const raw = await request.text(); + if (raw.length > 256 * 1024) { + return apiError(`Request body exceeds ${256 * 1024} bytes`, 413); + } + body = JSON.parse(raw); } catch { return apiError("Invalid JSON", 400); } diff --git a/src/app/api/internal/deploy-webhook/route.ts b/src/app/api/internal/deploy-webhook/route.ts index d60655740..25d0a583a 100644 --- a/src/app/api/internal/deploy-webhook/route.ts +++ b/src/app/api/internal/deploy-webhook/route.ts @@ -176,7 +176,7 @@ export const POST = apiHandler(async (request: NextRequest) => { getEvent()?.setAuth({ auth_method: "webhook_secret" }); const { data: payload, error: jsonError } = - await safeJson(request); + await safeJson(request, { maxBytes: 64 * 1024 }); if (jsonError) return jsonError; const event = normalizePayload(payload); diff --git a/src/app/api/internal/web-vitals/route.ts b/src/app/api/internal/web-vitals/route.ts index 7abe172bf..84683f421 100644 --- a/src/app/api/internal/web-vitals/route.ts +++ b/src/app/api/internal/web-vitals/route.ts @@ -57,7 +57,13 @@ const webVitalsBodySchema = z.object({ delta: z.number().finite().optional(), rating: z.enum(["good", "needs-improvement", "poor"]).optional(), navigationType: z - .enum(["navigate", "reload", "back-forward", "back-forward-cache", "prerender"]) + .enum([ + "navigate", + "reload", + "back-forward", + "back-forward-cache", + "prerender", + ]) .optional(), }); @@ -90,11 +96,7 @@ export const POST = apiHandler(async (request: NextRequest) => { // ≤ 7 beacons / page-load and well below a flood. 429 on hit; the // beacon contract still accepts 4xx without retry on the client. const ip = getClientIp(request); - const rl = await checkRateLimit( - `web-vitals:${ip ?? "unknown"}`, - 60, - 60_000, - ); + const rl = await checkRateLimit(`web-vitals:${ip ?? "unknown"}`, 60, 60_000); if (!rl.allowed) { return NextResponse.json( { error: "rate_limited" }, @@ -104,7 +106,11 @@ export const POST = apiHandler(async (request: NextRequest) => { let raw: unknown; try { - raw = await request.json(); + const text = await request.text(); + if (text.length > 64 * 1024) { + return NextResponse.json({ error: "payload_too_large" }, { status: 413 }); + } + raw = JSON.parse(text); } catch { // Malformed payload — 400 so the beacon never retries. Crucially: // we do NOT log the raw body (log-injection defence). diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts index e6077de71..3786b742e 100644 --- a/src/app/api/measurements/[id]/route.ts +++ b/src/app/api/measurements/[id]/route.ts @@ -66,7 +66,9 @@ export const PUT = apiHandler( return apiError("Measurement not found", 404); } - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = updateMeasurementSchema.safeParse(body); diff --git a/src/app/api/measurements/bulk-delete/__tests__/route.test.ts b/src/app/api/measurements/bulk-delete/__tests__/route.test.ts index c761efd55..55d976bc1 100644 --- a/src/app/api/measurements/bulk-delete/__tests__/route.test.ts +++ b/src/app/api/measurements/bulk-delete/__tests__/route.test.ts @@ -173,6 +173,15 @@ describe("POST /api/measurements/bulk-delete", () => { expect(res.status).toBe(422); }); + it("rejects a body over the 256 KB cap with 413 before parsing", async () => { + const res = await POST( + postReq({ ids: ["m1"], pad: "x".repeat(256 * 1024) }), + ); + expect(res.status).toBe(413); + expect(prisma.measurement.findMany).not.toHaveBeenCalled(); + expect(prisma.measurement.updateMany).not.toHaveBeenCalled(); + }); + it("429s when the rate limit is exceeded", async () => { vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never); const res = await POST(postReq({ ids: ["m1"] })); diff --git a/src/app/api/measurements/bulk-delete/route.ts b/src/app/api/measurements/bulk-delete/route.ts index 3494bd985..60a2a85f4 100644 --- a/src/app/api/measurements/bulk-delete/route.ts +++ b/src/app/api/measurements/bulk-delete/route.ts @@ -59,7 +59,9 @@ async function postBulkDelete(request: NextRequest): Promise { return apiError("Too many bulk deletions, try again later", 429); } - const { data: rawBody, error: jsonError } = await safeJson(request); + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 256 * 1024, + }); if (jsonError) return jsonError; const parsed = bulkDeleteSchema.safeParse(rawBody); diff --git a/src/app/api/measurements/by-external-ids/route.ts b/src/app/api/measurements/by-external-ids/route.ts index 1e25aa07e..9c07d6a53 100644 --- a/src/app/api/measurements/by-external-ids/route.ts +++ b/src/app/api/measurements/by-external-ids/route.ts @@ -55,7 +55,9 @@ export const DELETE = apiHandler(deleteByExternalIds); async function deleteByExternalIds(request: NextRequest): Promise { const { user } = await requireAuth(); - const { data: rawBody, error: jsonError } = await safeJson(request); + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 256 * 1024, + }); if (jsonError) return jsonError; // Distinguish "too many entries" from "validation failed" so the iOS diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index ac55d66a2..652da7bec 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -336,13 +336,7 @@ export const GET = apiHandler(async (request: NextRequest) => { // the live `date_trunc` path so a brand-new account whose // populator hasn't caught up still sees a correct chart on its // first render. - if ( - source === "rollup" && - aggregate === "daily" && - type && - from && - to - ) { + if (source === "rollup" && aggregate === "daily" && type && from && to) { const cap = Math.min(limit, BUCKET_CAP.daily); // v1.11.1 — rollup rows are per source; collapse overlapping sources to the // ladder-canonical reading per day before the chart consumes them, so a @@ -422,9 +416,7 @@ export const GET = apiHandler(async (request: NextRequest) => { // Cheap probe — one indexed range scan over // `(user_id, type, measured_at)`. Returns the count of distinct // calendar days the live table holds for the window. - const liveDayCountRows = await prisma.$queryRaw< - Array<{ days: bigint }> - >` + const liveDayCountRows = await prisma.$queryRaw>` SELECT COUNT(DISTINCT date_trunc('day', m."measured_at"))::bigint AS days FROM measurements m WHERE m."user_id" = ${user.id} @@ -504,9 +496,7 @@ export const GET = apiHandler(async (request: NextRequest) => { type != null && CUMULATIVE_HK_TYPES.has(type as MeasurementType); const measurements = effectiveRows.map((r) => ({ type: r.type, - value: useSum - ? (r.sumValue ?? r.mean * r.count) - : r.mean, + value: useSum ? (r.sumValue ?? r.mean * r.count) : r.mean, measuredAt: r.bucketStart.toISOString(), count: r.count, // v1.8.5 — per-day min / max so the chart can shade an @@ -847,7 +837,9 @@ export const POST = apiHandler(withIdempotency<[NextRequest]>(postMeasurement)); async function postMeasurement(request: NextRequest) { const { user } = await requireAuth(); - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; diff --git a/src/app/api/medications/[id]/api-endpoint/route.ts b/src/app/api/medications/[id]/api-endpoint/route.ts index 420d16c4c..d7736af97 100644 --- a/src/app/api/medications/[id]/api-endpoint/route.ts +++ b/src/app/api/medications/[id]/api-endpoint/route.ts @@ -74,7 +74,11 @@ export const PUT = apiHandler( let enabled = false; try { - const body = await request.json(); + const raw = await request.text(); + if (raw.length > 64 * 1024) { + return apiError(`Request body exceeds ${64 * 1024} bytes`, 413); + } + const body = JSON.parse(raw); enabled = body?.enabled === true; } catch { return apiError("Invalid request", 422); diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts index 07677f6a6..a951fc643 100644 --- a/src/app/api/medications/[id]/glp1/route.ts +++ b/src/app/api/medications/[id]/glp1/route.ts @@ -150,7 +150,9 @@ export const POST = apiHandler( }); } - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = glp1PostBodySchema.safeParse(body); diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index 0e1916ada..22996a426 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -40,7 +40,9 @@ export const PUT = apiHandler( return apiError("Intake not found", 404); } - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = updateIntakeEventSchema.safeParse(body); diff --git a/src/app/api/medications/[id]/intake/bulk-delete/route.ts b/src/app/api/medications/[id]/intake/bulk-delete/route.ts index 87bdf8c83..46732457d 100644 --- a/src/app/api/medications/[id]/intake/bulk-delete/route.ts +++ b/src/app/api/medications/[id]/intake/bulk-delete/route.ts @@ -77,7 +77,9 @@ export const POST = apiHandler( }); } - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 256 * 1024, + }); if (jsonError) return jsonError; const parsed = bulkDeleteIntakeEventsSchema.safeParse(body); if (!parsed.success) { diff --git a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts index 671a11a8b..b8199b8ab 100644 --- a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts @@ -71,6 +71,11 @@ beforeEach(() => { }); describe("POST /api/medications/[id]/intake/import — 422 multi-issue (v1.4.43 W6)", () => { + it("rejects a body over the 1 MB cap with 413 before parsing", async () => { + const res = await POST(postReq(["x".repeat(1024 * 1024)]), ROUTE_CTX); + expect(res.status).toBe(413); + }); + it("surfaces TWO simultaneous validation errors", async () => { // Two entries with malformed datum + malformed uhrzeit. const res = await POST( diff --git a/src/app/api/medications/[id]/intake/import/route.ts b/src/app/api/medications/[id]/intake/import/route.ts index d739cb365..0cff38b3f 100644 --- a/src/app/api/medications/[id]/intake/import/route.ts +++ b/src/app/api/medications/[id]/intake/import/route.ts @@ -37,7 +37,9 @@ export const POST = apiHandler( const guard = await assertMedicationOwnership(id, user.id); if (guard) return guard; - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 1024 * 1024, + }); if (jsonError) return jsonError; diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 538d1b66e..e201ebf22 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -44,7 +44,9 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { const guard = await assertMedicationOwnership(id, user.id); if (guard) return guard; - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = intakeSchema.safeParse({ @@ -126,9 +128,13 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { action: { name: "medication.intake.injection_site.disallowed" }, meta: { medication_id: id, site: resolution.site }, }); - return apiError("Injection site is not allowed for this medication", 422, { - errorCode: "medications.intake.injection_site.disallowed", - }); + return apiError( + "Injection site is not allowed for this medication", + 422, + { + errorCode: "medications.intake.injection_site.disallowed", + }, + ); } resolvedInjectionSite = resolution.site; } diff --git a/src/app/api/medications/[id]/inventory/[itemId]/route.ts b/src/app/api/medications/[id]/inventory/[itemId]/route.ts index f51bbee3d..25b7aa3b7 100644 --- a/src/app/api/medications/[id]/inventory/[itemId]/route.ts +++ b/src/app/api/medications/[id]/inventory/[itemId]/route.ts @@ -56,7 +56,9 @@ export const PATCH = apiHandler( const existing = await loadOwnedItem(id, itemId, user.id); if (!existing) return apiError("Inventory item not found", 404); - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = updateInventoryItemSchema.safeParse(body); diff --git a/src/app/api/medications/[id]/inventory/route.ts b/src/app/api/medications/[id]/inventory/route.ts index 193931644..41b3a5ebf 100644 --- a/src/app/api/medications/[id]/inventory/route.ts +++ b/src/app/api/medications/[id]/inventory/route.ts @@ -79,7 +79,9 @@ export const POST = apiHandler( }); } - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = createInventoryItemSchema.safeParse(body); diff --git a/src/app/api/medications/[id]/phase-config/route.ts b/src/app/api/medications/[id]/phase-config/route.ts index e4fbe243e..b8eada6c1 100644 --- a/src/app/api/medications/[id]/phase-config/route.ts +++ b/src/app/api/medications/[id]/phase-config/route.ts @@ -1,11 +1,7 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { - apiSuccess, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; +import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; import { phaseConfigSchema } from "@/lib/validations/phase-config"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { NextRequest } from "next/server"; @@ -58,7 +54,9 @@ export const PUT = apiHandler( const guard = await assertMedicationOwnership(id, user.id); if (guard) return guard; - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = phaseConfigSchema.safeParse(body); diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 2817baf87..c200acf77 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -49,21 +49,22 @@ export const GET = apiHandler( // cadences re-anchor on the latest non-skipped intake, so fetch it // once (no-op for calendar cadences but cheap + keeps the value // correct for rolling injections). - const lastIntake = - medication.schedules.some((s) => s.rollingIntervalDays !== null) - ? await prisma.medicationIntakeEvent.findFirst({ - // v1.7.0 sync — a tombstoned intake no longer anchors the - // rolling-interval next-due computation. - where: { - userId: user.id, - medicationId: id, - deletedAt: null, - takenAt: { not: null }, - }, - orderBy: { takenAt: "desc" }, - select: { takenAt: true }, - }) - : null; + const lastIntake = medication.schedules.some( + (s) => s.rollingIntervalDays !== null, + ) + ? await prisma.medicationIntakeEvent.findFirst({ + // v1.7.0 sync — a tombstoned intake no longer anchors the + // rolling-interval next-due computation. + where: { + userId: user.id, + medicationId: id, + deletedAt: null, + takenAt: { not: null }, + }, + orderBy: { takenAt: "desc" }, + select: { takenAt: true }, + }) + : null; // v1.15.10 — slots the user has already acted on near now, so the // next-due search skips them and advances to the next genuinely-open // slot. Bound the read to [now-1d, now+2d] — the lookahead only needs the @@ -136,7 +137,9 @@ export const PUT = apiHandler( return apiError("Medication not found", 404); } - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = updateMedicationSchema.safeParse(body); @@ -223,8 +226,7 @@ export const PUT = apiHandler( // Normalise endsOn for one-shot. `oneShot === true` + `startsOn` // means the dose is the start date; endsOn auto-matches. - const normalisedEndsOn = - oneShot === true && startsOn ? startsOn : endsOn; + const normalisedEndsOn = oneShot === true && startsOn ? startsOn : endsOn; const pausedAtPatch = active === undefined @@ -321,7 +323,9 @@ export const PUT = apiHandler( // v1.15.18 — per-dose configurable on-time windows. A `schedules` // replace re-creates the rows, so the column is re-written each // time; absent leaves it NULL (default ±1h derivation). - ...(s.doseWindows !== undefined && { doseWindows: s.doseWindows }), + ...(s.doseWindows !== undefined && { + doseWindows: s.doseWindows, + }), }; }), }, @@ -373,8 +377,7 @@ export const PUT = apiHandler( await prisma.medicationSchedule.update({ where: { id: primaryScheduleGracePatch.scheduleId }, data: { - reminderGraceMinutes: - primaryScheduleGracePatch.reminderGraceMinutes, + reminderGraceMinutes: primaryScheduleGracePatch.reminderGraceMinutes, }, }); const refreshed = await prisma.medication.findUnique({ diff --git a/src/app/api/medications/[id]/side-effects/route.ts b/src/app/api/medications/[id]/side-effects/route.ts index 755a5645e..899fa0067 100644 --- a/src/app/api/medications/[id]/side-effects/route.ts +++ b/src/app/api/medications/[id]/side-effects/route.ts @@ -111,7 +111,9 @@ export const POST = apiHandler( }); } - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = createSideEffectSchema.safeParse(body); diff --git a/src/app/api/medications/intake/bulk/__tests__/route.test.ts b/src/app/api/medications/intake/bulk/__tests__/route.test.ts index 69f8a9fda..5da4e8ce8 100644 --- a/src/app/api/medications/intake/bulk/__tests__/route.test.ts +++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts @@ -85,6 +85,13 @@ beforeEach(() => { }); describe("POST /api/medications/intake/bulk — 422 multi-issue (v1.4.43 W6)", () => { + it("rejects a body over the 2 MB cap with 413 before parsing", async () => { + const res = await POST( + postReq({ entries: [], pad: "x".repeat(2 * 1024 * 1024) }), + ); + expect(res.status).toBe(413); + }); + it("surfaces TWO simultaneous validation errors", async () => { const res = await POST( postReq({ @@ -319,7 +326,10 @@ describe("POST /api/medications/intake/bulk — v1.8.2 reconcile", () => { ); expect(res.status).toBe(200); const body = (await res.json()) as { - data: { updated: number; entries: Array<{ status: string; id?: string }> }; + data: { + updated: number; + entries: Array<{ status: string; id?: string }>; + }; }; // The dose is APPLIED to the existing row, not silently dropped. expect(body.data.updated).toBe(1); @@ -491,7 +501,10 @@ describe("POST /api/medications/intake/bulk — v1.15.19 resolver-null convergen ); expect(res.status).toBe(200); const body = (await res.json()) as { - data: { duplicates: number; entries: Array<{ status: string; id?: string }> }; + data: { + duplicates: number; + entries: Array<{ status: string; id?: string }>; + }; }; expect(body.data.duplicates).toBe(1); expect(body.data.entries[0].status).toBe("duplicate"); diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 08d2732b0..2bdbd06cd 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -112,7 +112,9 @@ async function postBulk(request: NextRequest): Promise { return apiError("Too many bulk submissions, try again later", 429); } - const { data: rawBody, error: jsonError } = await safeJson(request); + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 2 * 1024 * 1024, + }); if (jsonError) return jsonError; if ( @@ -207,7 +209,10 @@ async function postBulk(request: NextRequest): Promise { // touched by the batch so one rollup recompute fires per pair after // all inserts complete. Per-row recompute would balloon a 500-entry // batch into 500 sequential rollup hits. - const touchedDays = new Map(); + const touchedDays = new Map< + string, + { medicationId: string; dayKey: string } + >(); for (let i = 0; i < entries.length; i++) { const entry = entries[i]; @@ -409,7 +414,10 @@ async function postBulk(request: NextRequest): Promise { results.push({ index: i, status: "inserted", id: row.id }); } } - const dayKey = dayKeyForScheduledFor(effectiveScheduledFor, user.timezone); + const dayKey = dayKeyForScheduledFor( + effectiveScheduledFor, + user.timezone, + ); touchedDays.set(`${entry.medicationId}|${dayKey}`, { medicationId: entry.medicationId, dayKey, diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index b9f35fd7e..15548a6dc 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -277,7 +277,9 @@ async function buildScheduleAnchoredComplianceBuckets( const dayKeys: string[] = []; const dayBounds = new Map(); for (let i = days - 1; i >= 0; i--) { - const representative = new Date(nowMs - i * 86_400_000 - 12 * 60 * 60 * 1000); + const representative = new Date( + nowMs - i * 86_400_000 - 12 * 60 * 60 * 1000, + ); const { start: dayStart, end: dayEndInclusive } = getUserTodayBounds( representative, userTz, @@ -297,7 +299,12 @@ async function buildScheduleAnchoredComplianceBuckets( // Group events by medication so each med's engine context is built once. const eventsByMed = new Map< string, - { scheduledFor: Date; takenAt: Date | null; skipped: boolean; autoMissed: boolean }[] + { + scheduledFor: Date; + takenAt: Date | null; + skipped: boolean; + autoMissed: boolean; + }[] >(); for (const e of events) { const list = eventsByMed.get(e.medicationId) ?? []; @@ -363,7 +370,9 @@ async function buildScheduleAnchoredComplianceBuckets( export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const { data: body, error } = await safeJson(request); + const { data: body, error } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (error) return error; const parsed = updateSchema.safeParse(body); @@ -393,8 +402,14 @@ export const POST = apiHandler(async (request: NextRequest) => { return returnAllZodIssues(parsed.error, 422); } - const { intakeId, status, takenAt, snoozedUntil, injectionSite, forceSlotInstant } = - parsed.data; + const { + intakeId, + status, + takenAt, + snoozedUntil, + injectionSite, + forceSlotInstant, + } = parsed.data; // v1.7.0 sync — a tombstoned intake 404s on a status toggle; the // `deletedAt: null` filter refuses to mutate a soft-deleted row. @@ -428,8 +443,8 @@ export const POST = apiHandler(async (request: NextRequest) => { taken: status === "taken", deliveryForm: existing.medication.deliveryForm, trackInjectionSites: existing.medication.trackInjectionSites, - allowedInjectionSites: - existing.medication.allowedInjectionSites as InjectionSiteValue[], + allowedInjectionSites: existing.medication + .allowedInjectionSites as InjectionSiteValue[], globalExcludedInjectionSites: (userRow?.globalExcludedInjectionSites ?? []) as InjectionSiteValue[], }); @@ -438,9 +453,13 @@ export const POST = apiHandler(async (request: NextRequest) => { action: { name: "medication.intake.injection_site.disallowed" }, meta: { medication_id: existing.medicationId, site: resolution.site }, }); - return apiError("Injection site is not allowed for this medication", 422, { - errorCode: "medications.intake.injection_site.disallowed", - }); + return apiError( + "Injection site is not allowed for this medication", + 422, + { + errorCode: "medications.intake.injection_site.disallowed", + }, + ); } resolvedInjectionSite = resolution.site; } diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 8e4f77835..e9ec1fdf4 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -38,7 +38,9 @@ async function buildMedicationsList( // today-end + 2d] — the next-due lookahead only needs the slots adjacent to // now, and a tight window keeps the read cheap. The 60s list cache covers // the rest. - const resolvedWindowEnd = new Date(todayEndUtc.getTime() + 2 * 24 * 60 * 60 * 1000); + const resolvedWindowEnd = new Date( + todayEndUtc.getTime() + 2 * 24 * 60 * 60 * 1000, + ); const [medications, latestIntakes, todayEvents, resolvedEvents] = await Promise.all([ @@ -174,7 +176,9 @@ export const GET = apiHandler(async () => { export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = createMedicationSchema.safeParse(body); @@ -319,7 +323,9 @@ export const POST = apiHandler(async (request: NextRequest) => { rollingIntervalDays: s.rollingIntervalDays, }), // v1.7.0 — schedule type + cyclic weeks, field-by-field. - ...(s.scheduleType !== undefined && { scheduleType: s.scheduleType }), + ...(s.scheduleType !== undefined && { + scheduleType: s.scheduleType, + }), ...(s.cyclicOnWeeks !== undefined && { cyclicOnWeeks: s.cyclicOnWeeks, }), diff --git a/src/app/api/monitoring/glitchtip/route.ts b/src/app/api/monitoring/glitchtip/route.ts index e3a720d4a..c83a7596f 100644 --- a/src/app/api/monitoring/glitchtip/route.ts +++ b/src/app/api/monitoring/glitchtip/route.ts @@ -30,7 +30,11 @@ export const POST = apiHandler(async (request: NextRequest) => { let body: unknown; try { - body = await request.json(); + const raw = await request.text(); + if (raw.length > 256 * 1024) { + return apiError(`Request body exceeds ${256 * 1024} bytes`, 413); + } + body = JSON.parse(raw); } catch { return apiError("Invalid JSON data", 422); } diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index 23b61e8ea..0bb45c30f 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -73,7 +73,9 @@ export const PUT = apiHandler( return apiError("Mood entry not found", 404); } - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = updateMoodEntrySchema.safeParse(body); diff --git a/src/app/api/mood-entries/bulk-delete/route.ts b/src/app/api/mood-entries/bulk-delete/route.ts index 9c61322f2..4f0616be3 100644 --- a/src/app/api/mood-entries/bulk-delete/route.ts +++ b/src/app/api/mood-entries/bulk-delete/route.ts @@ -54,7 +54,9 @@ async function postBulkDelete(request: NextRequest): Promise { return apiError("Too many bulk deletions, try again later", 429); } - const { data: rawBody, error: jsonError } = await safeJson(request); + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 256 * 1024, + }); if (jsonError) return jsonError; const parsed = bulkDeleteSchema.safeParse(rawBody); diff --git a/src/app/api/mood-entries/bulk/__tests__/route.test.ts b/src/app/api/mood-entries/bulk/__tests__/route.test.ts index c9ec4b489..1946d171e 100644 --- a/src/app/api/mood-entries/bulk/__tests__/route.test.ts +++ b/src/app/api/mood-entries/bulk/__tests__/route.test.ts @@ -101,6 +101,13 @@ beforeEach(() => { }); describe("POST /api/mood-entries/bulk — 422 multi-issue (v1.4.43 W6)", () => { + it("rejects a body over the 2 MB cap with 413 before parsing", async () => { + const res = await POST( + postReq({ entries: [], pad: "x".repeat(2 * 1024 * 1024) }), + ); + expect(res.status).toBe(413); + }); + it("surfaces TWO simultaneous validation errors", async () => { const res = await POST( postReq({ diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index 8679e847d..fddbfa944 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -78,7 +78,10 @@ const bulkEntrySchema = z.object({ */ ratedFactors: z .array( - z.object({ key: z.string().max(60), rating: z.number().int().min(1).max(5) }), + z.object({ + key: z.string().max(60), + rating: z.number().int().min(1).max(5), + }), ) .max(30) .optional(), @@ -126,7 +129,9 @@ async function postBulk(request: NextRequest): Promise { return apiError("Too many bulk submissions, try again later", 429); } - const { data: rawBody, error: jsonError } = await safeJson(request); + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 2 * 1024 * 1024, + }); if (jsonError) return jsonError; if ( diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index e24894ab6..655c062c3 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -130,7 +130,10 @@ export const GET = apiHandler(async (request: NextRequest) => { // form re-renders the sliders without a refetch. ratedFactors: tagLinks .filter((link) => link.moodTag.kind === "RATED" && link.rating !== null) - .map((link) => ({ key: link.moodTag.key, rating: link.rating as number })), + .map((link) => ({ + key: link.moodTag.key, + rating: link.rating as number, + })), })); return apiSuccess({ @@ -144,7 +147,9 @@ export const POST = apiHandler(withIdempotency<[NextRequest]>(postMoodEntry)); async function postMoodEntry(request: NextRequest) { const { user } = await requireAuth(); - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = createMoodEntrySchema.safeParse(body); diff --git a/src/app/api/mood/tags/[key]/hidden/route.ts b/src/app/api/mood/tags/[key]/hidden/route.ts index f1929bea5..fb7dc4d6a 100644 --- a/src/app/api/mood/tags/[key]/hidden/route.ts +++ b/src/app/api/mood/tags/[key]/hidden/route.ts @@ -1,7 +1,12 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response"; +import { + apiSuccess, + apiError, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { hideCatalogueTagSchema, isCustomTagKey } from "@/lib/mood/custom-tags"; @@ -29,7 +34,11 @@ export const PUT = apiHandler( ); } - const parsed = hideCatalogueTagSchema.safeParse(await request.json()); + const { data: rawJsonBody, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + const parsed = hideCatalogueTagSchema.safeParse(rawJsonBody); if (!parsed.success) return returnAllZodIssues(parsed.error, 422); // Resolve against the catalogue (user_id NULL) only. diff --git a/src/app/api/mood/tags/custom/[key]/route.ts b/src/app/api/mood/tags/custom/[key]/route.ts index c8dd7b9cf..697489bbf 100644 --- a/src/app/api/mood/tags/custom/[key]/route.ts +++ b/src/app/api/mood/tags/custom/[key]/route.ts @@ -1,7 +1,12 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response"; +import { + apiSuccess, + apiError, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { @@ -30,7 +35,11 @@ export const PATCH = apiHandler( const { key } = await params; if (!isCustomTagKey(key)) return apiError("Not a custom tag", 404); - const parsed = updateCustomTagSchema.safeParse(await request.json()); + const { data: rawJsonBody, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + const parsed = updateCustomTagSchema.safeParse(rawJsonBody); if (!parsed.success) return returnAllZodIssues(parsed.error, 422); const owned = await prisma.moodTag.findFirst({ diff --git a/src/app/api/mood/tags/custom/route.ts b/src/app/api/mood/tags/custom/route.ts index 2736816c8..d5cf6d30e 100644 --- a/src/app/api/mood/tags/custom/route.ts +++ b/src/app/api/mood/tags/custom/route.ts @@ -1,7 +1,12 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response"; +import { + apiSuccess, + apiError, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { @@ -24,7 +29,11 @@ export const dynamic = "force-dynamic"; export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const parsed = createCustomTagSchema.safeParse(await request.json()); + const { data: rawJsonBody, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + const parsed = createCustomTagSchema.safeParse(rawJsonBody); if (!parsed.success) return returnAllZodIssues(parsed.error, 422); const activeCount = await prisma.moodTag.count({ @@ -52,10 +61,20 @@ export const POST = apiHandler(async (request: NextRequest) => { sortOrder: activeCount, userId: user.id, }, - select: { key: true, icon: true, kind: true, scaleMin: true, scaleMax: true, inverse: true }, + select: { + key: true, + icon: true, + kind: true, + scaleMin: true, + scaleMax: true, + inverse: true, + }, }); - annotate({ action: { name: "mood.tag.custom.create" }, meta: { icon: created.icon } }); + annotate({ + action: { name: "mood.tag.custom.create" }, + meta: { icon: created.icon }, + }); return apiSuccess( { diff --git a/src/app/api/notifications/preferences/route.ts b/src/app/api/notifications/preferences/route.ts index 4db1d5e84..9b98b2ca4 100644 --- a/src/app/api/notifications/preferences/route.ts +++ b/src/app/api/notifications/preferences/route.ts @@ -96,7 +96,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { let body: unknown; try { - body = await request.json(); + const raw = await request.text(); + if (raw.length > 64 * 1024) { + return apiError(`Request body exceeds ${64 * 1024} bytes`, 413); + } + body = JSON.parse(raw); } catch { return apiError("Invalid JSON data", 422); } diff --git a/src/app/api/notifications/status/route.ts b/src/app/api/notifications/status/route.ts index 39085a3c5..402521ee7 100644 --- a/src/app/api/notifications/status/route.ts +++ b/src/app/api/notifications/status/route.ts @@ -150,7 +150,11 @@ export const POST = apiHandler(async (request: NextRequest) => { let body: unknown; try { - body = await request.json(); + const raw = await request.text(); + if (raw.length > 64 * 1024) { + return apiError(`Request body exceeds ${64 * 1024} bytes`, 413); + } + body = JSON.parse(raw); } catch { return apiError("Invalid JSON data", 422); } diff --git a/src/app/api/notifications/web-push/route.ts b/src/app/api/notifications/web-push/route.ts index 45ca81ba5..ccc2d07ef 100644 --- a/src/app/api/notifications/web-push/route.ts +++ b/src/app/api/notifications/web-push/route.ts @@ -28,7 +28,11 @@ export const POST = apiHandler(async (request: NextRequest) => { let body: unknown; try { - body = await request.json(); + const raw = await request.text(); + if (raw.length > 64 * 1024) { + return apiError(`Request body exceeds ${64 * 1024} bytes`, 413); + } + body = JSON.parse(raw); } catch { return apiError("Invalid JSON data", 422); } @@ -90,7 +94,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => { let body: unknown; try { - body = await request.json(); + const raw = await request.text(); + if (raw.length > 64 * 1024) { + return apiError(`Request body exceeds ${64 * 1024} bytes`, 413); + } + body = JSON.parse(raw); } catch { return apiError("Invalid JSON data", 422); } diff --git a/src/app/api/onboarding/complete/route.ts b/src/app/api/onboarding/complete/route.ts index bef7c65d3..a6542a530 100644 --- a/src/app/api/onboarding/complete/route.ts +++ b/src/app/api/onboarding/complete/route.ts @@ -21,7 +21,9 @@ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "onboarding.complete" } }); - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const result = z.safeParse(onboardingSchema, body); diff --git a/src/app/api/onboarding/step/route.ts b/src/app/api/onboarding/step/route.ts index 47a9a22f0..50e73f716 100644 --- a/src/app/api/onboarding/step/route.ts +++ b/src/app/api/onboarding/step/route.ts @@ -61,7 +61,9 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Too many onboarding writes, try again later", 429); } - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = stepBodySchema.safeParse(body); @@ -102,10 +104,7 @@ export const POST = apiHandler(async (request: NextRequest) => { action: { name: "onboarding.step" }, meta: { outcome: "out_of_order", current, requested: step }, }); - return apiError( - `Step out of order — current step is ${current}`, - 409, - ); + return apiError(`Step out of order — current step is ${current}`, 409); } const completing = step === 4; diff --git a/src/app/api/onboarding/tour/route.ts b/src/app/api/onboarding/tour/route.ts index a219aee23..9e9b72a4a 100644 --- a/src/app/api/onboarding/tour/route.ts +++ b/src/app/api/onboarding/tour/route.ts @@ -33,7 +33,9 @@ const tourBodySchema = z.object({ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = z.safeParse(tourBodySchema, body); diff --git a/src/app/api/settings/account/route.ts b/src/app/api/settings/account/route.ts index 7a9ef3213..d5f0d3da3 100644 --- a/src/app/api/settings/account/route.ts +++ b/src/app/api/settings/account/route.ts @@ -17,7 +17,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => { let confirm = ""; try { - const body = await request.json(); + const raw = await request.text(); + if (raw.length > 64 * 1024) { + return apiError(`Request body exceeds ${64 * 1024} bytes`, 413); + } + const body = JSON.parse(raw); confirm = typeof body?.confirm === "string" ? body.confirm : ""; } catch { return apiError("Invalid request", 422); diff --git a/src/app/api/settings/data/route.ts b/src/app/api/settings/data/route.ts index d2758f0cd..71235937e 100644 --- a/src/app/api/settings/data/route.ts +++ b/src/app/api/settings/data/route.ts @@ -15,7 +15,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => { let confirm = ""; try { - const body = await request.json(); + const raw = await request.text(); + if (raw.length > 64 * 1024) { + return apiError(`Request body exceeds ${64 * 1024} bytes`, 413); + } + const body = JSON.parse(raw); confirm = typeof body?.confirm === "string" ? body.confirm : ""; } catch { return apiError("Invalid request", 422); diff --git a/src/app/api/settings/moodlog/route.ts b/src/app/api/settings/moodlog/route.ts index 5f17e6dc0..274a757cc 100644 --- a/src/app/api/settings/moodlog/route.ts +++ b/src/app/api/settings/moodlog/route.ts @@ -17,7 +17,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { let body: unknown; try { - body = await request.json(); + const raw = await request.text(); + if (raw.length > 64 * 1024) { + return apiError(`Request body exceeds ${64 * 1024} bytes`, 413); + } + body = JSON.parse(raw); } catch { return apiError("Invalid JSON", 400); } diff --git a/src/app/api/settings/ntfy/route.ts b/src/app/api/settings/ntfy/route.ts index 44f10c6f5..0bef16832 100644 --- a/src/app/api/settings/ntfy/route.ts +++ b/src/app/api/settings/ntfy/route.ts @@ -47,7 +47,9 @@ export const GET = apiHandler(async () => { export const PUT = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = ntfySettingsSchema.safeParse(body); diff --git a/src/app/api/settings/telegram/route.ts b/src/app/api/settings/telegram/route.ts index ec99a1222..697e41d02 100644 --- a/src/app/api/settings/telegram/route.ts +++ b/src/app/api/settings/telegram/route.ts @@ -49,7 +49,9 @@ export const PUT = apiHandler(async (request: NextRequest) => { }); if (!current) return apiError("User not found", 404); - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const result = z.safeParse(telegramSettingsSchema, body); diff --git a/src/app/api/telegram/webhook/route.ts b/src/app/api/telegram/webhook/route.ts index 03ac91845..b492fa489 100644 --- a/src/app/api/telegram/webhook/route.ts +++ b/src/app/api/telegram/webhook/route.ts @@ -684,7 +684,13 @@ export const POST = apiHandler(async (request: NextRequest) => { let update: TelegramUpdate; try { - update = (await request.json()) as TelegramUpdate; + const raw = await request.text(); + if (raw.length > 256 * 1024) { + // Oversized payload — acknowledge with 200 like invalid JSON so the + // sender does not retry-loop the update. + return NextResponse.json({ status: "invalid json" }, { status: 200 }); + } + update = JSON.parse(raw) as TelegramUpdate; } catch { return NextResponse.json({ status: "invalid json" }, { status: 200 }); } diff --git a/src/app/api/tokens/route.ts b/src/app/api/tokens/route.ts index c8a2a0f9b..cde3bc73a 100644 --- a/src/app/api/tokens/route.ts +++ b/src/app/api/tokens/route.ts @@ -52,7 +52,9 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("API is globally disabled", 403); } - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = createTokenSchema.safeParse(body); diff --git a/src/app/api/user/ai-provider/route.ts b/src/app/api/user/ai-provider/route.ts index cbd63f9a5..2134c6e84 100644 --- a/src/app/api/user/ai-provider/route.ts +++ b/src/app/api/user/ai-provider/route.ts @@ -46,8 +46,10 @@ export const PATCH = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "user.ai-provider.update" } }); - const { data: body, error } = - await safeJson>(request); + const { data: body, error } = await safeJson>( + request, + { maxBytes: 64 * 1024 }, + ); if (error) return error; const updates: Record = {}; diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index 6dd2ed943..1722858a5 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -75,7 +75,9 @@ export const GET = apiHandler(async () => { export const PATCH = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - const { data: body, error } = await safeJson(request); + const { data: body, error } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (error) return error; const result = await applyProfileUpdate(user.id, body, getClientIp(request)); diff --git a/src/app/api/user/thresholds/route.ts b/src/app/api/user/thresholds/route.ts index bf353dc92..c13eb52b0 100644 --- a/src/app/api/user/thresholds/route.ts +++ b/src/app/api/user/thresholds/route.ts @@ -76,7 +76,9 @@ export const PUT = apiHandler(async (request: NextRequest) => { return apiError("Too many requests, please slow down", 429); } - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const parsed = thresholdsUpdateSchema.safeParse(body); diff --git a/src/app/api/whoop/credentials/route.ts b/src/app/api/whoop/credentials/route.ts index 645287c2f..618d87405 100644 --- a/src/app/api/whoop/credentials/route.ts +++ b/src/app/api/whoop/credentials/route.ts @@ -35,7 +35,9 @@ export const PUT = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "whoop.credentials.update" } }); - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const result = z.safeParse(whoopCredentialsSchema, body); diff --git a/src/app/api/whoop/sync/route.ts b/src/app/api/whoop/sync/route.ts index da912d4a0..17d069ad4 100644 --- a/src/app/api/whoop/sync/route.ts +++ b/src/app/api/whoop/sync/route.ts @@ -1,5 +1,5 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess } from "@/lib/api-response"; +import { apiSuccess, apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { syncUserWhoop } from "@/lib/whoop/sync"; import { NextRequest } from "next/server"; @@ -16,7 +16,12 @@ export const POST = apiHandler(async (request: NextRequest) => { let fullSync = false; try { - const body = await request.json(); + const raw = await request.text(); + // Flag-only payload — cap the parse cost (mirrors safeJson maxBytes). + if (raw.length > 64 * 1024) { + return apiError(`Request body exceeds ${64 * 1024} bytes`, 413); + } + const body = JSON.parse(raw); fullSync = body?.fullSync === true; } catch { // no body provided -> default incremental sync diff --git a/src/app/api/whoop/webhook/[token]/route.ts b/src/app/api/whoop/webhook/[token]/route.ts index 9c6dcb691..f3e411b9d 100644 --- a/src/app/api/whoop/webhook/[token]/route.ts +++ b/src/app/api/whoop/webhook/[token]/route.ts @@ -27,9 +27,7 @@ interface RouteContext { params: Promise<{ token: string }>; } -async function verifyTokenSegment( - token: string | undefined, -): Promise { +async function verifyTokenSegment(token: string | undefined): Promise { const expected = process.env.WHOOP_WEBHOOK_SECRET; if (!expected) { getEvent()?.addWarning("WHOOP_WEBHOOK_SECRET not configured"); @@ -57,6 +55,14 @@ export const POST = apiHandler( // exactly once — `request.json()` would consume it and defeat the // signature check. const rawBody = await request.text(); + if (rawBody.length > 256 * 1024) { + // Cap the body before the HMAC walk and JSON.parse — legitimate + // webhook payloads are tiny. + return NextResponse.json( + { status: "payload_too_large" }, + { status: 413 }, + ); + } const secret = process.env.WHOOP_WEBHOOK_SECRET; if ( !secret || diff --git a/src/app/api/withings/credentials/route.ts b/src/app/api/withings/credentials/route.ts index f59323a57..0bd669f86 100644 --- a/src/app/api/withings/credentials/route.ts +++ b/src/app/api/withings/credentials/route.ts @@ -36,7 +36,9 @@ export const PUT = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "withings.credentials.update" } }); - const { data: body, error: jsonError } = await safeJson(request); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); if (jsonError) return jsonError; const result = z.safeParse(withingsCredentialsSchema, body); diff --git a/src/app/api/withings/sync/route.ts b/src/app/api/withings/sync/route.ts index fd93fa800..f881d62d8 100644 --- a/src/app/api/withings/sync/route.ts +++ b/src/app/api/withings/sync/route.ts @@ -1,5 +1,5 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess } from "@/lib/api-response"; +import { apiSuccess, apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { syncUserMeasurements } from "@/lib/withings/sync"; import { NextRequest } from "next/server"; @@ -13,7 +13,12 @@ export const POST = apiHandler(async (request: NextRequest) => { let fullSync = false; try { - const body = await request.json(); + const raw = await request.text(); + // Flag-only payload — cap the parse cost (mirrors safeJson maxBytes). + if (raw.length > 64 * 1024) { + return apiError(`Request body exceeds ${64 * 1024} bytes`, 413); + } + const body = JSON.parse(raw); fullSync = body?.fullSync === true; } catch { // no body provided -> default incremental sync