[Security] User Enumeration via Forgot-Password Endpoint
Reported on 24 May 2026 to https://github.com/automatisch/automatisch/security/advisories/GHSA-5mjr-mfq9-vr7f but did not get any response.
Summary
The POST /internal/api/v1/users/forgot-password endpoint returns different HTTP status codes depending on whether a submitted email address is registered in the system. A registered email receives 204 No Content; an unregistered email receives 404 Not Found. An unauthenticated attacker can exploit this discrepancy to enumerate valid user email addresses without any prior access or credentials.
Vulnerability Details
The controller at packages/backend/src/controllers/internal/api/v1/users/forgot-password.js calls User.query().findOne({ email }). When no user is found, Objection.js raises a NotFoundError, which the global error handler converts to a 404 Not Found response. When a user is found, the controller sends a password reset email and returns 204 No Content.
// packages/backend/src/controllers/internal/api/v1/users/forgot-password.js
export default async (request, response) => {
const { email } = request.body;
const user = await User.query()
.findOne({ email: email?.toLowerCase() })
.throwIfNotFound(); // <-- throws 404 for non-existent emails
await user.sendResetPasswordEmail();
response.status(204).end();
};The route is unauthenticated:
// packages/backend/src/routes/internal/api/v1/users.js
router.post('/forgot-password', forgotPasswordAction);The standard remediation is for the endpoint to return 204 unconditionally, regardless of whether the email is registered, and to process or discard the reset email behind the scenes.
Proof of Concept
Request against a registered email:
POST /internal/api/v1/users/forgot-password HTTP/1.1
Host: target.example.com
Content-Type: application/json
{"email":"[email protected]"}Response:
HTTP/1.1 204 No ContentRequest against an unregistered email:
POST /internal/api/v1/users/forgot-password HTTP/1.1
Host: target.example.com
Content-Type: application/json
{"email":"[email protected]"}Response:
HTTP/1.1 404 Not FoundThe difference in status code (204 vs 404) unambiguously identifies whether any given email address is registered in the application.
Reproduction (Docker)
Environment used: automatisch commit 41f3c56, self-hosted mode, port 9140.
Registered email test:
curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]"}' \
http://localhost:9140/internal/api/v1/users/forgot-password
# Output: 204Unregistered email test:
curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]"}' \
http://localhost:9140/internal/api/v1/users/forgot-password
# Output: 404Impact
An unauthenticated attacker can submit a list of candidate email addresses and use the status code difference to determine which addresses have accounts in the system. The enumerated list can be used to:
- Target specific accounts for phishing or credential stuffing attacks
- Map the user population of an automatisch instance
- Identify administrator accounts by cross-referencing known email patterns
The impact is limited to information disclosure (email address validity). No authentication bypass or account takeover is possible through this endpoint alone.
Root Cause
The throwIfNotFound() call is placed before the success response. Objection.js throws a NotFoundError which propagates as a 404. The fix is to suppress the NotFoundError and always return 204.
Suggested Fix
export default async (request, response) => {
const { email } = request.body;
const user = await User.query()
.findOne({ email: email?.toLowerCase() });
if (user) {
await user.sendResetPasswordEmail();
}
// Always return 204 to prevent email enumeration
response.status(204).end();
};Source: automatisch/automatisch