Skip to content

fix(security): harden backup code login path (hashing, rate limit, CSRF) - #126

Open
palmoni5 wants to merge 1 commit into
NodeBB:masterfrom
palmoni5:fix/backup-code-hardening
Open

fix(security): harden backup code login path (hashing, rate limit, CSRF)#126
palmoni5 wants to merge 1 commit into
NodeBB:masterfrom
palmoni5:fix/backup-code-hardening

Conversation

@palmoni5

@palmoni5 palmoni5 commented Sep 5, 2026

Copy link
Copy Markdown

Summary

Hardens the backup-code and TOTP challenge routes. All three issues are present on master (8.0.9).

1. Backup codes were stored in plain text and only 40 bits long

utils.generateUUID().replace('-', '') removes only the first dash, so the resulting code was 10 hex characters (~40 bits). Codes were then stored verbatim in 2factor:uid:<uid>:backupCodes, so anyone with read access to the database had a working second factor for every user.

  • Codes are now generated with crypto.randomBytes(6) (12 hex chars).
  • Only the SHA-256 hash is stored. Validation hashes the submitted code and checks set membership.
  • Backwards compatible: codes generated before this change are still matched as plain text and removed once used. No upgrade script needed; the legacy codes disappear naturally as users regenerate or consume them.
  • Submitted codes are normalised (trim, lowercase, strip spaces/dashes) so users can paste them with formatting.

2. No rate limiting on POST /login/2fa/backup

processTotpLogin has a per-uid lock (locks:totp:<uid>), a 2s delay on failure, and a 10s penalty when spammed. processBackup had none of these, so backup codes could be brute-forced online with no throttling. This PR mirrors the TOTP behaviour using locks:backup:<uid>.

3. No CSRF protection on the two challenge POSTs

POST /login/2fa/totp and POST /login/2fa/backup ran without applyCSRF, while the PUT /login/2fa/backup route on the next line already had it. The templates rendered a hidden field named csrf, but core's CSRF middleware only reads csrf_token / _csrf, so the token was never checked. Both routes now use hostMiddleware.applyCSRF and the templates send csrf_token.

Notes for review

  • Syntax-checked only; not yet exercised against a running forum. Areas worth a manual pass: login with a pre-existing plain-text code, login with a freshly generated code, and both forms submitting with the renamed csrf_token field.
  • The lock key locks:backup:<uid> is cleared on success, on failure (after the 2s delay) and on exception, matching the TOTP path.

- Store backup codes as SHA-256 hashes instead of plain text. Codes that
  were generated before this change are still accepted (matched as plain
  text) so existing users are not locked out.
- Generate codes with crypto.randomBytes (12 hex chars). The previous
  implementation only stripped the first dash from the UUID, which left
  a 10-character code.
- Rate-limit POST /login/2fa/backup the same way as the TOTP path: one
  attempt at a time per uid, 2s delay on failure, 10s penalty when
  spammed.
- Apply CSRF protection to POST /login/2fa/totp and
  POST /login/2fa/backup. The templates were already rendering the token
  but under a field name (`csrf`) that core never reads; rename it to
  `csrf_token`.
- Normalise submitted backup codes (trim, lowercase, strip spaces/dashes).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant