Email enumeration in a password-reset endpoint (200 vs 422 tells you who has an account)
Target redacted. This finding is not submitted to the program nor publicly disclosed yet, so hostnames are replaced with redacted.example and account identifiers are removed. The technical detail is kept intact.
Summary
POST /api/forgot-password answered differently depending on whether the email existed. That turns an anonymous endpoint into a free "is this person registered?" oracle for the whole crew database.
Impact
- Confirms accounts for credential stuffing and for targeted password-reset phishing.
- Leaks that a specific address belongs to staff (organisational information).
- Feeds the missing-rate-limit issue on the same host, which turns enumeration into brute force.
Reproduction
1. Get a CSRF token (requires the legitimate Origin):
GET /api/csrf-cookie HTTP/1.1
Host: redacted.example
Origin: https://app.redacted.example
2. Ask for a reset with an existing account:
POST /api/forgot-password HTTP/1.1
Host: redacted.example
Content-Type: application/json
X-XSRF-TOKEN: <token>
Origin: https://app.redacted.example
{"email":"crew@redacted.example"}
3. Compare responses:
| Email state | Status | Body |
|---|---|---|
| Registered | 200 | "We have e-mailed your password reset link!" |
| Unknown | 422 | "We can't find a user with that e-mail address." |
Root cause
The controller returns validation errors per-email instead of a single generic answer. Laravel/Fortify makes this easy to do right (sendResetLink result is a status, not a user-existence check).
Remediation
Always return the same 200 + generic message, and rate-limit the endpoint per IP and per email.
Lesson
Differential responses are the tell. Two bodies, one request, no auth: that is a finding.