Skip to content

fix: prevent account enumeration via resend-verification endpoint - #1455

Open
ionfwsrijan wants to merge 1 commit into
Canopus-Labs:mainfrom
ionfwsrijan:fix/1437-resend-verification-enumeration
Open

fix: prevent account enumeration via resend-verification endpoint#1455
ionfwsrijan wants to merge 1 commit into
Canopus-Labs:mainfrom
ionfwsrijan:fix/1437-resend-verification-enumeration

Conversation

@ionfwsrijan

@ionfwsrijan ionfwsrijan commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Problem

POST /api/auth/resend-verification behaves as an account oracle. A request for an unregistered email returns a generic 200 "If this email is registered...", while a request for a registered-but-already-verified email returns a distinct 400 with "This email is already verified. Please log in." The distinct status code and message let an attacker enumerate which emails are registered on PrepPilot.

Fix

resendVerificationEmail now returns the identical generic 200 response shape for every input (unregistered, already verified, or unverified). The verification email is only sent when a matching unverified user actually exists. A 400 is now returned only when the email field itself is missing from the request body.

Files changed

  • backend/controllers/authController.js — uniform response for all email states.
  • backend/tests/resendVerification.enumeration.unit.test.js — new unit tests covering unregistered, verified, and unverified emails.

Testing

npx vitest run tests/resendVerification.enumeration.unit.test.js — 4/4 passing.

Closes #1437

The resend-verification endpoint now prevents account enumeration by returning the same generic 200 response for all email registration states. It sends verification emails only for matching unverified users and returns 400 only when email is missing.

Added unit tests for all scenarios. All 4 tests pass.

Ready to merge.

…meration

Return an identical generic 200 for every input (unregistered, already
verified, or unverified) so the endpoint can no longer be used as an
account oracle. The verification email is only sent when a matching
unverified user actually exists.

Closes Canopus-Labs#1437
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The resend-verification endpoint now uses one generic success response for nonexistent, verified, and unverified accounts. Tests cover response consistency, email side effects, and missing-email validation.

Changes

Resend verification anti-enumeration

Layer / File(s) Summary
Generic response and behavior coverage
backend/controllers/authController.js, backend/tests/resendVerification.enumeration.unit.test.js
The endpoint returns the generic success response for nonexistent and already-verified accounts. Unverified accounts still receive verification emails. Tests cover all account states and missing email input.

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

Possibly related issues

Possibly related PRs

Suggested reviewers: karanunique, suhaniiz

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the primary security fix: preventing account enumeration through the resend-verification endpoint.
Linked Issues check ✅ Passed The implementation returns uniform generic responses, conditionally sends emails, and preserves 400 handling for missing email input as required by [#1437].
Out of Scope Changes check ✅ Passed The controller change and focused unit tests directly support the account-enumeration fix and contain no unrelated changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

@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: 2

🤖 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/controllers/authController.js`:
- Around line 369-375: Update the unverified-user flow after the account-state
check in the authentication controller to catch failures from user.save() and
sendVerificationEmail(), log the error, and return the same generic HTTP 200
response used for nonexistent and verified accounts. Prevent these
delivery-operation errors from reaching the outer catch and exposing account
status.

In `@backend/tests/resendVerification.enumeration.unit.test.js`:
- Around line 21-28: Restore the process-global Module._load after this test
file finishes by assigning originalLoad back to Module._load in the test
teardown or cleanup path. Keep the testDoubles interception behavior unchanged
during execution, and ensure restoration occurs even when the tests fail.
🪄 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: 9ffe7b58-8df2-49de-bfd3-03f38089d44e

📥 Commits

Reviewing files that changed from the base of the PR and between 8acb5b8 and 4699f62.

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

Comment on lines +369 to 375
// Return the exact same response whether the account does not exist,
// is already verified, or is unverified. This prevents the endpoint
// from being used to enumerate which emails are registered.
if (!user || user.isEmailVerified) {
return res.json({ success: true, message: "If this email is registered, a verification link has been sent." });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Return the generic response when delivery operations fail.

Lines 369-375 require the same response for all account states. For an unverified user, user.save() or sendVerificationEmail() can throw and the outer catch returns HTTP 500. Nonexistent and verified users return HTTP 200 before these operations. This can reveal unverified accounts during a database-write or email-provider failure.

Catch and log failures after the user lookup, then return the generic HTTP 200 response.

Proposed fix
-        await user.save();
-
-        const verificationUrl = `${process.env.FRONTEND_URL}/verify-email?token=${user.emailVerificationToken}`;
-        await sendVerificationEmail(user.email, verificationUrl);
+        try {
+            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 delivery failed:", error);
+        }
 
-        res.json({ success: true, message: "If this email is registered, a verification link has been sent." });
+        return res.json({ success: true, message: "If this email is registered, a verification link has been sent." });

Also applies to: 384-384

🤖 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 - 375, Update the
unverified-user flow after the account-state check in the authentication
controller to catch failures from user.save() and sendVerificationEmail(), log
the error, and return the same generic HTTP 200 response used for nonexistent
and verified accounts. Prevent these delivery-operation errors from reaching the
outer catch and exposing account status.

Comment on lines +21 to +28
const testDoubles = new Map();
const originalLoad = Module._load;
Module._load = function (request, parent, isMain) {
if (testDoubles.has(request)) {
return testDoubles.get(request);
}
return originalLoad.call(this, request, parent, isMain);
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Restore Module._load after this test file.

This file replaces the process-global CommonJS loader. It never restores originalLoad. Later tests in the same process can receive testDoubles and become order-dependent.

Proposed fix
-import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest";
+import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll, vi } from "vitest";
 
+afterAll(() => {
+  Module._load = originalLoad;
+  testDoubles.clear();
+  clearRequireCache();
+});
🤖 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/resendVerification.enumeration.unit.test.js` around lines 21 -
28, Restore the process-global Module._load after this test file finishes by
assigning originalLoad back to Module._load in the test teardown or cleanup
path. Keep the testDoubles interception behavior unchanged during execution, and
ensure restoration occurs even when the tests fail.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Account enumeration via POST /api/auth/resend-verification

1 participant