2FA verify endpoint rate limit bypassable via spoofed X-Forwarded-For
The /rest/2fa/verify rate limit keys on req.ip, but trust proxy is enabled globally so attackers can rotate the X-Forwarded-For header to obtain unlimited TOTP-guess buckets and brute-force the 6-digit second factor.
routes/2fa.ts verify() (lines 16–48) validates a TOTP code with no per-account/lockout protection of its own; protection is delegated to an express-rate-limit middleware registered in server.ts:
// server.ts:451-454
app.post('/rest/2fa/verify',
rateLimit({ windowMs: 5 * 60 * 1000, max: 100, validate: false }),
utils.asyncHandler(twoFactorAuth.verify)
)
No custom keyGenerator is supplied, so the library falls back to keying on req.ip. But earlier in the same file:
// server.ts:339
app.enable('trust proxy')
trust proxy is set to the most permissive value (true), so Express uses the leftmost entry of the X-Forwarded-For header as req.ip. That value is entirely attacker-controlled — any HTTP client can send X-Forwarded-For: 1.2.3.4 and req.ip becomes 1.2.3.4. Setting validate: false even suppresses express-rate-limit's built-in misconfiguration warning for this exact pattern.
The 2FA verify handler accepts (tmpToken, totpToken) from the request body and calls verifySync({ secret: user.totpSecret, token: totpToken, ... }). The tmpToken is obtained from /rest/login once a valid password is presented (so the attacker only needs to know the password — which itself is unrestricted in length, see the companion weak-password-policy finding) and contains a fixed userId. After acquiring a single tmpToken, an attacker can iterate the entire 10⁶ TOTP space by simply rotating X-Forwarded-For per request, defeating the intended 100-per-5-minute cap.
The other 2FA endpoints (/rest/2fa/setup, /rest/2fa/disable) and /rest/user/reset-password are limited the same way and inherit the same bypass.
- POST /rest/user/login {email, password} → receive
tmpToken(typepassword_valid_needs_second_factor_token). - Loop totpToken from 000000…999999:
POST /rest/2fa/verify Headers: X-Forwarded-For: <random IP per request> Body: {"tmpToken":"…","totpToken":"NNNNNN"}
- Each request lands in a fresh rate-limit bucket, so the 100/5min ceiling never trips. Expected to recover the correct TOTP in seconds-to-minutes, fully bypassing the second factor.
Allows an attacker who already knows or has captured a victim's password to bypass 2FA by brute-forcing the 6-digit TOTP code. With XFF rotation the rate limit is effectively absent, so the entire 10⁶ search space is searchable in well under an hour at modest request rates. End result: full account takeover for any 2FA-protected account whose password the attacker can obtain (credential stuffing, password spraying, phishing). The same bypass affects /rest/user/reset-password (which keys on the literal header X-Forwarded-For) and the other 2FA endpoints.
The verify() handler in routes/2fa.ts has zero in-handler brute-force protection (no per-user counter, no lockout after failed verifySync attempts) and returns 401 on any mismatch, so the only defense is the external express-rate-limit wrapper. With app.enable('trust proxy') (equivalent to trust proxy = true) and no custom keyGenerator, express-rate-limit keys on req.ip, which Express resolves from the leftmost X-Forwarded-For entry — fully attacker-controlled. validate: false even suppresses the library's built-in warning for this exact misconfiguration. The PoC (acquire tmpToken from login, then iterate 000000–999999 while rotating XFF) is mechanically sound and yields full 2FA bypass.
The /rest/2fa/verify endpoint is reachable anonymously over HTTP (PR:N, AV:N, UI:N): with app.enable('trust proxy') at server.ts:339 and the express-rate-limit middleware having no custom keyGenerator (so it keys on req.ip, which comes from the leftmost X-Forwarded-For), an attacker simply rotates that header per request — trivial, deterministic, no race or special config (AC:L). Once the rate limit is bypassed, the entire 10^6 TOTP space in verifySync({ secret: user.totpSecret, token: totpToken }) is brute-forceable, yielding full session tokens via security.authorize(plainUser) for any account whose password is known, i.e. complete account takeover (C:H, I:H). Availability is L because the takeover allows password/2FA changes that lock the victim out of their own account, but the broader service remains up; Scope is U since the impact stays within the application's own auth boundary.
- CWE-307
- CWE-799
- OWASP API4:2023 Unrestricted Resource Consumption
- https://express-rate-limit.mintlify.app/guides/troubleshooting-proxy-issues