[Security] User Enumeration via Forgot-Password Endpoint

Author: geo-chenCreated Jun 24, 2026Updated Jun 24, 2026

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.

javascript
// 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:

javascript
// 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 Content

Request 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 Found

The 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:

bash
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: 204

Unregistered email test:

bash
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: 404

Impact

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

javascript
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();
};