From 7d4338bfab8f7595a2efd1cf3ac493e3d9ad61e7 Mon Sep 17 00:00:00 2001 From: Piyush <296399266+skikken@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:00:40 +0530 Subject: [PATCH 1/2] Login and Registration: Add a reset_password_url filter The password reset link emailed by retrieve_password() was built inline and appended straight to the message, so the only way to point it at a custom reset page was the retrieve_password_notification_email filter, which replaces the whole email body. Build the URL into its own variable and pass it through a reset_password_url filter before it is appended to the message, so a site can redirect the link without rebuilding the email. The filter receives the URL, the user login, the activation key and the WP_User object. The locale, which is appended after the URL rather than being part of it, is deliberately left outside the filtered value. With no filter registered the message is unchanged. Props sebastian.pisula. Fixes #34712. --- src/wp-includes/user.php | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php index a13b3f75c0bdb..03268d4a65fdf 100644 --- a/src/wp-includes/user.php +++ b/src/wp-includes/user.php @@ -3409,7 +3409,24 @@ function retrieve_password( $user_login = '' ) { * * @see https://core.trac.wordpress.org/tickets/42957 */ - $message .= network_site_url( 'wp-login.php?login=' . rawurlencode( $user_login ) . "&key=$key&action=rp", 'login' ) . '&wp_lang=' . $locale . "\r\n\r\n"; + $reset_url = network_site_url( 'wp-login.php?login=' . rawurlencode( $user_login ) . "&key=$key&action=rp", 'login' ); + + /** + * Filters the URL used to reset a user's password. + * + * Returning a different URL allows the password reset link sent by email + * to point at a custom reset page instead of wp-login.php. + * + * @since 7.2.0 + * + * @param string $reset_url The password reset URL. + * @param string $user_login The user's login name. + * @param string $key The activation key used to reset the password. + * @param WP_User $user_data WP_User object. + */ + $reset_url = apply_filters( 'reset_password_url', $reset_url, $user_login, $key, $user_data ); + + $message .= $reset_url . '&wp_lang=' . $locale . "\r\n\r\n"; if ( ! is_user_logged_in() ) { $requester_ip = $_SERVER['REMOTE_ADDR']; From 9b4bb04d96318301bff555c1f694f37cbb74dc4b Mon Sep 17 00:00:00 2001 From: Piyush <296399266+skikken@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:00:40 +0530 Subject: [PATCH 2/2] Tests: Cover the reset_password_url filter Assert that the filter is applied to the password reset URL in the email sent by retrieve_password(), that it receives the documented arguments, and that the unfiltered message still carries the default wp-login.php reset URL together with the locale fragment. The last of these guards the back-compat requirement that the default email output does not change. Fixes #34712. --- tests/phpunit/tests/user/retrievePassword.php | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/tests/phpunit/tests/user/retrievePassword.php b/tests/phpunit/tests/user/retrievePassword.php index da72bed8b70de..4cb643fd6d331 100644 --- a/tests/phpunit/tests/user/retrievePassword.php +++ b/tests/phpunit/tests/user/retrievePassword.php @@ -18,6 +18,15 @@ class Tests_User_RetrievePassword extends WP_UnitTestCase { */ protected $user; + /** + * The message body of the password reset email captured by the test. + * + * @since 7.2.0 + * + * @var string + */ + protected $captured_message = ''; + /** * Create users for tests. * @@ -26,6 +35,8 @@ class Tests_User_RetrievePassword extends WP_UnitTestCase { public function set_up() { parent::set_up(); + $this->captured_message = ''; + // Create the user. $this->user = self::factory()->user->create_and_get( array( @@ -35,6 +46,35 @@ public function set_up() { ); } + /** + * Removes the filters and actions added by these tests. + * + * @since 7.2.0 + */ + public function tear_down() { + remove_all_filters( 'reset_password_url' ); + remove_filter( 'retrieve_password_notification_email', array( $this, 'capture_retrieve_password_notification_email' ) ); + remove_all_actions( 'retrieve_password_key' ); + + parent::tear_down(); + } + + /** + * Captures the message body of the password reset email. + * + * Used as the callback of the `retrieve_password_notification_email` filter. + * + * @since 7.2.0 + * + * @param array $defaults The default notification email arguments. + * @return array The unmodified notification email arguments. + */ + public function capture_retrieve_password_notification_email( $defaults ) { + $this->captured_message = $defaults['message']; + + return $defaults; + } + /** * The function should not error when the email was sent. * @@ -88,6 +128,120 @@ public function test_retrieve_password_does_not_throw_deprecation_notice_with_de $this->assertWPError( retrieve_password() ); } + /** + * Tests that the `reset_password_url` filter replaces the reset URL in the email. + * + * @ticket 34712 + */ + public function test_retrieve_password_should_apply_reset_password_url_filter() { + $custom_url = 'https://example.org/custom-reset-page/'; + + add_filter( + 'reset_password_url', + static function () use ( $custom_url ) { + return $custom_url; + } + ); + add_filter( 'retrieve_password_notification_email', array( $this, 'capture_retrieve_password_notification_email' ) ); + + $this->assertTrue( retrieve_password( $this->user->user_login ), 'Sending the password reset notification email failed.' ); + $this->assertStringContainsString( + $custom_url, + $this->captured_message, + 'The custom password reset URL was not used in the email message.' + ); + $this->assertStringNotContainsString( + 'wp-login.php?login=', + $this->captured_message, + 'The default password reset URL was still used in the email message.' + ); + } + + /** + * Tests that the `reset_password_url` filter receives the expected arguments. + * + * @ticket 34712 + */ + public function test_retrieve_password_should_pass_expected_arguments_to_reset_password_url_filter() { + $filter_args = array(); + $generated_key = ''; + $expected_user_id = $this->user->ID; + $expected_user_login = $this->user->user_login; + + add_action( + 'retrieve_password_key', + static function ( $user_login, $key ) use ( &$generated_key ) { + $generated_key = $key; + }, + 10, + 2 + ); + add_filter( + 'reset_password_url', + static function ( $reset_url, $user_login, $key, $user_data ) use ( &$filter_args ) { + $filter_args = array( + 'reset_url' => $reset_url, + 'user_login' => $user_login, + 'key' => $key, + 'user_data' => $user_data, + ); + + return $reset_url; + }, + 10, + 4 + ); + + $this->assertTrue( retrieve_password( $this->user->user_login ), 'Sending the password reset notification email failed.' ); + + $this->assertNotEmpty( $generated_key, 'The password reset key was not generated.' ); + $this->assertSame( $expected_user_login, $filter_args['user_login'], 'The user login passed to the filter is incorrect.' ); + $this->assertIsString( $filter_args['key'], 'The activation key passed to the filter is not a string.' ); + $this->assertNotEmpty( $filter_args['key'], 'The activation key passed to the filter is empty.' ); + $this->assertSame( $generated_key, $filter_args['key'], 'The activation key passed to the filter is incorrect.' ); + $this->assertInstanceOf( WP_User::class, $filter_args['user_data'], 'The user data passed to the filter is not a WP_User object.' ); + $this->assertSame( $expected_user_id, $filter_args['user_data']->ID, 'The user ID passed to the filter is incorrect.' ); + + $expected_default_url = network_site_url( 'wp-login.php?login=' . rawurlencode( $expected_user_login ) . "&key=$generated_key&action=rp", 'login' ); + + $this->assertSame( $expected_default_url, $filter_args['reset_url'], 'The URL passed to the filter is not the default password reset URL.' ); + } + + /** + * Tests that the default reset URL, including the `wp_lang` query arg, is unchanged + * when the `reset_password_url` filter is not registered. + * + * @ticket 34712 + */ + public function test_retrieve_password_should_keep_default_reset_url_when_unfiltered() { + $generated_key = ''; + + add_action( + 'retrieve_password_key', + static function ( $user_login, $key ) use ( &$generated_key ) { + $generated_key = $key; + }, + 10, + 2 + ); + add_filter( 'retrieve_password_notification_email', array( $this, 'capture_retrieve_password_notification_email' ) ); + + $this->assertTrue( retrieve_password( $this->user->user_login ), 'Sending the password reset notification email failed.' ); + + $this->assertNotEmpty( $generated_key, 'The password reset key was not generated.' ); + + $default_url = network_site_url( 'wp-login.php?login=' . rawurlencode( $this->user->user_login ) . "&key=$generated_key&action=rp", 'login' ); + $locale = get_user_locale( $this->user ); + + $this->assertStringContainsString( 'wp-login.php?login=', $this->captured_message, 'The default password reset URL is missing from the email message.' ); + $this->assertStringContainsString( 'action=rp', $this->captured_message, 'The default password reset URL is missing the action argument.' ); + $this->assertStringContainsString( + $default_url . '&wp_lang=' . $locale . "\r\n\r\n", + $this->captured_message, + 'The default password reset URL and locale are not intact in the email message.' + ); + } + /** * Tests that a fatal error is not thrown when the login passed via `$_POST` * is an array instead of a string.