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