diff --git a/backend/controllers/authController.js b/backend/controllers/authController.js index 56f2665d..20ba5000 100644 --- a/backend/controllers/authController.js +++ b/backend/controllers/authController.js @@ -366,14 +366,15 @@ const resendVerificationEmail = async (req, res) => { const user = await User.findOne({ email: email.trim().toLowerCase() }); - // Return success even if user not found — avoids exposing which emails are registered - if (!user) { - return res.json({ success: true, message: "If this email is registered, a verification link has been sent." }); - } + // Generic message returned for every non-validation outcome so that an + // attacker cannot infer whether an email is registered or already + // verified (prevents email enumeration). A link is only actually sent + // when the account exists and is still unverified. + const genericMessage = + "If this email is registered and unverified, a verification link has been sent."; - // If already verified, no need to resend - if (user.isEmailVerified) { - return res.status(400).json({ success: false, message: "This email is already verified. Please log in." }); + if (!user || user.isEmailVerified) { + return res.json({ success: true, message: genericMessage }); } // Generate a fresh token and reset expiry to 24 hours from now @@ -384,7 +385,7 @@ const resendVerificationEmail = async (req, res) => { const verificationUrl = `${process.env.FRONTEND_URL}/verify-email?token=${user.emailVerificationToken}`; await sendVerificationEmail(user.email, verificationUrl); - res.json({ success: true, message: "Verification email resent. Please check your inbox." }); + res.json({ success: true, message: genericMessage }); } catch (error) { console.error("Resend verification error:", error); res.status(500).json({ success: false, message: "Internal server error occurred" }); diff --git a/backend/tests/authController.resendVerification.enumeration.unit.test.js b/backend/tests/authController.resendVerification.enumeration.unit.test.js new file mode 100644 index 00000000..40324386 --- /dev/null +++ b/backend/tests/authController.resendVerification.enumeration.unit.test.js @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import express from "express"; +import request from "supertest"; + +// Auto-mock the User model (same pattern as the repo's +// interviewExperienceController test: auto-mock + overwrite findOne). The email +// subsystem is stubbed by patching nodemailer.createTransport BEFORE the +// controller loads, so sendEmail.js captures a fake transporter whose sendMail +// is a spy — no real SMTP connection is attempted. +vi.mock("../models/User.js"); + +const nodemailer = require("nodemailer"); +const sendMail = vi.fn().mockResolvedValue({}); +nodemailer.createTransport = () => ({ sendMail }); + +const User = require("../models/User.js"); +const { resendVerificationEmail } = require("../controllers/authController.js"); + +function buildApp() { + const app = express(); + app.use(express.json()); + app.post("/api/auth/resend-verification", resendVerificationEmail); + return app; +} + +function fakeUser({ isEmailVerified = false } = {}) { + return { + email: "victim@example.com", + isEmailVerified, + save: vi.fn().mockResolvedValue(), + emailVerificationToken: "old", + emailVerificationExpires: new Date(), + }; +} + +const GENERIC_MESSAGE = + "If this email is registered and unverified, a verification link has been sent."; + +beforeEach(() => { + vi.clearAllMocks(); + User.findOne = vi.fn(); + sendMail.mockResolvedValue({}); +}); + +describe("resend-verification — email enumeration prevention (#1414)", () => { + it("returns 200 with the generic message when the email is not registered", async () => { + User.findOne.mockResolvedValue(null); + const res = await request(buildApp()) + .post("/api/auth/resend-verification") + .send({ email: "nobody@example.com" }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, message: GENERIC_MESSAGE }); + expect(sendMail).not.toHaveBeenCalled(); + }); + + it("returns the SAME 200 generic message when the email is already verified", async () => { + User.findOne.mockResolvedValue(fakeUser({ isEmailVerified: true })); + const res = await request(buildApp()) + .post("/api/auth/resend-verification") + .send({ email: "victim@example.com" }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, message: GENERIC_MESSAGE }); + expect(sendMail).not.toHaveBeenCalled(); + }); + + it("returns the SAME generic message for an unverified user but actually sends the email", async () => { + const user = fakeUser({ isEmailVerified: false }); + User.findOne.mockResolvedValue(user); + const res = await request(buildApp()) + .post("/api/auth/resend-verification") + .send({ email: "Victim@Example.com" }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.message).toBe(GENERIC_MESSAGE); + // A fresh token must be issued and the verification email actually sent. + expect(user.save).toHaveBeenCalled(); + expect(sendMail).toHaveBeenCalledTimes(1); + }); + + it("produces indistinguishable responses for not-found vs already-verified", async () => { + User.findOne.mockResolvedValue(null); + const r1 = await request(buildApp()) + .post("/api/auth/resend-verification") + .send({ email: "a@example.com" }); + + User.findOne.mockResolvedValue(fakeUser({ isEmailVerified: true })); + const r2 = await request(buildApp()) + .post("/api/auth/resend-verification") + .send({ email: "b@example.com" }); + + // An attacker must not be able to tell these two states apart. + expect(r1.status).toBe(r2.status); + expect(JSON.stringify(r1.body)).toBe(JSON.stringify(r2.body)); + }); + + it("still rejects an empty email with 400", async () => { + const res = await request(buildApp()) + .post("/api/auth/resend-verification") + .send({ email: "" }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); +});