Skip to content

fix(auth): prevent email enumeration via resend-verification endpoint (#1414) - #1742

Open
saidai-bhuvanesh wants to merge 1 commit into
Canopus-Labs:mainfrom
saidai-bhuvanesh:fix/1414-email-enumeration-resend-verification
Open

fix(auth): prevent email enumeration via resend-verification endpoint (#1414)#1742
saidai-bhuvanesh wants to merge 1 commit into
Canopus-Labs:mainfrom
saidai-bhuvanesh:fix/1414-email-enumeration-resend-verification

Conversation

@saidai-bhuvanesh

@saidai-bhuvanesh saidai-bhuvanesh commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR fixes the email enumeration vulnerability reported in #1414 in the /api/auth/resend-verification endpoint. Previously, the endpoint returned distinguishable responses depending on the state of the supplied email address, which allowed an attacker to infer whether an email was registered and whether it was already verified — a classic account-enumeration vector that can be leveraged for targeted phishing, credential stuffing, and user-base scraping.

Before (vulnerable behavior)

  • Unknown email -> 200 { success: true, message: "If this email is registered, a verification link has been sent." }
  • Already-verified email -> 400 { success: false, message: "This email is already verified. Please log in." }
  • Unverified registered email -> 200 { success: true, message: "Verification email resent. Please check your inbox." }

An attacker could probe any email address and tell, from the status code and message alone, whether the account existed and whether it had completed verification — all without authentication or rate limiting on the signal.

After (fixed behavior)

All non-validation outcomes now return the identical response:

200 { success: true, message: "If this email is registered and unverified, a verification link has been sent." }

This single, indistinguishable response is returned for unknown emails, already-verified emails, and successfully re-sent verification emails. A verification link is only actually generated and dispatched when the account exists and is still unverified, so legitimate users are unaffected. The empty-email validation (400) is preserved.

Changes

  • backend/controllers/authController.js: collapsed the not-found, already-verified, and success branches into one generic 200 response with a unified message; a fresh token + email dispatch now happens only for existing unverified accounts.
  • backend/tests/authController.resendVerification.enumeration.unit.test.js: new unit test suite (5 cases) asserting that not-found and already-verified produce byte-identical responses, that the unverified path still sends the email, and that empty-email validation remains 400.

Test plan

  • npx vitest run tests/authController.resendVerification.enumeration.unit.test.js — 5/5 passing
  • Full backend suite npx vitest run — 132/132 passing (13 files)

Closes #1414.

Looks good to me. Ready to merge.

…-Labs#1414)

The resend-verification endpoint returned distinguishable responses for
unknown emails vs already-verified accounts, letting an attacker enumerate
registered and verified emails. All non-validation outcomes now return the
same generic 200 message; a link is only sent for existing unverified users.

Co-authored-by: openhands <openhands@all-hands.dev>
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The resend-verification endpoint now returns a generic success response for nonexistent, verified, and unverified accounts. Tests verify response uniformity, conditional email delivery, token persistence, and input validation.

Changes

Verification email enumeration protection

Layer / File(s) Summary
Generic resend responses
backend/controllers/authController.js
The endpoint returns the same generic success message for nonexistent, already verified, and successfully processed unverified accounts.
Enumeration behavior tests
backend/tests/authController.resendVerification.enumeration.unit.test.js
Tests validate indistinguishable responses, conditional email sending, token persistence, and empty-email validation.

Estimated code review effort: 2 (Simple) | ~15 minutes

Possibly related PRs

Suggested labels: type:security

Suggested reviewers: karanunique, aasritha-sure

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The controller returns generic responses for all non-validation outcomes and sends links only to existing unverified users, meeting issue #1414.
Out of Scope Changes check ✅ Passed The controller changes and focused unit tests directly support issue #1414 and contain no unrelated changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the authentication fix and the prevention of email enumeration in the resend-verification endpoint.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

function buildApp() {
const app = express();
app.use(express.json());
app.post("/api/auth/resend-verification", resendVerificationEmail);

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/controllers/authController.js (1)

369-388: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Keep the response generic when account-specific resend work fails.

At Line 383, a rejected user.save() reaches the outer catch and returns 500. Only an existing unverified account reaches this write. During a write failure, this restores an account-state response difference.

  • backend/controllers/authController.js#L369-L388: Catch token persistence and delivery failures in the unverified-user branch. Log the failure. Return genericMessage.
  • backend/tests/authController.resendVerification.enumeration.unit.test.js#L45-L107: Mock user.save() to reject. Assert the endpoint still returns the generic 200 response.
Proposed controller update
-        user.emailVerificationToken = crypto.randomBytes(32).toString("hex");
-        user.emailVerificationExpires = new Date(Date.now() + 24 * 60 * 60 * 1000);
-        await user.save();
-
-        const verificationUrl = `${process.env.FRONTEND_URL}/verify-email?token=${user.emailVerificationToken}`;
-        await sendVerificationEmail(user.email, verificationUrl);
-
-        res.json({ success: true, message: genericMessage });
+        try {
+            user.emailVerificationToken = crypto.randomBytes(32).toString("hex");
+            user.emailVerificationExpires = new Date(Date.now() + 24 * 60 * 60 * 1000);
+            await user.save();
+
+            const verificationUrl = `${process.env.FRONTEND_URL}/verify-email?token=${user.emailVerificationToken}`;
+            await sendVerificationEmail(user.email, verificationUrl);
+        } catch (error) {
+            console.error("Resend verification work failed:", error);
+        }
+
+        return res.json({ success: true, message: genericMessage });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/controllers/authController.js` around lines 369 - 388, Wrap the
unverified-user token persistence and email delivery flow in the
resend-verification handler with failure handling that logs the error and
returns the existing genericMessage with a successful response instead of
reaching the outer 500 handler. In backend/controllers/authController.js lines
369-388, update the branch after the !user/user.isEmailVerified check; in
backend/tests/authController.resendVerification.enumeration.unit.test.js lines
45-107, mock user.save() to reject and assert the endpoint returns status 200
with genericMessage.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/tests/authController.resendVerification.enumeration.unit.test.js`:
- Around line 68-80: Strengthen the resend-verification test around the
user.save mock by capturing the token and expiry values persisted by the
handler. Assert that the saved token is a newly generated 64-character
hexadecimal value and that the saved expiry is later than the current time,
while retaining the existing response and email assertions.

---

Outside diff comments:
In `@backend/controllers/authController.js`:
- Around line 369-388: Wrap the unverified-user token persistence and email
delivery flow in the resend-verification handler with failure handling that logs
the error and returns the existing genericMessage with a successful response
instead of reaching the outer 500 handler. In
backend/controllers/authController.js lines 369-388, update the branch after the
!user/user.isEmailVerified check; in
backend/tests/authController.resendVerification.enumeration.unit.test.js lines
45-107, mock user.save() to reject and assert the endpoint returns status 200
with genericMessage.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 378c52bd-e40e-4e0f-81f5-8dba04620a05

📥 Commits

Reviewing files that changed from the base of the PR and between a8a7be0 and 7a594aa.

📒 Files selected for processing (2)
  • backend/controllers/authController.js
  • backend/tests/authController.resendVerification.enumeration.unit.test.js

Comment on lines +68 to +80
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the values persisted by user.save.

The test passes if the handler saves the existing "old" token. Capture the token and expiry inside the user.save mock. Assert that the saved token is a new 64-character hex value and that the saved expiry is in the future.

Proposed test update
   it("returns the SAME generic message for an unverified user but actually sends the email", async () => {
     const user = fakeUser({ isEmailVerified: false });
+    let savedToken;
+    let savedExpiry;
+    user.save.mockImplementation(async () => {
+      savedToken = user.emailVerificationToken;
+      savedExpiry = user.emailVerificationExpires;
+    });
     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(savedToken).not.toBe("old");
+    expect(savedToken).toMatch(/^[0-9a-f]{64}$/);
+    expect(savedExpiry.getTime()).toBeGreaterThan(Date.now());
     expect(sendMail).toHaveBeenCalledTimes(1);
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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("returns the SAME generic message for an unverified user but actually sends the email", async () => {
const user = fakeUser({ isEmailVerified: false });
let savedToken;
let savedExpiry;
user.save.mockImplementation(async () => {
savedToken = user.emailVerificationToken;
savedExpiry = user.emailVerificationExpires;
});
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);
expect(user.save).toHaveBeenCalled();
expect(savedToken).not.toBe("old");
expect(savedToken).toMatch(/^[0-9a-f]{64}$/);
expect(savedExpiry.getTime()).toBeGreaterThan(Date.now());
expect(sendMail).toHaveBeenCalledTimes(1);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/authController.resendVerification.enumeration.unit.test.js`
around lines 68 - 80, Strengthen the resend-verification test around the
user.save mock by capturing the token and expiry values persisted by the
handler. Assert that the saved token is a newly generated 64-character
hexadecimal value and that the saved expiry is later than the current time,
while retaining the existing response and email assertions.

@github-actions github-actions Bot added the merge ready PR is mergeable and has no conflicts label Aug 9, 2026
@github-actions

Copy link
Copy Markdown

@saidai-bhuvanesh, please resolve the commit so that it will be merged soon ......

@github-actions github-actions Bot added merge conflicts PR has merge conflicts and removed merge ready PR is mergeable and has no conflicts labels Aug 11, 2026
@KaranUnique

Copy link
Copy Markdown
Contributor

@saidai-bhuvanesh resolve the conflicts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge conflicts PR has merge conflicts

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Security: Prevent email enumeration on resend verification endpoint

4 participants