Repository navigation
Only clear the login nonce once it has expired - #980
Conversation
|
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. |
6e0615e to
841b444
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
As implemented, an expired stored nonce may remain in usermeta (and continue triggering mismatch logging) when presented with a wrong key; expiry cleanup should not depend on providing the correct nonce value.
Pull request overview
This PR adjusts Two_Factor_Core’s login nonce verification behavior to avoid deleting a pending (valid) login nonce on nonce mismatch, reducing the ability for unauthenticated requests to disrupt in-progress 2FA logins. It also adds/updates tests around nonce failure reasons, logging hooks, and the preserved-on-mismatch behavior (stacked with the diagnostics from #973).
Changes:
- Update
verify_login_nonce()to delete stored nonces only in the expired path, and add reasoned diagnostics viatwo_factor_login_nonce_failed+two_factor_log_login_nonce_failures. - Add/adjust PHPUnit coverage for mismatch preservation, expired nonce deletion, and per-reason logging defaults.
- Add a test-suite-level filter to suppress nonce-failure error log output during tests.
File summaries
| File | Description |
|---|---|
| class-two-factor-core.php | Refines login nonce verification semantics and adds structured logging/action hooks for failure diagnostics. |
| tests/class-two-factor-core.php | Updates and extends nonce verification tests (mismatch preservation, expiry cleanup, and diagnostics/logging behavior). |
Review details
Suppressed comments (1)
class-two-factor-core.php:1383
- Expired login nonces are only deleted when the correct key is presented (the
hashes_matchbranch). If the nonce has expired but the request presents an incorrect/garbage key (the common case for abandoned logins and any probing), the code logsmismatchand leaves an already-expired nonce in usermeta indefinitely (until the next successful password phase overwrites it). That also meansmismatchlogging can continue long after expiry. Consider checking expiration up front and deleting/loggingexpiredregardless of whether the key matches, then doing the hash comparison only for non-expired nonces.
$unverified_nonce = array(
'user_id' => $user_id,
'expiration' => $login_nonce['expiration'],
'key' => $nonce,
- Files reviewed: 2/2 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
841b444 to
b57feb7
Compare
|
Good catch — adopted. Gating the expiry cleanup on Checking expiry first is also just simpler, and it means the hash is never computed for a nonce that cannot succeed: if ( time() >= $login_nonce['expiration'] ) {
self::log_login_nonce_failure( $user_id, 'expired' );
self::delete_login_nonce( $user_id );
return false;
}
$unverified_hash = self::hash_login_nonce( $unverified_nonce );
if ( $unverified_hash && hash_equals( $login_nonce['key'], $unverified_hash ) ) {
return true;
}
self::log_login_nonce_failure( $user_id, 'mismatch' );
return false;Worth being explicit that this does not weaken what the PR is for. Deleting an expired nonce is not destructive — it was already dead, and no request could have used it. The behavior being removed is deleting a live nonce because someone presented the wrong value against it, which is what let an unauthenticated request end another user's in-progress login. Expiry cleanup and mismatch preservation are independent, and checking expiry first is what keeps them that way. Coverage added for exactly the case you named: a new |
b57feb7 to
19e0fb1
Compare
An unrecognized nonce no longer discards the pending one. Preserve it and let it run out its own expiration instead. Delete-on-mismatch dates back to the original implementation, where the mismatch and expiry cases shared one branch. It offers no brute-force resistance -- the key is 256 bits from random_bytes() inside a ten minute window, it is useless without the second factor, and validate_login_form_2fa() already rotates it whenever a factor fails -- while allowing any unauthenticated request to end another user's in-progress login. Expiry is checked before the key, so an expired nonce is cleared whatever was presented alongside it. It can never succeed again, so deleting it is not destructive, and an abandoned login no longer leaves dead weight in usermeta until the next password success overwrites it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
19e0fb1 to
025fe23
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Address log/hook flooding and clarify nonce failure-reason semantics.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
class-two-factor-core.php:1387
- This early expiry check reports
expiredfor every submitted value once the stored nonce has expired, including a wrong key. However,log_login_nonce_failure()documentsexpiredas the correct value being past expiration, and consumers of its action/filter can rely on that distinction. Since the new tests intentionally cover an arbitrary value asexpired, update that reason contract and related documentation, or preserve the hash check before assigning the reason.
if ( time() >= $login_nonce['expiration'] ) {
self::log_login_nonce_failure( $user_id, 'expired' );
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Lite
| * of random_bytes(), and a failed second factor rotates it in | ||
| * validate_login_form_2fa() regardless. | ||
| */ | ||
| self::log_login_nonce_failure( $user_id, 'mismatch' ); |
67 commits upstream, toujours sans release (dernière : 0.16.0 du 27/03/2026). Parmi eux, WordPress#980 : un nonce de login invalide ne supprime plus le nonce en cours, ce qui permettait à une requête non authentifiée d'interrompre la connexion 2FA d'un autre utilisateur. Upstream relève les prérequis à WordPress 7.0 et PHP 7.4. - readme.txt : seul conflit, dû au passage CRLF -> LF en amont. Version upstream reprise, plus la ligne two_factor_fallback_provider_for_user de WordPress#927. - pnpm-lock.yaml : régénéré par pnpm import. - pnpm-workspace.yaml : scripts d'installation de @parcel/watcher et unrs-resolver refusés (binaires précompilés en dépendances optionnelles) ; sans décision explicite, pnpm 11 fait échouer l'installation. Validé en local (wp-env, WordPress 7.1.1, PHP 7.4.33) : PHPUnit 218 tests, 711 assertions OK, dont les 10 du groupe enforcement ; PHPCS 23/23 ; PHPStan niveau 5, 0 erreur. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Intègre les 175 commits accumulés depuis c8c8823 (20/09), dont la release stable 0.17.0 publiée le 28/09 — la première depuis la 0.16.0 du 27/03. Correctif de sécurité déterminant pour ce fork : WordPress#989 empêche un mot de passe classique de contourner la 2FA sur REST et XML-RPC. Notre code testait `did_action( 'application_password_did_authenticate' )`, vrai dès que n'importe quel utilisateur s'était authentifié par application password pendant la requête ; upstream scope désormais la vérification par ID (`$app_password_auth_user_ids`). Les autres correctifs de sécurité de la release (WordPress#877, WordPress#973, WordPress#980) étaient déjà présents ici. Trois PR qui composaient le fork sont maintenant mergées upstream : WordPress#927 (fail-safe), WordPress#917 (rate-limit) et WordPress#882. Notre implémentation locale de WordPress#927 et celle d'upstream sont identiques, d'où l'absence de conflit sur class-two-factor-core.php. Le fork ne repose plus que sur WordPress#845 (enforcement par rôle, approuvée le 24/09 mais non mergée) et WordPress#958. Résolution des trois conflits, upstream retenu dans les trois cas : - tests/class-two-factor-core.php : nos noms de tests contredisaient leurs propres assertions (« invalidates » pour un token vérifié « preserved ») ; upstream les renomme et ajoute test_clear_login_rate_limit. - tests/providers/class-two-factor-email.php : ajout de trois tests de lockout côté upstream, aucun test perdu ici. - readme.txt : notre seul apport propre, la documentation du filtre two_factor_fallback_provider_for_user, est déjà dans leur version. Validation (wp-env, WordPress 7.1 / PHP 7.4) : PHPUnit 308 tests et 932 assertions OK sur les suites single et multisite, dont 10/10 pour le groupe enforcement ; PHPCS 29/29 ; PHPStan 0 erreur ; build OK. Le merge apporte une suite multisite et le support WP-CLI, d'où le passage de 218 à 308 tests et la nouvelle dépendance php-stubs/wp-cli-stubs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes the destructive half of
verify_login_nonce(): an unrecognized nonce no longer discards the pending one.The behavior
verify_login_nonce()currently deletes the stored login nonce on any verification failure, including a simple mismatch:Its one caller,
validate_login_form_2fa(), runs that check beforeprocess_provider()— which is where rate limiting lives. So the destructive path is the only one in the flow with nothing throttling it, and it needs no authentication to reach. Anyone can end another user's in-progress 2FA login by submitting a garbage nonce with a guessed user ID. The victim isn't locked out, but their half-finished login dies and they start over at the password prompt.Why the delete was there
Not as a considered defense. The original implementation collapsed both failure cases into one branch:
Deleting an expired nonce is reasonable garbage collection. Deleting on mismatch came along for the ride. The 2021 hashing refactor (f3232d9) split the condition apart and added the comment "Require a fresh nonce if verification fails" — describing the existing behavior rather than arguing for it.
Why dropping it costs nothing
It isn't brute-forceable.
create_login_nonce()usesbin2hex( random_bytes( 32 ) )— 256 bits, inside a ten minute window.The nonce alone isn't sufficient. Even a correct one only reaches the second factor prompt;
process_provider()still demands the real TOTP code, email code, or backup code. A guessed nonce puts an attacker exactly where knowing the password would.Rotation already covers it. On a failed second factor,
validate_login_form_2fa()callscreate_login_nonce()again and overwrites the stored value. The "one attempt per prompt" property delete-on-mismatch would supposedly provide is already provided by rotation.There's no replay surface to protect. The stored hash binds
user_idandexpiration, so a nonce can't be reused for another user or have its window extended.The change
verify_login_nonce()has exactly one caller and it treats every non-truereturn identically, so nothing downstream shifts.Tests
test_invalid_nonce_deletes_valid_noncelocks in the behavior being removed, so it inverts:test_invalid_nonce_preserves_valid_nonce; the second assertion flips toassertTrue, plus a usermeta check that the pending nonce survived.test_expired_nonce_is_deletedcovers the retained delete path.test_can_verify_login_noncedrops a now-stale "Must create a new one since incorrect nonces deletes them" workaround.Stacked on #973
This branches off #973 and reuses its
log_login_nonce_failure()call sites, so the reason codes stay accurate across the split. Review and merge #973 first — the diff shown here includes its commits.🤖 Generated with Claude Code