GHSA-v9xm-ffx2-7h35 ApostropheCMS MFA/TOTP Bypass via Incorrect MongoDB Query in Bearer Token Middleware
MFA/TOTP Bypass via Incorrect MongoDB Query in Bearer Token MiddlewareSummaryThe bearer token authentication middleware in `@apostrophecms/express/index.js` (lines 386-389) contains an incorrect MongoDB query that allows incomplete login tokens — where the password was verified but TOTP/MFA requirements were NOT — to be used as fully authenticated bearer tokens. This completely bypasses multi-factor authentication for any ApostropheCMS deployment using `@apostrophecms/login-totp` or any custom `afterPasswordVerified` login requirement.SeverityThe AC is High because the attacker must first obtain the victim's password. However, the entire purpose of MFA is to protect accounts when passwords are compromised (credential stuffing, phishing, database breaches), so this bypass negates the security control entirely.Affected VersionsAll versions of ApostropheCMS from 3.0.0 to 4.27.1, when used with `@apostrophecms/login-totp` or any custom `afterPasswordVerified` requirement.Root CauseIn `packages/apostrophe/modules/@apostrophecms/express/index.js`, the `getBearer()` function (line 377) queries MongoDB for valid bearer tokens. The query at lines 386-389 is intended to only match tokens where the `requirementsToVerify` array is either absent (no MFA configured) or empty (all MFA requirements completed):The comment correctly states the intent: the array should be "empty or inexistant." However, the MongoDB operator `$ne: []` matches documents where `requirementsToVerify` is **NOT** an empty array — meaning it matches tokens that still have **unverified requirements**. This is the exact opposite of the intended behavior.| Token State | `requirementsToVerify` | `$ne: []` result | Should match? |
|---|---|---|---|
| No MFA configured | *(field absent)* | N/A (`$exists: false` matches) | Yes |
| TOTP pending | `["AposTotp"]` | `true` (BUG!) | **No** |
| All verified | `[]` | `false` (BUG!) | **Yes** |
| Field removed (`$unset`) | *(field absent)* | N/A (`$exists: false` matches) | Yes |Attack ScenarioPrerequisitesApostropheCMS instance with `@apostrophecms/login-totp` enabledAttacker knows the victim's username and password (e.g., from credential stuffing, phishing, or a database breach)Attacker does NOT know the victim's TOTP secret/codeSteps**Authenticate with password only:****Receive incomplete token** (server correctly requires TOTP):**Use incomplete token as bearer token** (bypassing TOTP):**Full authenticated access granted.** The bearer token middleware matches the token because `requirementsToVerify: ["AposTotp"]` satisfies `$ne: []`. The attacker has complete API access as the victim without ever providing a TOTP code.Proof of ConceptSee `mfa-bypass-poc.js` — demonstrates the query logic bug with all token states. Run:Both bypass vectors (single and multiple unverified requirements) confirmed.Amplifying Bug: Incorrect Token Deletion in `finalizeIncompleteLogin`A second bug in `@apostrophecms/login/index.js` (lines 728-729, 735-736) amplifies the MFA bypass. When `finalizeIncompleteLogin` attempts to delete the incomplete token, it uses the wrong identifier:The token's `_id` is a CUID (e.g., `clxxxxxxxxx`), but `token.userId` is the user's document ID. This means:The incomplete token is **never deleted** from the database, even after a legitimate MFA-verified loginCombined with the `$ne: []` bug, the incomplete token remains usable as a bearer token for its full lifetime (default: 1 hour)Even if the legitimate user completes TOTP and logs in properly, the incomplete token persistsThis bug appears at two locations in `finalizeIncompleteLogin`:Line 728-729: Error case (user not found)Line 735-736: Success case (session-based login after MFA)Recommended FixFix 1: Bearer token query (express/index.js line 388)Replace `$ne: []` with `$size: 0`:This ensures only tokens with no remaining requirements (empty array or absent field) are accepted as valid bearer tokens.Fix 2: Token deletion (login/index.js lines 728-729, 735-736)Replace `token.userId` with `token._id`:
CVSS v3.1 score: 8.1