Gate 5 — Quality & Security Audit (ticket #3273, forgot-password OTP flow) =========================================================================== Run date: 2026-09-08 Feature files audited: app/(auth)/forgot-password/page.tsx app/(auth)/forgot-password/verify/page.tsx app/(auth)/forgot-password/reset/page.tsx components/auth/ForgotPasswordForm.tsx components/auth/VerifyOtpForm.tsx components/auth/ResetPasswordForm.tsx components/auth/SignInForm.tsx Supporting files read (for context/cross-reference): lib/actions/forgot-password.ts lib/actions/auth.ts lib/actions/shared.ts lib/auth/flowToken.ts lib/validation/schemas.ts (forgot-password + passwordPolicy sections) middleware.ts next.config.mjs app/(auth)/layout.tsx app/(auth)/login/page.tsx components/auth/BrandPanel.tsx components/forms/ResetPasswordForm.tsx (existing profile-section variant) components/ui/fields/PasswordField.tsx components/ui/SubmitButton.tsx Skill resolution (per gate spec): 1. nextjs-tailwind-code-quality-audit — read via file /opt/drcarrot-deploy/cross-repo-orchestration/skills/nextjs-tailwind-code-quality-audit/SKILL.md 2. nextjs-tailwind-dry-architecture-audit — read via file /opt/drcarrot-deploy/cross-repo-orchestration/skills/nextjs-tailwind-dry-architecture-audit/SKILL.md (NOTE: file content partially garbled/corrupted after line ~20; audit conducted using the readable header description + standard DRY analysis principles) 3. nextjs-tailwind-css-dry-architecture-audit — read via file /opt/drcarrot-deploy/cross-repo-orchestration/skills/nextjs-tailwind-css-dry-architecture-audit/SKILL.md 4. nextjs-tailwind-security-audit — read via file /opt/drcarrot-deploy/cross-repo-orchestration/skills/nextjs-tailwind-security-audit/SKILL.md ═══════════════════════════════════════════════════════════════════════════ FINDING 1 — [security] Missing failed-attempt counter on OTP verification ═══════════════════════════════════════════════════════════════════════════ File: lib/actions/forgot-password.ts Location: verifyResetOtp (lines 231-287) Category: auth / input-validation Priority: P2 (Medium) Fixed: false Problem: verifyResetOtp does not track or limit the number of incorrect OTP submissions against a given OTP record. The resendOtp action correctly caps resend attempts at OTP_MAX_ATTEMPTS (5), but the verification path itself has no failed-attempt counter. Within the 10-minute OTP window, a caller holding the fp_otp_token cookie can submit unlimited guesses. Attack scenario: 1. Attacker initiates the flow for a target mobile (they just need the number). 2. requestPasswordResetOtp returns the same shape for both registered and unregistered numbers (anti-enumeration is correct), but if the number IS registered, an OTP is created and the attacker receives the fp_otp_token cookie. 3. Attacker scripts rapid POST requests to the verifyResetOtp Server Action with sequential 6-digit codes. At ~100ms/request, a single client makes ~6,000 attempts in 10 minutes → ~0.6% success probability. A distributed attacker (botnet) at 1,000 req/s reaches ~600,000 attempts → ~45% success. 4. On a correct guess, the attacker receives fp_reset_token and can set a new password, taking over the account. Evidence: verifyResetOtp (lines 245-259) — on hash mismatch, returns a field error but does NOT increment any counter or lock the OTP record: ``` const submittedHash = await hashOtp(otp); if (submittedHash !== otpRecord.otpHash) { return { ok: false, fieldErrors: { otp: "Invalid code. ..." } }; } ``` Impact: Account takeover for any user whose mobile number the attacker knows. Recommended remediation: Add a `verifyAttempts` field to the passwordResetOtp model (or reuse the existing `attempts` field with a separate cap). Increment on each failed verification, and reject with an expiry-style message after N failures (e.g., 5). This mirrors the existing resendOtp rate limit. Risk of change: Low — adds a DB column and an increment+check. Does not change the happy path. Requires a Prisma migration. ═══════════════════════════════════════════════════════════════════════════ FINDING 2 — [dry-architecture] RULES + STRENGTH_LABELS arrays duplicated ═══════════════════════════════════════════════════════════════════════════ File: components/auth/ResetPasswordForm.tsx (lines 31-39) + components/forms/ResetPasswordForm.tsx (lines 23-31) Category: dup-logic Priority: P2 Fixed: false Problem: The RULES array (5 password policy rules with label+test) and STRENGTH_LABELS array are byte-for-byte identical between the new auth-flow ResetPasswordForm and the existing profile-section ResetPasswordForm. The code itself acknowledges the copy (line 29: "Strength computation and RULES array copied from the existing form"). Both represent the same concept: the client-side password policy checklist that mirrors lib/validation/schemas.ts's passwordPolicy Zod schema. Evidence: auth/ResetPasswordForm.tsx lines 31-39: const RULES: { label: string; test: (v: string) => boolean }[] = [ { label: "Minimum 8 characters", test: (v) => v.length >= 8 }, { label: "1 uppercase letter", test: (v) => /[A-Z]/.test(v) }, ... ]; const STRENGTH_LABELS = ["Weak", "Fair", "Good", "Strong"]; forms/ResetPasswordForm.tsx lines 23-31: const RULES: { label: string; test: (v: string) => boolean }[] = [ { label: "Minimum 8 characters", test: (v) => v.length >= 8 }, { label: "1 uppercase letter", test: (v) => /[A-Z]/.test(v) }, ... ]; const STRENGTH_LABELS = ["Weak", "Fair", "Good", "Strong"]; Why these are the same concept: Both enforce and display the same password policy. A policy change (e.g. adding a "no common passwords" rule) must update both or they drift, silently showing different rules on two screens. Recommended fix: Extract to a shared module, e.g. lib/validation/password-rules.ts exporting RULES and STRENGTH_LABELS. Both components import from it. Risk of change: None — pure extraction, no behavioral change. ═══════════════════════════════════════════════════════════════════════════ FINDING 3 — [dry-architecture] Strength meter computation logic duplicated ═══════════════════════════════════════════════════════════════════════════ File: components/auth/ResetPasswordForm.tsx (lines 49-57) + components/forms/ResetPasswordForm.tsx (lines 47-54) Category: dup-logic Priority: P2 Fixed: false Problem: The strength bar computation (rulesMet, metCount, strengthBars, strengthLabel) is identical between the two ResetPasswordForm variants: const rulesMet = useMemo(() => RULES.map((r) => r.test(newPassword)), [newPassword]); const policyOk = rulesMet.every(Boolean); const matchOk = newPassword.length > 0 && newPassword === confirmPassword; const canSubmit = policyOk && matchOk; const metCount = rulesMet.filter(Boolean).length; const strengthBars = newPassword.length === 0 ? 0 : Math.max(1, Math.ceil(...)); const strengthLabel = STRENGTH_LABELS[Math.max(0, strengthBars - 1)]; Why these are the same concept: This is the same password-strength UI computation. Any UX change (different strength tiers, different labels) must update both. Recommended fix: Extract a custom hook, e.g. usePasswordStrength(password), returning { rulesMet, policyOk, strengthBars, strengthLabel }. Risk of change: None — pure extraction, no behavioral change. ═══════════════════════════════════════════════════════════════════════════ FINDING 4 — [css-dry] Auth shell wrapper duplicated 4 times ═══════════════════════════════════════════════════════════════════════════ Files: app/(auth)/login/page.tsx (line 6) components/auth/ForgotPasswordForm.tsx (line 24) components/auth/VerifyOtpForm.tsx (line 100) components/auth/ResetPasswordForm.tsx (line 60) Concept: Auth page shell (full-screen two-panel layout) Priority: P2 Fixed: false Evidence — the same class combination in all 4 locations:
{/* form content */}
Minor drift: ResetPasswordForm's inner div adds `overflow-auto py-10` to handle its taller form content. Why these are the same concept: Every auth-flow page (login, forgot-password ×3) renders an identical shell: fixed fullscreen overlay, BrandPanel on the left (45%), centred form column on the right. A design change to this shell (e.g. background, z-index, BrandPanel width) must touch all 4 files. Recommended primitive: An AuthShell component: {children} The `scrollable` prop covers the ResetPasswordForm's overflow-auto variant. Risk of change: Low — each call site becomes {form}. Visual output unchanged. The login page.tsx version (server component) would need AuthShell to be a server component too (it has no client-side state), or the page can accept being wrapped by a client AuthShell since its content (SignInForm) is already a client component. ═══════════════════════════════════════════════════════════════════════════ FINDING 5 — [css-dry] Back-navigation link pattern duplicated ═══════════════════════════════════════════════════════════════════════════ Files: components/auth/ForgotPasswordForm.tsx (lines 28-34) components/auth/VerifyOtpForm.tsx (lines 107-113) Concept: Back-navigation link in auth flow Priority: P3 Fixed: false Evidence: ForgotPasswordForm: