Skip to content

Move rate-limit check before provider pre-processing, preserve email token#917

Open
dknauss wants to merge 5 commits into
WordPress:masterfrom
dknauss:rate-limit-gate
Open

Move rate-limit check before provider pre-processing, preserve email token#917
dknauss wants to merge 5 commits into
WordPress:masterfrom
dknauss:rate-limit-gate

Conversation

@dknauss

@dknauss dknauss commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Note: This is a rebase of PR #859, which was accidentally closed without comment on June 14. The branch has been rebased onto current master. Apologies for the confusion — the milestone, reviewer assignments, and discussion thread are in the original.

Problem

Two-factor has a rate-limit that makes you wait longer after each wrong code you enter, if you keep failing retries, so it becomes a short-term ban or lockout. The lockout period starts at 2 seconds, then 4, 8, 16... up to 15 minutes if you keep failing repeated login attempts.

The bug: clicking "Resend Code" remains available and operational for rate-limited users during the lockout period. Clicking "Resend Code" while locked out is possible because the resend logic runs before the rate-limit check.

Security and UX Impact:

  • An attacker can deliberately lock out and flood a known user with useless emailed codes.
  • A valid user who locks themselves out can do the same thing to themself: if they keep clicking the "Resend Code" and trying to log in again, even with a valid code and the right credentials they will be blocked and the lockout period will be extended.

Solution

PR #917 simply moves the rate-limit check to run first, so Resend is blocked during a lockout just like a code submission is.

  • Runs the rate-limit check before any provider can resend or validate on POST requests. No provider-specific changes.
  • Reorders process_provider() so the rate-limit gate runs before pre_process_authentication(), blocking all providers from resending or rotating state while rate-limited. Previously, the email provider could bypass the rate limit via the "Resend Code" button because pre_process_authentication() ran first.
  • GET requests (page loads) are excluded from rate-limiting, so the login form renders normally when the rate limit expires.

Implements the core-level approach suggested by @kasparsd in #848.

Security Context

Summary: Moving the rate-limit gate before pre_process_authentication() closes the resend bypass. GET requests are excluded to avoid triggering token regeneration on page reload. Pre-existing concurrency races (#510) are not worsened.

Token handling during rate limiting

The email token is intentionally preserved when the rate limit fires. An earlier version of this PR deleted the token on rate-limit, reasoning that a captured code should not outlive the brute-force threshold. In practice this caused authentication_page() — called by login_html() immediately after the WP_Error return — to see no token and regenerate one, sending a new email on every blocked POST. That recreated the flooding vector the rate-limit was meant to stop.

The replay concern is already covered by the token TTL: the default two_factor_email_token_ttl and the max rate-limit cap are both 15 minutes, so a captured token expires at the same time the lockout lifts.

Provider switching during rate limiting

Rate-limit state is per-user, not per-provider (_two_factor_last_login_failure, _two_factor_failed_login_attempts). If a user triggers the rate limit on TOTP then switches to the email provider, the rate-limit gate still fires. This is intentional — the account is under scrutiny regardless of which provider the failures came from.

GET requests excluded

The $is_post_request early return is kept above the rate-limit check. Without this, a page reload while rate-limited would trigger authentication_page() on a GET, causing repeated token emails on every reload. The resend button is a POST form submission, so it is still gated.

Concurrent request races

The read-modify-write race on the failure counter (acknowledged by @dd32 in #510) and the TOCTOU window in validate_token() are pre-existing and not worsened by this change.

Specific changes

  • Reorder process_provider(): $is_post_request check first, then is_user_rate_limited(), then pre_process_authentication() — gating all provider POST actions equally.
  • Tests (6 total):
    • Email resend blocked while rate-limited.
    • Email token preserved on rate-limited validation attempt (token deletion causes authentication_page() to re-send immediately — regression test added in email provider suite).
    • Email token preserved on provider switch while rate-limited.
    • GET request while rate-limited passes through without side effects.
    • Revalidation flow respects rate limiting equally (_login_form_validate_2fa and _login_form_revalidate_2fa share the same rate-limit state, per #510).
    • authentication_page() does not send a new email when re-rendered after a rate-limited POST.

Test plan

  • Verify rate-limited users cannot trigger email resend via POST
  • Verify rate-limited users see two_factor_too_fast error on both resend and validation POST requests
  • Verify email token is preserved when rate limit fires (no re-send on blocked POST)
  • Verify page reload (GET) while rate-limited renders the form normally
  • Verify provider switch while rate-limited still blocks the attempt
  • Verify legitimate users can retry with the same token before hitting the rate limit
  • Run vendor/bin/phpunit tests/class-two-factor-core.php tests/providers/class-two-factor-email.php

Closes #847
Supersedes #848
Supersedes #859

🤖 Assisted by Claude Code

Open WordPress Playground Preview

dknauss and others added 3 commits July 2, 2026 20:43
…okens

Moves the rate-limit gate in process_provider() ahead of
pre_process_authentication() so all providers — not just email —
are blocked from resending or rotating state while rate-limited.

Also invalidates provider tokens (e.g. email OTP) when the rate
limit fires, preventing a captured token from remaining valid for
its full TTL after brute-force detection.

Closes WordPress#847

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ting

- Provider switch: email token is invalidated when rate-limited
  via failures on another provider (e.g. TOTP)
- GET bypass: page reloads while rate-limited return false without
  deleting the token or showing errors
- Revalidation: rate limiting and token invalidation apply equally
  to the revalidation flow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: dknauss <dpknauss@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@dknauss

dknauss commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

@masteradhoc — could you add this to milestone 0.17.0 and assign reviewers as you did on the original #859?

I got caught by this trap today — it's really more than a security issue, it's a highly confusing and frustrating gotcha for any user who locks themselves out with a temporary ban after multiple failed login attempts. It ties into some UX issues I've also added, mainly #920 but also #918 and #919.

@masteradhoc masteradhoc added this to the 0.17.0 milestone Jul 3, 2026
…-render

Removing the delete_token() call that fired when the rate-limit gate
blocked a POST. Deleting the token caused authentication_page() to
immediately regenerate and email a new code on every blocked submission
(called via login_html() after each WP_Error return), recreating the
flooding vector the rate-limit was meant to prevent.

The existing token TTL (default 15 minutes) already closes the replay
window: the max rate-limit duration is also 15 minutes, so a captured
token cannot outlive the lockout period without the deletion.

Updates three core assertions and adds a regression test in the email
provider suite that exercises the full process_provider() ->
authentication_page() sequence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@dknauss

dknauss commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

Note for reviewers familiar with the original PR #859: the approach here has changed significantly since that PR was open.

The original version also deleted the email token when the rate limit fired, on the reasoning that a captured code should not remain valid after brute-force detection. That was removed after testing revealed it recreated the problem we were solving: login_html() calls authentication_page() immediately after the WP_Error return to re-render the form, and authentication_page() regenerates and emails a new token whenever none is found — so every blocked POST silently sent a fresh code, including every "Resend Code" click. The flooding path just moved one step later in the call chain.

The fix is now limited to the ordering change in process_provider(): rate-limit check before pre_process_authentication(). Token state is left alone. A regression test in the email provider suite covers the re-render path to guard against reintroducing the deletion.

Commit 7bbb96e updated three `user_has_token` assertions to `assertTrue`
when the token-preservation behaviour was introduced, but missed one that
used `get_user_token` instead. Same fix: the token is preserved when a
rate-limited resend is blocked, to prevent auto-send on form re-render.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@dknauss dknauss changed the title Move rate-limit check before provider pre-processing and invalidate tokens Move rate-limit check before provider pre-processing, preserve email token Jul 3, 2026
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.

Email resend bypasses retry delay in 2FA challenge flow

2 participants