Move rate-limit check before provider pre-processing, preserve email token#917
Move rate-limit check before provider pre-processing, preserve email token#917dknauss wants to merge 5 commits into
Conversation
…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>
|
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 If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
|
@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. |
…-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>
|
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: The fix is now limited to the ordering change in |
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>
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:
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.
process_provider()so the rate-limit gate runs beforepre_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 becausepre_process_authentication()ran first.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 bylogin_html()immediately after theWP_Errorreturn — 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_ttland 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_requestearly return is kept above the rate-limit check. Without this, a page reload while rate-limited would triggerauthentication_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
process_provider():$is_post_requestcheck first, thenis_user_rate_limited(), thenpre_process_authentication()— gating all provider POST actions equally.authentication_page()to re-send immediately — regression test added in email provider suite)._login_form_validate_2faand_login_form_revalidate_2fashare 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
two_factor_too_fasterror on both resend and validation POST requestsvendor/bin/phpunit tests/class-two-factor-core.php tests/providers/class-two-factor-email.phpCloses #847
Supersedes #848
Supersedes #859
🤖 Assisted by Claude Code