Commit f7404dea4ad for woocommerce
commit f7404dea4adbd31239a498fc149b1988edac202e
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Tue Sep 8 12:27:26 2026 +0300
Fix password resets with SameSite=Strict cookies (#68334)
* fix(account): Support resets without redirect cookies
Password reset links rely on the wp-resetpass cookie surviving a cross-site redirect. SameSite=Strict policies can suppress it and return shoppers to the lost-password form.
Exchange the validated key for a short-lived consumed handle and a reset-state-bound signed form token. This preserves core validation and cookie precedence while preventing URL credential reuse and allowing validation retries.
Refs #39652
* chore: Add password reset changelog
Record the SameSite-compatible password reset fix for the next WooCommerce release.
Refs #39652
* refactor(account): simplify password reset bridge flow
The reset form resolved cookie, bridge, and posted credentials across
interleaved branches, duplicating eligibility and signing rules while
exchanging a bridge that lost to a valid WordPress cookie.
Resolve the winning credentials once and render the form once. Discard a
losing handle without minting an unused signed token, and centralize the
eligibility, handle-shape, and HMAC invariants.
Consolidate the focused test fixtures and remove PHPStan baseline entries
made obsolete by the refactor.
Refs #39652
* fix(account): reject array password reset keys
Bracket-style query parameters can make the legacy reset key an array.
The handler formatted that value before core validation, emitting a warning,
setting a junk reset cookie, and redirecting an unusable request.
Require a string key before entering the redirect flow and cover malformed
array input with a focused no-redirect regression.
Refs #39652
* fix(account): bind the reset form token to the WordPress key lifetime
The signed form token minted when a bridge handle is exchanged inherited
that handle's expiration, which is capped at ten minutes.
The cap is right for the handle, which travels in the URL and so reaches
browser history, referrers and link scanners. It is wrong for the form
token, whose exposure matches the raw reset key the cookie path already
places in the same hidden field for a full 24 hours. Re-renders return
the token verbatim, so a retry never extends it: a shopper who mistypes
a confirmation or waits on a password manager past ten minutes is told
"This key is invalid or has already been used", which is not what
happened. That left the Strict-cookie path worse off than the cookie
path this fix exists to bring it level with.
Bind the form token to the WordPress key's own expiry instead. Old-style
keys carry no request timestamp and yield PHP_INT_MAX, which would never
expire, so they keep the bridge's short window. Validation still
re-checks the key expiry on its own and mixes the activation key into
the signature, so a token can outlive neither the key nor the reset.
Refs #39652
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(account): enforce single use when exchanging a reset bridge handle
Consuming a bridge handle read the payload with get_transient() and then
deleted it, ignoring the result.
That pair is not atomic. Two requests arriving close together can both
read the payload before either delete lands, and both mint a form token,
which contradicts the single-use property the surrounding docblock
claims. Nothing is granted that the caller did not already hold, since
both requests carry the same emailed handle and both tokens would be for
the same user, but an invariant a security path states in prose is worth
enforcing in code.
delete_transient() returns true only when the row was actually removed,
so requiring it to succeed lets exactly one request complete the
exchange. The narrow cost is that an email scanner or prefetcher racing
the shopper by milliseconds now loses one of the two, where previously
both were served; a scanner that wins outright already burns the handle
under the existing design.
Refs #39652
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(account): show the invalid reset key notice once
A rejected submission carrying a spent handle rendered the same notice
twice: process_reset_password() adds it through the reset-key wrapper,
then the show-reset-form branch adds it again because a handle is
present in the URL.
Trunk already does this on the cookie path, where the wrapper runs at
both call sites, so the duplication is not new. The bridge path makes it
easier to reach, and the shopper sees the same sentence stacked.
Guard the helper on wc_has_notice() so the message is queued once
regardless of how many call sites reach it, which fixes both paths.
Refs #39652
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(account): mark the password reset redirect no-store
set_reset_password_bridge_headers() requires reset-token in the query
string, so it never sees the 302 issued by redirect_reset_password_link()
- and that redirect's Location header is where the handle first appears.
prevent_caching() already gives the response private, no-cache and
max-age=0, so shared caches were never a risk; only no-store was absent.
The handle reaches browser history either way, so this is defence in
depth rather than a hole, and it makes the description's claim that
bridge responses are marked no-store true of the redirect as well.
Call nocache_headers() before redirecting.
Refs #39652
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(account): log when the password reset bridge cannot be stored
When all three set_transient() attempts fail, an empty handle is
returned and the caller falls back to the cookie-only flow.
For a shopper whose cookies are SameSite=Strict that is the original
bug returning, with support tickets as the only signal that a persistent
object cache is refusing writes.
Log a warning on that branch. It is reachable only behind a genuine
valid emailed key, so it cannot be driven from outside.
Refs #39652
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(account): cover the single-use guard on the bridge handle exchange
The guard requiring delete_transient() to succeed before a handle is
exchanged had no test behind it. Reverting it left the class green, so
the invariant the docblock claims rested on a one-off runtime probe
rather than on the suite.
It looked untestable because the losing interleaving only happens
between two concurrent requests. It is not: get_transient() fires
transient_{$name} on its way out, which is precisely the seam between
the read and the caller's delete. Deleting the row from that filter
reproduces the case where a second request read the same payload and
landed its delete first, with no concurrency involved.
Reverting the guard now renders the reset form for the request that
lost, and the test fails.
Refs #39652
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-39652-samesite-password-reset b/plugins/woocommerce/changelog/fix-39652-samesite-password-reset
new file mode 100644
index 00000000000..147c32fbffe
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-39652-samesite-password-reset
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Allow password reset links to work when reset cookies use SameSite=Strict.
diff --git a/plugins/woocommerce/includes/class-wc-form-handler.php b/plugins/woocommerce/includes/class-wc-form-handler.php
index f58765ccc27..21cc00b382d 100644
--- a/plugins/woocommerce/includes/class-wc-form-handler.php
+++ b/plugins/woocommerce/includes/class-wc-form-handler.php
@@ -44,6 +44,7 @@ class WC_Form_Handler {
* Hook in methods.
*/
public static function init() {
+ add_filter( 'wp_headers', array( __CLASS__, 'set_reset_password_bridge_headers' ), 10 );
add_action( 'template_redirect', array( __CLASS__, 'redirect_reset_password_link' ) );
add_action( 'template_redirect', array( __CLASS__, 'resend_set_password' ) );
add_action( 'template_redirect', array( __CLASS__, 'save_address' ) );
@@ -65,11 +66,45 @@ class WC_Form_Handler {
add_action( 'wp', array( __CLASS__, 'set_default_payment_method_action' ), 20 );
}
+ /**
+ * Prevent storage and referrer disclosure of password-reset bridge handles.
+ *
+ * @since 11.2.0
+ * @internal
+ *
+ * @param array<string, string> $headers Response headers.
+ * @return array<string, string> Filtered response headers.
+ */
+ public static function set_reset_password_bridge_headers( $headers ) {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( empty( $_GET['reset-token'] ) || empty( $_GET['show-reset-form'] ) ) {
+ return $headers;
+ }
+
+ $bridge_handle = wc_clean( wp_unslash( $_GET['reset-token'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( ! WC_Shortcode_My_Account::is_password_reset_bridge_handle( $bridge_handle ) || ! is_account_page() ) {
+ return $headers;
+ }
+
+ WC_Cache_Helper::set_nocache_constants();
+ $headers = array_merge( $headers, wp_get_nocache_headers() );
+
+ /*
+ * WC_Cache_Helper::prevent_caching() drops no-store for logged-out visitors to preserve
+ * bfcache, and wp_get_nocache_headers() is filterable. Spell the directives out so a page
+ * carrying a reset credential is never stored, whatever those two decided.
+ */
+ $headers['Cache-Control'] = 'no-cache, no-store, must-revalidate, max-age=0, private';
+ $headers['Referrer-Policy'] = 'no-referrer';
+
+ return $headers;
+ }
+
/**
* Remove key and user ID (or user login, as a fallback) from query string, set cookie, and redirect to account page to show the form.
*/
public static function redirect_reset_password_link() {
- if ( is_account_page() && isset( $_GET['key'] ) && ( isset( $_GET['id'] ) || isset( $_GET['login'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( is_account_page() && isset( $_GET['key'] ) && is_string( $_GET['key'] ) && ( isset( $_GET['id'] ) || isset( $_GET['login'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
// If available, get $user_id from query string parameter for fallback purposes.
if ( isset( $_GET['login'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
@@ -77,6 +112,7 @@ class WC_Form_Handler {
$user_id = $user ? $user->ID : 0;
} else {
$user_id = absint( $_GET['id'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $user = get_userdata( $user_id );
}
// If the reset token is not for the current user, ignore the reset request (don't redirect).
@@ -86,18 +122,24 @@ class WC_Form_Handler {
return;
}
- $action = isset( $_GET['action'] ) ? sanitize_text_field( wp_unslash( $_GET['action'] ) ) : '';
- $value = sprintf( '%d:%s', $user_id, wp_unslash( $_GET['key'] ) ); // phpcs:ignore
- WC_Shortcode_My_Account::set_reset_password_cookie( $value );
- wp_safe_redirect(
- add_query_arg(
- array(
- 'show-reset-form' => 'true',
- 'action' => $action,
- ),
- wc_lostpassword_url()
- )
+ $action = isset( $_GET['action'] ) ? sanitize_text_field( wp_unslash( $_GET['action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $reset_key = wp_unslash( $_GET['key'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+ $value = sprintf( '%d:%s', $user_id, $reset_key );
+ $redirect_args = array(
+ 'show-reset-form' => 'true',
+ 'action' => $action,
);
+
+ $validated_user = $user ? check_password_reset_key( $reset_key, $user->user_login ) : null;
+ $bridge_token = WC_Shortcode_My_Account::create_password_reset_bridge_token( $validated_user );
+
+ if ( $bridge_token ) {
+ $redirect_args['reset-token'] = $bridge_token;
+ }
+
+ WC_Shortcode_My_Account::set_reset_password_cookie( $value );
+ nocache_headers();
+ wp_safe_redirect( add_query_arg( $redirect_args, wc_lostpassword_url() ) );
exit;
}
}
diff --git a/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-my-account.php b/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-my-account.php
index 5e3c7d2d781..1c704482f09 100644
--- a/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-my-account.php
+++ b/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-my-account.php
@@ -17,6 +17,26 @@ defined( 'ABSPATH' ) || exit;
*/
class WC_Shortcode_My_Account {
+ /**
+ * Signed password-reset form token version.
+ */
+ private const PASSWORD_RESET_FORM_TOKEN_VERSION = 'wc1';
+
+ /**
+ * Password-reset bridge lifetime in seconds.
+ */
+ private const PASSWORD_RESET_BRIDGE_EXPIRATION = 10 * MINUTE_IN_SECONDS;
+
+ /**
+ * Prefix for transient keys containing short-lived password-reset bridge state.
+ */
+ private const PASSWORD_RESET_BRIDGE_TRANSIENT_PREFIX = 'wc_password_reset_bridge_';
+
+ /**
+ * Character length of a password-reset bridge handle.
+ */
+ private const PASSWORD_RESET_BRIDGE_HANDLE_LENGTH = 32;
+
/**
* Get the shortcode content.
*
@@ -255,24 +275,52 @@ class WC_Shortcode_My_Account {
* Process reset key / login from email confirmation link
*/
} elseif ( ! empty( $_GET['show-reset-form'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only UI selector; login and path are normalized downstream.
- if ( isset( $_COOKIE[ 'wp-resetpass-' . COOKIEHASH ] ) && 0 < strpos( $_COOKIE[ 'wp-resetpass-' . COOKIEHASH ], ':' ) ) { // @codingStandardsIgnoreLine
- list( $rp_id, $rp_key ) = array_map( 'wc_clean', explode( ':', wp_unslash( $_COOKIE[ 'wp-resetpass-' . COOKIEHASH ] ), 2 ) ); // @codingStandardsIgnoreLine
+
+ /*
+ * Three sources can carry reset credentials onto this page, in this order of precedence:
+ * WordPress's own wp-resetpass-* cookie, a single-use bridge handle in the URL (used when
+ * the cookie is SameSite=Strict and so does not survive the click from the email client),
+ * and the signed token re-posted by the form when password validation rejects a submission.
+ */
+ $reset_cookie = isset( $_COOKIE[ 'wp-resetpass-' . COOKIEHASH ] ) ? wp_unslash( $_COOKIE[ 'wp-resetpass-' . COOKIEHASH ] ) : ''; // @codingStandardsIgnoreLine
+ $has_reset_cookie = is_string( $reset_cookie ) && 0 < strpos( $reset_cookie, ':' );
+ $bridge_handle = isset( $_GET['reset-token'] ) ? wc_clean( wp_unslash( $_GET['reset-token'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $has_bridge_handle = is_string( $bridge_handle ) && '' !== $bridge_handle;
+ $form_args = false;
+
+ if ( $has_reset_cookie ) {
+ list( $rp_id, $rp_key ) = array_map( 'wc_clean', explode( ':', $reset_cookie, 2 ) );
$userdata = get_userdata( absint( $rp_id ) );
$rp_login = $userdata ? $userdata->user_login : '';
- $user = self::check_password_reset_key( $rp_key, $rp_login );
// Reset key / login is correct, display reset password form with hidden key / login values.
- if ( is_object( $user ) ) {
- wc_get_template(
- 'myaccount/form-reset-password.php',
- array(
- 'key' => $rp_key,
- 'login' => $rp_login,
- )
+ if ( is_string( $rp_key ) && check_password_reset_key( $rp_key, $rp_login ) instanceof WP_User ) {
+ $form_args = array(
+ 'key' => $rp_key,
+ 'login' => $rp_login,
);
- return;
}
}
+
+ if ( $form_args ) {
+ // The cookie won, but a handle is single-use either way so it cannot be replayed.
+ self::discard_password_reset_bridge_token( $bridge_handle );
+ } else {
+ $form_args = self::consume_password_reset_bridge_token( $bridge_handle );
+
+ if ( ! $form_args ) {
+ $form_args = self::get_posted_password_reset_bridge_credentials();
+ }
+ }
+
+ if ( $form_args ) {
+ wc_get_template( 'myaccount/form-reset-password.php', $form_args );
+ return;
+ }
+
+ if ( $has_reset_cookie || $has_bridge_handle ) {
+ self::add_password_reset_key_error_notice();
+ }
}
// Show lost password form by default.
@@ -360,8 +408,13 @@ class WC_Shortcode_My_Account {
/**
* Retrieves a user row based on password reset key and login.
*
+ * Since 11.2.0 the key may also be a signed WooCommerce reset-form token, which is what the
+ * reset form carries when the WordPress reset cookie did not survive the click from the email
+ * client. Both formats are accepted so that anything reading the key handed to
+ * `myaccount/form-reset-password.php` keeps validating through this method.
+ *
* @uses $wpdb WordPress Database object.
- * @param string $key Hash to validate sending user's password.
+ * @param string $key WordPress password reset key, or a signed WooCommerce reset-form token.
* @param string $login The user login.
* @return WP_User|bool User's database row on success, false for invalid keys
*/
@@ -371,13 +424,320 @@ class WC_Shortcode_My_Account {
$user = check_password_reset_key( $key, $login );
if ( is_wp_error( $user ) ) {
- wc_add_notice( __( 'This key is invalid or has already been used. Please reset your password again if needed.', 'woocommerce' ), 'error' );
+ $user = self::get_password_reset_bridge_user( $key, $login );
+
+ if ( ! $user ) {
+ self::add_password_reset_key_error_notice();
+ return false;
+ }
+ }
+
+ return $user;
+ }
+
+ /**
+ * Create a short-lived random handle that bridges a valid reset link across a redirect.
+ *
+ * Only a keyed digest of the handle and a verifier for WordPress's current hashed reset
+ * state are stored. The handle is consumed when it is exchanged for a separate signed
+ * form token, so the rendered page URL is not a reusable password-reset credential.
+ *
+ * @since 11.2.0
+ * @internal
+ *
+ * @param WP_User|WP_Error|null $user User with an active WordPress password-reset key. Anything
+ * else yields an empty handle, so callers can pass the raw
+ * result of check_password_reset_key().
+ * @return string Random bridge handle, or an empty string when no reset state exists.
+ */
+ public static function create_password_reset_bridge_token( $user ) {
+ if ( ! $user instanceof WP_User || empty( $user->user_activation_key ) ) {
+ return '';
+ }
+
+ $now = time();
+ $expiration = min( $now + self::PASSWORD_RESET_BRIDGE_EXPIRATION, self::get_password_reset_state_expiration( $user ) );
+ if ( $expiration <= $now ) {
+ return '';
+ }
+
+ $payload = array(
+ 'user_id' => $user->ID,
+ 'expiration' => $expiration,
+ 'state_signature' => self::get_password_reset_state_signature( $user ),
+ );
+
+ for ( $attempt = 0; $attempt < 3; $attempt++ ) {
+ $handle = wp_generate_password( self::PASSWORD_RESET_BRIDGE_HANDLE_LENGTH, false, false );
+ $transient_name = self::get_password_reset_bridge_transient_name( $handle );
+
+ if ( false !== get_transient( $transient_name ) ) {
+ continue;
+ }
+
+ if ( set_transient( $transient_name, $payload, $expiration - $now ) ) {
+ return $handle;
+ }
+ }
+
+ wc_get_logger()->warning( 'Password reset bridge could not be stored; falling back to the cookie-only flow.', array( 'source' => 'password-reset' ) );
+
+ return '';
+ }
+
+ /**
+ * Check whether a value has the shape of a password-reset bridge handle.
+ *
+ * @since 11.2.0
+ * @internal
+ *
+ * @param mixed $handle Candidate handle.
+ * @return bool Whether the value is a well-formed handle.
+ */
+ public static function is_password_reset_bridge_handle( $handle ) {
+ return is_string( $handle )
+ && 1 === preg_match( '/^[A-Za-z0-9]{' . self::PASSWORD_RESET_BRIDGE_HANDLE_LENGTH . '}$/D', $handle );
+ }
+
+ /**
+ * Delete a bridge handle without exchanging it, so it cannot be replayed.
+ *
+ * @param mixed $handle URL bridge handle.
+ */
+ private static function discard_password_reset_bridge_token( $handle ): void {
+ if ( self::is_password_reset_bridge_handle( $handle ) ) {
+ delete_transient( self::get_password_reset_bridge_transient_name( $handle ) );
+ }
+ }
+
+ /**
+ * Consume a URL bridge handle and exchange it for signed form credentials.
+ *
+ * @param mixed $handle URL bridge handle.
+ * @return array{key: string, login: string}|false Form credentials on success, false otherwise.
+ */
+ private static function consume_password_reset_bridge_token( $handle ) {
+ if ( ! self::is_password_reset_bridge_handle( $handle ) ) {
+ return false;
+ }
+
+ $transient_name = self::get_password_reset_bridge_transient_name( $handle );
+ $payload = get_transient( $transient_name );
+
+ // Only the request whose delete actually removed the row may exchange the handle.
+ if ( ! delete_transient( $transient_name ) ) {
+ return false;
+ }
+
+ if (
+ ! is_array( $payload ) ||
+ ! isset( $payload['user_id'], $payload['expiration'], $payload['state_signature'] ) ||
+ ! is_string( $payload['state_signature'] )
+ ) {
+ return false;
+ }
+
+ $expiration = absint( $payload['expiration'] );
+ $user = self::get_eligible_password_reset_user( absint( $payload['user_id'] ), $expiration );
+
+ if ( ! $user ) {
+ return false;
+ }
+
+ if ( ! hash_equals( self::get_password_reset_state_signature( $user ), $payload['state_signature'] ) ) {
+ return false;
+ }
+
+ // Old-style keys carry no timestamp and keep the bridge's short window; everything else lasts as long as the WordPress key.
+ $form_expiration = self::get_password_reset_state_expiration( $user );
+ if ( PHP_INT_MAX === $form_expiration ) {
+ $form_expiration = $expiration;
+ }
+
+ return array(
+ 'key' => self::create_password_reset_form_token( $user, $form_expiration ),
+ 'login' => $user->user_login,
+ );
+ }
+
+ /**
+ * Resolve the user a bridge claim refers to, while the reset state and the viewer still allow it.
+ *
+ * @param int $user_id Claimed user ID.
+ * @param int $expiration Claimed Unix expiry timestamp.
+ * @param string $login Optional login the claim must match.
+ * @return WP_User|false User on success, false otherwise.
+ */
+ private static function get_eligible_password_reset_user( $user_id, $expiration, $login = '' ) {
+ $user = get_userdata( $user_id );
+
+ if ( ! $user || empty( $user->user_activation_key ) || ( $login && $login !== $user->user_login ) ) {
+ return false;
+ }
+
+ $now = time();
+ if ( $expiration <= $now || self::get_password_reset_state_expiration( $user ) <= $now ) {
+ return false;
+ }
+
+ $logged_in_user_id = get_current_user_id();
+ if ( $logged_in_user_id && $logged_in_user_id !== $user_id ) {
return false;
}
return $user;
}
+ /**
+ * Recover validated form credentials after password validation leaves the POST in place.
+ *
+ * @return array{key: string, login: string}|false Form credentials on success, false otherwise.
+ */
+ private static function get_posted_password_reset_bridge_credentials() {
+ $nonce_value = wc_get_var( $_POST['woocommerce-reset-password-nonce'], wc_get_var( $_POST['_wpnonce'], '' ) ); // @codingStandardsIgnoreLine.
+
+ if ( ! wp_verify_nonce( $nonce_value, 'reset_password' ) || ! isset( $_POST['reset_key'], $_POST['reset_login'] ) ) {
+ return false;
+ }
+
+ $token = wc_clean( wp_unslash( $_POST['reset_key'] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+ $login = sanitize_user( wp_unslash( $_POST['reset_login'] ) );
+ $user = is_string( $token ) ? self::get_password_reset_bridge_user( $token, $login ) : false;
+
+ if ( ! $user ) {
+ return false;
+ }
+
+ return array(
+ 'key' => $token,
+ 'login' => $user->user_login,
+ );
+ }
+
+ /**
+ * Create the signed token submitted by the reset-password form.
+ *
+ * @param WP_User $user Token owner.
+ * @param int $expiration Unix expiry timestamp.
+ * @return string Signed form token.
+ */
+ private static function create_password_reset_form_token( $user, $expiration ) {
+ $nonce = wp_generate_password( 32, false, false );
+ $signature = self::get_password_reset_bridge_signature( $user, $expiration, $nonce );
+
+ return implode( '.', array( self::PASSWORD_RESET_FORM_TOKEN_VERSION, $user->ID, $expiration, $nonce, $signature ) );
+ }
+
+ /**
+ * Resolve and validate a signed password-reset bridge token.
+ *
+ * @param string $token Signed bridge token.
+ * @param string $login Optional login to bind during form submission.
+ * @return WP_User|false User on success, false otherwise.
+ */
+ private static function get_password_reset_bridge_user( $token, $login = '' ) {
+ $pattern = '/^' . self::PASSWORD_RESET_FORM_TOKEN_VERSION . '\.([1-9][0-9]*)\.([0-9]+)\.([A-Za-z0-9]{32})\.([a-f0-9]{64})$/D';
+ if ( ! is_string( $token ) || ! preg_match( $pattern, $token, $matches ) ) {
+ return false;
+ }
+
+ $user_id = absint( $matches[1] );
+ $expiration = absint( $matches[2] );
+ $nonce = $matches[3];
+ $signature = $matches[4];
+
+ $user = self::get_eligible_password_reset_user( $user_id, $expiration, $login );
+ if ( ! $user ) {
+ return false;
+ }
+
+ if ( ! hash_equals( self::get_password_reset_bridge_signature( $user, $expiration, $nonce ), $signature ) ) {
+ return false;
+ }
+
+ return $user;
+ }
+
+ /**
+ * Sign bridge claims together with WordPress's current hashed reset state.
+ *
+ * @param WP_User $user Token owner.
+ * @param int $expiration Unix expiry timestamp.
+ * @param string $nonce Random token nonce.
+ * @return string HMAC signature.
+ */
+ private static function get_password_reset_bridge_signature( $user, $expiration, $nonce ) {
+ return self::sign_password_reset_claims( array( self::PASSWORD_RESET_FORM_TOKEN_VERSION, $user->ID, $expiration, $nonce, $user->user_activation_key ) );
+ }
+
+ /**
+ * Sign password-reset bridge claims with the site's nonce salt.
+ *
+ * @param array<int, int|string> $claims Claims to sign.
+ * @return string HMAC signature.
+ */
+ private static function sign_password_reset_claims( $claims ) {
+ return hash_hmac( 'sha256', implode( '|', $claims ), wp_salt( 'nonce' ) );
+ }
+
+ /**
+ * Get the transient name for a bridge handle without storing the raw handle.
+ *
+ * @param string $handle URL bridge handle.
+ * @return string Transient name.
+ */
+ private static function get_password_reset_bridge_transient_name( $handle ) {
+ return self::PASSWORD_RESET_BRIDGE_TRANSIENT_PREFIX . self::sign_password_reset_claims( array( $handle ) );
+ }
+
+ /**
+ * Sign the reset state persisted in the short-lived bridge payload.
+ *
+ * @param WP_User $user Token owner.
+ * @return string Reset-state signature.
+ */
+ private static function get_password_reset_state_signature( $user ) {
+ return self::sign_password_reset_claims( array( $user->ID, $user->user_activation_key ) );
+ }
+
+ /**
+ * Get the current WordPress expiration for a user's hashed reset state.
+ *
+ * Old-style activation keys do not carry a request timestamp. WordPress may still
+ * accept those through the password_reset_key_expired filter, so the bridge's own
+ * short expiry remains the limit for that compatibility path.
+ *
+ * @param WP_User $user Token owner.
+ * @return int Unix expiry timestamp.
+ */
+ private static function get_password_reset_state_expiration( $user ) {
+ if ( false === strpos( $user->user_activation_key, ':' ) ) {
+ return PHP_INT_MAX;
+ }
+
+ list( $request_time ) = explode( ':', $user->user_activation_key, 2 );
+ if ( ! ctype_digit( $request_time ) ) {
+ return 0;
+ }
+
+ // This filter is documented in WordPress core.
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
+ $expiration_duration = (int) apply_filters( 'password_reset_expiration', DAY_IN_SECONDS );
+
+ return (int) $request_time + max( 0, $expiration_duration );
+ }
+
+ /**
+ * Add the standard invalid password-reset key notice.
+ */
+ private static function add_password_reset_key_error_notice(): void {
+ $message = __( 'This key is invalid or has already been used. Please reset your password again if needed.', 'woocommerce' );
+
+ if ( ! wc_has_notice( $message, 'error' ) ) {
+ wc_add_notice( $message, 'error' );
+ }
+ }
+
/**
* Handles resetting the user's password.
*
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index e515c8ab6cf..5bbc4147b56 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -31182,7 +31182,7 @@ parameters:
-
message: '#^Constant COOKIEHASH not found\.$#'
identifier: constant.notFound
- count: 4
+ count: 3
path: includes/shortcodes/class-wc-shortcode-my-account.php
-
@@ -31245,12 +31245,6 @@ parameters:
count: 1
path: includes/shortcodes/class-wc-shortcode-my-account.php
- -
- message: '#^Parameter \#1 \$key of static method WC_Shortcode_My_Account\:\:check_password_reset_key\(\) expects string, array\|string given\.$#'
- identifier: argument.type
- count: 1
- path: includes/shortcodes/class-wc-shortcode-my-account.php
-
-
message: '#^Method WC_Shortcode_Order_Tracking\:\:output\(\) has no return type specified\.$#'
identifier: missingType.return
diff --git a/plugins/woocommerce/tests/php/includes/shortcodes/class-wc-shortcode-my-account-password-reset-test.php b/plugins/woocommerce/tests/php/includes/shortcodes/class-wc-shortcode-my-account-password-reset-test.php
new file mode 100644
index 00000000000..38dd820fc5d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/shortcodes/class-wc-shortcode-my-account-password-reset-test.php
@@ -0,0 +1,766 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * Tests for the My Account password-reset flow.
+ *
+ * @package WooCommerce\Tests\Shortcodes
+ */
+
+/**
+ * Class WC_Shortcode_My_Account_Password_Reset_Test.
+ */
+class WC_Shortcode_My_Account_Password_Reset_Test extends WC_Unit_Test_Case {
+
+ /**
+ * Test customer.
+ *
+ * @var WP_User
+ */
+ private WP_User $user;
+
+ /**
+ * Test customer's WordPress password-reset key.
+ *
+ * @var string
+ */
+ private string $reset_key;
+
+ /**
+ * Set up a customer with an active WordPress password-reset key.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $user_id = self::factory()->user->create(
+ array(
+ 'role' => 'customer',
+ 'user_login' => 'reset-bridge-customer',
+ )
+ );
+
+ $user = new WP_User( $user_id );
+ $reset_key = get_password_reset_key( $user );
+ $this->assertIsString( $reset_key );
+ $this->reset_key = $reset_key;
+ // Refetched so user_activation_key, written by get_password_reset_key(), is populated.
+ $this->user = new WP_User( $user_id );
+
+ $this->reset_request_state();
+ }
+
+ /**
+ * Clean up request state.
+ */
+ public function tearDown(): void {
+ $this->reset_request_state();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Return the request to a signed-out state with no reset credentials or notices.
+ */
+ private function reset_request_state(): void {
+ wp_set_current_user( 0 );
+ wc_clear_notices();
+ $_GET = array();
+ $_POST = array();
+ $_REQUEST = array();
+ $_COOKIE = array();
+ }
+
+ /**
+ * @testdox A URL handle is consumed and exchanged for a signed form token.
+ */
+ public function test_url_handle_exchanges_for_signed_form_token(): void {
+ $handle = WC_Shortcode_My_Account::create_password_reset_bridge_token( $this->user );
+ $template = $this->render_bridge_handle( $handle );
+ $token = $template['args']['key'];
+
+ $this->assertMatchesRegularExpression( '/^[A-Za-z0-9]{32}$/', $handle );
+ $this->assertMatchesRegularExpression( '/^wc1\.[1-9][0-9]*\.[0-9]+\.[A-Za-z0-9]{32}\.[a-f0-9]{64}$/', $token );
+ $this->assertNotSame( $handle, $token );
+ $this->assertSame( $this->user->ID, WC_Shortcode_My_Account::check_password_reset_key( $token, $this->user->user_login )->ID );
+ }
+
+ /**
+ * @testdox The bridge renders the reset form when a Strict cookie is unavailable.
+ */
+ public function test_bridge_renders_reset_form_without_cookie(): void {
+ $handle = WC_Shortcode_My_Account::create_password_reset_bridge_token( $this->user );
+ $template = $this->render_bridge_handle( $handle );
+
+ $this->assertSame( 'myaccount/form-reset-password.php', $template['name'] );
+ $this->assertNotSame( $handle, $template['args']['key'] );
+ $this->assertSame( $this->user->user_login, $template['args']['login'] );
+ }
+
+ /**
+ * @testdox A different well-formed handle cannot consume another bridge.
+ */
+ public function test_unrelated_handle_cannot_consume_valid_bridge(): void {
+ $handle = WC_Shortcode_My_Account::create_password_reset_bridge_token( $this->user );
+ $unrelated_handle = ( 'a' === $handle[0] ? 'b' : 'a' ) . substr( $handle, 1 );
+
+ $unrelated_template = $this->render_bridge_handle( $unrelated_handle );
+ $this->assertSame( 'myaccount/form-lost-password.php', $unrelated_template['name'] );
+
+ wc_clear_notices();
+ $valid_template = $this->render_bridge_handle( $handle );
+ $this->assertSame( 'myaccount/form-reset-password.php', $valid_template['name'] );
+ }
+
+ /**
+ * @testdox A stale well-formed cookie does not suppress a valid bridge.
+ */
+ public function test_stale_cookie_falls_back_to_valid_bridge(): void {
+ $handle = WC_Shortcode_My_Account::create_password_reset_bridge_token( $this->user );
+
+ $_GET = array(
+ 'show-reset-form' => 'true',
+ 'reset-token' => $handle,
+ );
+
+ $_COOKIE[ 'wp-resetpass-' . COOKIEHASH ] = $this->user->ID . ':stale-reset-key';
+
+ $template = $this->render_lost_password_page();
+
+ $this->assertSame( 'myaccount/form-reset-password.php', $template['name'] );
+ $this->assertNotSame( $handle, $template['args']['key'] );
+ $this->assertSame( $this->user->ID, WC_Shortcode_My_Account::check_password_reset_key( $template['args']['key'], $this->user->user_login )->ID );
+ $this->assertSame( 0, wc_notice_count( 'error' ) );
+ }
+
+ /**
+ * @testdox A valid WordPress reset cookie keeps precedence over a valid bridge.
+ */
+ public function test_valid_cookie_takes_precedence_over_valid_bridge(): void {
+ $other_user_id = self::factory()->user->create(
+ array(
+ 'role' => 'customer',
+ 'user_login' => 'cookie-reset-customer',
+ )
+ );
+ $other_user = get_userdata( $other_user_id );
+ $other_key = get_password_reset_key( $other_user );
+ $handle = WC_Shortcode_My_Account::create_password_reset_bridge_token( $this->user );
+
+ $_GET = array(
+ 'show-reset-form' => 'true',
+ 'reset-token' => $handle,
+ );
+
+ $_COOKIE[ 'wp-resetpass-' . COOKIEHASH ] = $other_user_id . ':' . $other_key;
+
+ $template = $this->render_lost_password_page();
+
+ $this->assertSame( $other_key, $template['args']['key'] );
+ $this->assertSame( $other_user->user_login, $template['args']['login'] );
+
+ $_COOKIE = array();
+ wc_clear_notices();
+ $replay = $this->render_lost_password_page();
+ $this->assertSame( 'myaccount/form-lost-password.php', $replay['name'] );
+ }
+
+ /**
+ * @testdox A tampered signature fails closed.
+ */
+ public function test_tampered_bridge_token_fails_closed(): void {
+ $token = $this->create_exchanged_form_token();
+ $tampered_token = substr( $token, 0, -1 ) . ( 'a' === substr( $token, -1 ) ? 'b' : 'a' );
+
+ $this->assertFalse( WC_Shortcode_My_Account::check_password_reset_key( $tampered_token, $this->user->user_login ) );
+ }
+
+ /**
+ * @testdox An expired claim fails closed.
+ */
+ public function test_expired_bridge_token_fails_closed(): void {
+ $expired_token = $this->create_signed_token( $this->user, time() - 1 );
+
+ $this->assertFalse( WC_Shortcode_My_Account::check_password_reset_key( $expired_token, $this->user->user_login ) );
+ }
+
+ /**
+ * @testdox A token bound to a different login fails closed.
+ */
+ public function test_bridge_token_bound_to_other_login_fails_closed(): void {
+ $token = $this->create_exchanged_form_token();
+
+ $this->assertFalse( WC_Shortcode_My_Account::check_password_reset_key( $token, 'another-customer' ) );
+ }
+
+ /**
+ * @testdox A shortened WordPress reset policy fails an otherwise valid token closed.
+ */
+ public function test_shortened_reset_policy_fails_bridge_token_closed(): void {
+ $token = $this->create_exchanged_form_token();
+
+ add_filter( 'password_reset_expiration', '__return_zero' );
+ try {
+ $this->assertFalse( WC_Shortcode_My_Account::check_password_reset_key( $token, $this->user->user_login ) );
+ } finally {
+ remove_filter( 'password_reset_expiration', '__return_zero' );
+ }
+ }
+
+ /**
+ * @testdox A different logged-in user cannot consume another account's handle.
+ */
+ public function test_logged_in_user_cannot_consume_another_users_handle(): void {
+ $handle = WC_Shortcode_My_Account::create_password_reset_bridge_token( $this->user );
+ $other_user_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+ wp_set_current_user( $other_user_id );
+
+ $mismatched_template = $this->render_bridge_handle( $handle );
+ $this->assertSame( 'myaccount/form-lost-password.php', $mismatched_template['name'] );
+
+ wp_set_current_user( 0 );
+ wc_clear_notices();
+ $replayed_template = $this->render_bridge_handle( $handle );
+ $this->assertSame( 'myaccount/form-lost-password.php', $replayed_template['name'] );
+ }
+
+ /**
+ * @testdox A different logged-in user cannot validate another account's form token.
+ */
+ public function test_logged_in_user_cannot_validate_another_users_form_token(): void {
+ $handle = WC_Shortcode_My_Account::create_password_reset_bridge_token( $this->user );
+ $template = $this->render_bridge_handle( $handle );
+ $token = $template['args']['key'];
+ $other_user_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+ wp_set_current_user( $other_user_id );
+
+ $this->assertFalse( WC_Shortcode_My_Account::check_password_reset_key( $token, $this->user->user_login ) );
+
+ wp_set_current_user( 0 );
+ wc_clear_notices();
+ $this->assertSame( $this->user->ID, WC_Shortcode_My_Account::check_password_reset_key( $token, $this->user->user_login )->ID );
+ }
+
+ /**
+ * @testdox Changing the password invalidates the bridge with WordPress reset state.
+ */
+ public function test_password_change_invalidates_bridge(): void {
+ $handle = WC_Shortcode_My_Account::create_password_reset_bridge_token( $this->user );
+ $template = $this->render_bridge_handle( $handle );
+ $token = $template['args']['key'];
+
+ wp_set_password( 'new-secure-password', $this->user->ID );
+
+ $this->assertFalse( WC_Shortcode_My_Account::check_password_reset_key( $token, $this->user->user_login ) );
+ }
+
+ /**
+ * @testdox A reset-key rotation between validation and storage invalidates the bridge.
+ */
+ public function test_reset_key_rotation_invalidates_validated_snapshot_bridge(): void {
+ $validated_user = new WP_User( $this->user->ID );
+ $new_key = get_password_reset_key( new WP_User( $this->user->ID ) );
+ $this->assertIsString( $new_key );
+
+ $handle = WC_Shortcode_My_Account::create_password_reset_bridge_token( $validated_user );
+ $template = $this->render_bridge_handle( $handle );
+
+ $this->assertSame( 'myaccount/form-lost-password.php', $template['name'] );
+ }
+
+ /**
+ * @testdox Bridge expiry honors the ten-minute bound and a shorter WordPress policy.
+ */
+ public function test_bridge_expiration_honors_both_lifetime_bounds(): void {
+ $transient_expirations = array();
+ $short_expiration = static function (): int {
+ return 30;
+ };
+ $capture_transient_expiration = static function ( string $transient, $value, int $expiration ) use ( &$transient_expirations ): void {
+ unset( $transient, $value );
+ $transient_expirations[] = $expiration;
+ };
+
+ add_action( 'set_transient', $capture_transient_expiration, 10, 3 );
+ try {
+ $default_handle = WC_Shortcode_My_Account::create_password_reset_bridge_token( $this->user );
+ $default_template = $this->render_bridge_handle( $default_handle );
+ $default_claims = explode( '.', $default_template['args']['key'] );
+
+ add_filter( 'password_reset_expiration', $short_expiration );
+ try {
+ $short_handle = WC_Shortcode_My_Account::create_password_reset_bridge_token( $this->user );
+ $short_template = $this->render_bridge_handle( $short_handle );
+ $short_claims = explode( '.', $short_template['args']['key'] );
+ } finally {
+ remove_filter( 'password_reset_expiration', $short_expiration );
+ }
+ } finally {
+ remove_action( 'set_transient', $capture_transient_expiration, 10 );
+ }
+
+ $this->assertCount( 2, $transient_expirations );
+ $this->assertGreaterThan( 0, $transient_expirations[0] );
+ $this->assertLessThanOrEqual( 10 * MINUTE_IN_SECONDS, $transient_expirations[0] );
+ $this->assertLessThanOrEqual( 30, $transient_expirations[1] );
+ $this->assertGreaterThan( time() + 10 * MINUTE_IN_SECONDS, (int) $default_claims[2] );
+ $this->assertLessThanOrEqual( time() + DAY_IN_SECONDS, (int) $default_claims[2] );
+ $this->assertLessThanOrEqual( time() + 30, (int) $short_claims[2] );
+ $this->assertLessThan( (int) $default_claims[2], (int) $short_claims[2] );
+ }
+
+ /**
+ * @testdox A core-approved old-style reset key exchanges a bridge that keeps the short window.
+ */
+ public function test_old_style_reset_key_compatibility(): void {
+ $old_style_key = 'oldstyleresetkey';
+ $result = wp_update_user(
+ array(
+ 'ID' => $this->user->ID,
+ 'user_activation_key' => $old_style_key,
+ )
+ );
+ $this->assertSame( $this->user->ID, $result );
+
+ $accept_old_style_key = static function ( WP_Error $error, int $user_id ) {
+ unset( $error );
+ return get_userdata( $user_id );
+ };
+ add_filter( 'password_reset_key_expired', $accept_old_style_key, 10, 2 );
+ try {
+ $validated_user = check_password_reset_key( $old_style_key, $this->user->user_login );
+ $this->assertInstanceOf( WP_User::class, $validated_user );
+ $handle = WC_Shortcode_My_Account::create_password_reset_bridge_token( $validated_user );
+ } finally {
+ remove_filter( 'password_reset_key_expired', $accept_old_style_key, 10 );
+ }
+
+ $template = $this->render_bridge_handle( $handle );
+ $this->assertSame( 'myaccount/form-reset-password.php', $template['name'] );
+ $this->assertSame( $this->user->ID, WC_Shortcode_My_Account::check_password_reset_key( $template['args']['key'], $this->user->user_login )->ID );
+
+ // An old-style key carries no request timestamp, so its form token cannot borrow the WordPress key's lifetime.
+ $claims = explode( '.', $template['args']['key'] );
+ $this->assertLessThanOrEqual( time() + 10 * MINUTE_IN_SECONDS, (int) $claims[2] );
+ }
+
+ /**
+ * @testdox Password validation errors preserve the signed reset form after handle consumption.
+ */
+ public function test_password_validation_error_preserves_reset_form(): void {
+ $handle = WC_Shortcode_My_Account::create_password_reset_bridge_token( $this->user );
+ $template = $this->render_bridge_handle( $handle );
+ $token = $template['args']['key'];
+ $nonce = wp_create_nonce( 'reset_password' );
+
+ $_POST = array(
+ 'woocommerce-reset-password-nonce' => $nonce,
+ '_wpnonce' => 'extension-nonce-that-must-not-win',
+ 'wc_reset_password' => 'true',
+ 'password_1' => 'first-password',
+ 'password_2' => 'different-password',
+ 'reset_key' => $token,
+ 'reset_login' => $this->user->user_login,
+ );
+ // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Build the request superglobal consumed by the form handler.
+ $_REQUEST = $_POST;
+
+ WC_Form_Handler::process_reset_password();
+ $this->assertSame( 1, wc_notice_count( 'error' ) );
+
+ $rerendered = $this->render_lost_password_page();
+ $this->assertSame( 'myaccount/form-reset-password.php', $rerendered['name'] );
+ $this->assertSame( $token, $rerendered['args']['key'] );
+ $this->assertSame( $this->user->user_login, $rerendered['args']['login'] );
+ }
+
+ /**
+ * @testdox A handle whose row disappears mid-read cannot be exchanged.
+ */
+ public function test_bridge_handle_losing_the_consume_race_is_not_exchanged(): void {
+ $transient_name = '';
+ $capture_name = static function ( string $transient ) use ( &$transient_name ): void {
+ $transient_name = $transient_name ? $transient_name : $transient;
+ };
+
+ add_action( 'set_transient', $capture_name, 10, 1 );
+ $handle = WC_Shortcode_My_Account::create_password_reset_bridge_token( $this->user );
+ $this->assertNotSame( '', $transient_name );
+
+ /*
+ * Stand in for a second request that read the same payload and got its delete in first:
+ * the row is gone by the time this request tries to consume the handle.
+ */
+ $delete_between_read_and_delete = static function ( $value ) use ( $transient_name ) {
+ delete_transient( $transient_name );
+ return $value;
+ };
+ add_filter( "transient_{$transient_name}", $delete_between_read_and_delete );
+
+ $template = $this->render_bridge_handle( $handle );
+
+ $this->assertSame( 'myaccount/form-lost-password.php', $template['name'] );
+ }
+
+ /**
+ * @testdox A rejected submission carrying a spent handle shows the invalid-key notice once.
+ */
+ public function test_rejected_submission_with_spent_handle_notices_once(): void {
+ $handle = WC_Shortcode_My_Account::create_password_reset_bridge_token( $this->user );
+ $template = $this->render_bridge_handle( $handle );
+ $token = $template['args']['key'];
+ $tampered = substr( $token, 0, -1 ) . ( 'a' === substr( $token, -1 ) ? 'b' : 'a' );
+
+ $_POST = array(
+ 'woocommerce-reset-password-nonce' => wp_create_nonce( 'reset_password' ),
+ 'wc_reset_password' => 'true',
+ 'password_1' => 'a-valid-password',
+ 'password_2' => 'a-valid-password',
+ 'reset_key' => $tampered,
+ 'reset_login' => $this->user->user_login,
+ );
+ // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Build the request superglobal consumed by the form handler.
+ $_REQUEST = $_POST;
+
+ WC_Form_Handler::process_reset_password();
+ $this->assertSame( 1, wc_notice_count( 'error' ) );
+
+ // The first render spent the handle, so the page cannot recover the form either.
+ $_GET = array(
+ 'show-reset-form' => 'true',
+ 'reset-token' => $handle,
+ );
+
+ // Counted while the page renders, because printing the notices empties the queue.
+ $rendered_error_count = null;
+ $count_errors = static function () use ( &$rendered_error_count ): void {
+ $rendered_error_count ??= wc_notice_count( 'error' );
+ };
+ add_action( 'woocommerce_before_template_part', $count_errors, 10, 0 );
+
+ $rerendered = $this->render_lost_password_page();
+
+ $this->assertSame( 'myaccount/form-lost-password.php', $rerendered['name'] );
+ $this->assertSame( 1, $rendered_error_count, 'The form handler and the page should not each add the invalid-key notice.' );
+ }
+
+ /**
+ * @testdox Invalid nonce candidates cannot recover a consumed bridge form.
+ */
+ public function test_invalid_nonces_do_not_recover_consumed_bridge_form(): void {
+ $handle = WC_Shortcode_My_Account::create_password_reset_bridge_token( $this->user );
+ $template = $this->render_bridge_handle( $handle );
+ $token = $template['args']['key'];
+
+ $_GET = array( 'show-reset-form' => 'true' );
+ $_POST = array(
+ 'woocommerce-reset-password-nonce' => 'invalid-woocommerce-nonce',
+ '_wpnonce' => 'invalid-generic-nonce',
+ 'reset_key' => $token,
+ 'reset_login' => $this->user->user_login,
+ );
+
+ $rerendered = $this->render_lost_password_page();
+ $this->assertSame( 'myaccount/form-lost-password.php', $rerendered['name'] );
+ }
+
+ /**
+ * @testdox Creating a bridge does not persist the URL token or plaintext reset key.
+ */
+ public function test_bridge_credentials_are_not_persisted(): void {
+ global $wpdb;
+
+ $token = WC_Shortcode_My_Account::create_password_reset_bridge_token( $this->user );
+ $user = new WP_User( $this->user->ID );
+ $tables = array(
+ $wpdb->options => 'option_value',
+ $wpdb->usermeta => 'meta_value',
+ );
+
+ foreach ( $tables as $table => $value_column ) {
+ $token_count = (int) $wpdb->get_var(
+ $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE {$value_column} LIKE %s", '%' . $wpdb->esc_like( $token ) . '%' ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ );
+ $key_count = (int) $wpdb->get_var(
+ $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE {$value_column} LIKE %s", '%' . $wpdb->esc_like( $this->reset_key ) . '%' ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ );
+ $state_count = (int) $wpdb->get_var(
+ $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE {$value_column} LIKE %s", '%' . $wpdb->esc_like( $user->user_activation_key ) . '%' ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ );
+
+ $this->assertSame( 0, $token_count, "The bridge token must not be stored in {$table}." );
+ $this->assertSame( 0, $key_count, "The plaintext reset key must not be stored in {$table}." );
+ $this->assertSame( 0, $state_count, "The hashed reset state must not be duplicated in {$table}." );
+ }
+ }
+
+ /**
+ * @testdox The legacy reset link redirects with a bridge and without its original credentials.
+ */
+ public function test_legacy_reset_link_redirects_with_bridge(): void {
+ $identifiers = array(
+ 'id' => (string) $this->user->ID,
+ 'login' => $this->user->user_login,
+ );
+
+ foreach ( $identifiers as $name => $identifier ) {
+ $_GET = array(
+ 'key' => $this->reset_key,
+ $name => $identifier,
+ 'action' => 'rp',
+ );
+
+ $location = $this->intercept_reset_link_redirect();
+ $query = wp_parse_url( $location, PHP_URL_QUERY );
+ parse_str( is_string( $query ) ? $query : '', $args );
+
+ $this->assertSame( 'true', $args['show-reset-form'] );
+ $this->assertSame( 'rp', $args['action'] );
+ $this->assertArrayHasKey( 'reset-token', $args );
+ $this->assertArrayNotHasKey( 'key', $args );
+ $this->assertArrayNotHasKey( 'id', $args );
+ $this->assertArrayNotHasKey( 'login', $args );
+ $this->assertMatchesRegularExpression( '/^[A-Za-z0-9]{32}$/', $args['reset-token'] );
+ $template = $this->render_bridge_handle( $args['reset-token'] );
+ $this->assertSame( $this->user->ID, WC_Shortcode_My_Account::check_password_reset_key( $template['args']['key'], $this->user->user_login )->ID );
+ }
+ }
+
+ /**
+ * @testdox Invalid legacy credentials do not receive a signed bridge.
+ */
+ public function test_invalid_legacy_reset_link_does_not_receive_bridge(): void {
+ $_GET = array(
+ 'key' => 'invalid-reset-key',
+ 'id' => (string) $this->user->ID,
+ );
+
+ $location = $this->intercept_reset_link_redirect();
+ $query = wp_parse_url( $location, PHP_URL_QUERY );
+ parse_str( is_string( $query ) ? $query : '', $args );
+
+ $this->assertArrayNotHasKey( 'reset-token', $args );
+ }
+
+ /**
+ * @testdox An array-valued legacy reset key is rejected before redirecting.
+ */
+ public function test_array_legacy_reset_key_is_rejected_before_redirect(): void {
+ $_GET = array(
+ 'key' => array( 'malformed' ),
+ 'id' => (string) $this->user->ID,
+ );
+
+ $this->assertSame( '', $this->intercept_reset_link_redirect() );
+ }
+
+ /**
+ * @testdox A logged-in user cannot create a bridge for another account.
+ */
+ public function test_logged_in_user_mismatch_still_stops_redirect(): void {
+ $other_user_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+ wp_set_current_user( $other_user_id );
+
+ $_GET = array(
+ 'key' => $this->reset_key,
+ 'id' => (string) $this->user->ID,
+ );
+
+ $redirected = false;
+ $capture = static function () use ( &$redirected ): void {
+ $redirected = true;
+ };
+
+ add_filter( 'wp_redirect', $capture );
+ try {
+ $this->on_account_page( array( WC_Form_Handler::class, 'redirect_reset_password_link' ) );
+ } finally {
+ remove_filter( 'wp_redirect', $capture );
+ }
+
+ $this->assertFalse( $redirected );
+ $this->assertSame( 1, wc_notice_count( 'error' ) );
+ }
+
+ /**
+ * @testdox Bridge responses prevent storage and referrer disclosure.
+ */
+ public function test_bridge_response_headers_are_private(): void {
+ $original_headers = array( 'Cache-Control' => 'public, max-age=600' );
+
+ $_GET = array();
+ $this->assertSame( $original_headers, WC_Form_Handler::set_reset_password_bridge_headers( $original_headers ) );
+
+ $_GET = array(
+ 'show-reset-form' => 'true',
+ 'reset-token' => str_repeat( 'a', 32 ),
+ );
+
+ $this->assertSame( $original_headers, WC_Form_Handler::set_reset_password_bridge_headers( $original_headers ) );
+
+ $headers = $this->on_account_page(
+ static fn() => WC_Form_Handler::set_reset_password_bridge_headers( $original_headers )
+ );
+
+ $this->assertStringContainsString( 'no-store', $headers['Cache-Control'] );
+ $this->assertStringContainsString( 'private', $headers['Cache-Control'] );
+ $this->assertSame( 'no-referrer', $headers['Referrer-Policy'] );
+ }
+
+ /**
+ * @testdox Malformed bridge handles do not receive private reset headers.
+ * @dataProvider malformed_bridge_handle_provider
+ *
+ * @param string $handle Malformed bridge handle.
+ */
+ public function test_malformed_bridge_handles_do_not_receive_private_headers( string $handle ): void {
+ $original_headers = array( 'Cache-Control' => 'public, max-age=600' );
+ $_GET = array(
+ 'show-reset-form' => 'true',
+ 'reset-token' => $handle,
+ );
+
+ $headers = $this->on_account_page(
+ static fn() => WC_Form_Handler::set_reset_password_bridge_headers( $original_headers )
+ );
+
+ $this->assertSame( $original_headers, $headers );
+ }
+
+ /**
+ * Provide malformed bridge handles with extra leading or trailing characters.
+ *
+ * @return array<string, array{string}>
+ */
+ public function malformed_bridge_handle_provider(): array {
+ return array(
+ 'extra leading character' => array( '!' . str_repeat( 'a', 32 ) ),
+ 'extra trailing character' => array( str_repeat( 'a', 32 ) . '!' ),
+ );
+ }
+
+ /**
+ * Render the lost-password page and capture the selected template arguments.
+ *
+ * @return array{name: string, args: array<string, mixed>}
+ */
+ private function render_lost_password_page(): array {
+ $rendered = array(
+ 'name' => '',
+ 'args' => array(),
+ );
+ $capture = static function ( string $template_name, string $template_path, string $located, array $args ) use ( &$rendered ): void {
+ unset( $template_path, $located );
+ if ( 0 !== strpos( $template_name, 'myaccount/' ) ) {
+ return;
+ }
+
+ $rendered = array(
+ 'name' => $template_name,
+ 'args' => $args,
+ );
+ };
+
+ add_action( 'woocommerce_before_template_part', $capture, 10, 4 );
+ try {
+ ob_start();
+ WC_Shortcode_My_Account::lost_password();
+ ob_end_clean();
+ } finally {
+ remove_action( 'woocommerce_before_template_part', $capture, 10 );
+ }
+
+ return $rendered;
+ }
+
+ /**
+ * Run a callback with WooCommerce treating the request as an account page.
+ *
+ * @param callable $callback Callback to run.
+ * @return mixed The callback's return value.
+ */
+ private function on_account_page( callable $callback ) {
+ add_filter( 'woocommerce_is_account_page', '__return_true' );
+ try {
+ return $callback();
+ } finally {
+ remove_filter( 'woocommerce_is_account_page', '__return_true' );
+ }
+ }
+
+ /**
+ * Mint a bridge handle and exchange it for the signed form token the reset form carries.
+ *
+ * @return string Signed form token.
+ */
+ private function create_exchanged_form_token(): string {
+ $handle = WC_Shortcode_My_Account::create_password_reset_bridge_token( $this->user );
+ $template = $this->render_bridge_handle( $handle );
+
+ return $template['args']['key'];
+ }
+
+ /**
+ * Consume a URL bridge handle and capture the resulting form.
+ *
+ * @param string $handle URL bridge handle.
+ * @return array{name: string, args: array<string, mixed>}
+ */
+ private function render_bridge_handle( string $handle ): array {
+ $_GET = array(
+ 'show-reset-form' => 'true',
+ 'reset-token' => $handle,
+ );
+
+ return $this->render_lost_password_page();
+ }
+
+ /**
+ * Create a bridge token with a chosen expiry for boundary tests.
+ *
+ * @param WP_User $user Token owner.
+ * @param int $expiration Unix expiry timestamp.
+ * @return string
+ */
+ private function create_signed_token( WP_User $user, int $expiration ): string {
+ $user = new WP_User( $user->ID );
+ $nonce = str_repeat( 'a', 32 );
+ $claims = implode( '|', array( 'wc1', $user->ID, $expiration, $nonce, $user->user_activation_key ) );
+ $signature = hash_hmac( 'sha256', $claims, wp_salt( 'nonce' ) );
+
+ return implode( '.', array( 'wc1', $user->ID, $expiration, $nonce, $signature ) );
+ }
+
+ /**
+ * Invoke the reset-link handler without allowing its trailing exit to stop PHPUnit.
+ *
+ * @return string Redirect location.
+ */
+ private function intercept_reset_link_redirect(): string {
+ add_filter( 'woocommerce_is_account_page', '__return_true' );
+ $location = '';
+ $abort = static function ( string $redirect ) use ( &$location ): void {
+ $location = $redirect;
+ throw new RuntimeException( 'Password-reset redirect intercepted.' );
+ };
+
+ add_filter( 'wp_redirect', $abort );
+ // PHPUnit has already emitted output; suppress only the expected setcookie() header warning from the existing handler.
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler
+ set_error_handler(
+ static function ( int $level, string $message ): bool {
+ return E_WARNING === $level && false !== strpos( $message, 'Cannot modify header information' );
+ }
+ );
+ try {
+ WC_Form_Handler::redirect_reset_password_link();
+ } catch ( RuntimeException $e ) {
+ $this->assertSame( 'Password-reset redirect intercepted.', $e->getMessage() );
+ } finally {
+ restore_error_handler();
+ remove_filter( 'wp_redirect', $abort );
+ remove_filter( 'woocommerce_is_account_page', '__return_true' );
+ }
+
+ return $location;
+ }
+}