diff --git a/src/app/api/auth/me/mfa/disable/route.ts b/src/app/api/auth/me/mfa/disable/route.ts index 24a266288..9595de572 100644 --- a/src/app/api/auth/me/mfa/disable/route.ts +++ b/src/app/api/auth/me/mfa/disable/route.ts @@ -16,7 +16,12 @@ import { HttpError, MFA_STEP_UP_MAX_AGE_SECONDS, } from "@/lib/api-handler"; -import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + getClientIp, + safeJson, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -47,10 +52,10 @@ export const POST = apiHandler(async (req: Request) => { return res; } - let body: unknown; - try { - body = await req.json(); - } catch { + const { data: body, error: jsonError } = await safeJson(req, { + maxBytes: 4096, + }); + if (jsonError) { throw new HttpError(400, "Invalid JSON body"); } const parsed = mfaDisableSchema.safeParse(body); diff --git a/src/app/api/auth/me/mfa/totp/confirm/route.ts b/src/app/api/auth/me/mfa/totp/confirm/route.ts index 861c7c7a3..840e48fa7 100644 --- a/src/app/api/auth/me/mfa/totp/confirm/route.ts +++ b/src/app/api/auth/me/mfa/totp/confirm/route.ts @@ -13,7 +13,12 @@ * Cookie-only (`requireCookieAuth`): an API token can never enrol MFA. */ import { apiHandler, requireCookieAuth, HttpError } from "@/lib/api-handler"; -import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + getClientIp, + safeJson, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -54,10 +59,10 @@ export const POST = apiHandler(async (req: Request) => { throw new HttpError(409, "Start enrollment before confirming"); } - let body: unknown; - try { - body = await req.json(); - } catch { + const { data: body, error: jsonError } = await safeJson(req, { + maxBytes: 4096, + }); + if (jsonError) { throw new HttpError(400, "Invalid JSON body"); } const parsed = totpConfirmSchema.safeParse(body); diff --git a/src/app/api/auth/mfa/verify/route.ts b/src/app/api/auth/mfa/verify/route.ts index adf7dfde8..4e0829821 100644 --- a/src/app/api/auth/mfa/verify/route.ts +++ b/src/app/api/auth/mfa/verify/route.ts @@ -70,6 +70,14 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Invalid or expired challenge", 401); } + // Only a login-issued challenge may be redeemed for a session here. A future + // step-up or webauthn challenge kind must never mint a login session through + // this endpoint, so reject anything but "login" with the same generic 401. + if (challenge.kind !== "login") { + annotate({ action: { name: "auth.mfa.verify.invalid_ticket" } }); + return apiError("Invalid or expired challenge", 401); + } + const user = await prisma.user.findUnique({ where: { id: challenge.userId }, select: { diff --git a/src/lib/auth/mfa/recovery-codes.ts b/src/lib/auth/mfa/recovery-codes.ts index 6e9caa54e..8d73dbe9d 100644 --- a/src/lib/auth/mfa/recovery-codes.ts +++ b/src/lib/auth/mfa/recovery-codes.ts @@ -117,8 +117,9 @@ export async function verifyAndConsumeRecoveryCode( }); for (const row of rows) { - // Argon2id verify is constant-time; iterate the full set so the match - // position is not observable through timing. + // Argon2id verify is constant-time per code. A match returns early (the 200 + // already reveals validity, so match-position timing leaks nothing useful); + // a miss scans on, so a wrong code never reveals how many remain. const match = await verifyPassword(row.codeHash, normalised); if (!match) continue;