From 5eae8d0bddb3c5c36252470d9a35032eb5203dbf Mon Sep 17 00:00:00 2001 From: HasnainAshfaq Date: Tue, 21 Jul 2026 15:26:18 +0500 Subject: [PATCH 1/4] Login and Registration: Add support for /.well-known/change-password Adds a redirect from `/.well-known/change-password` to the user profile page (`wp-admin/profile.php`) inside `wp_redirect_admin_locations()`. Browsers and password managers use this well-known URL to help users change their passwords. A `wp_change_password_url` filter is provided for sites with custom user flows (e.g. membership plugins) to redirect to a different URL. Fixes #51173 --- src/wp-includes/canonical.php | 18 +++ .../tests/canonical/changePassword.php | 116 ++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 tests/phpunit/tests/canonical/changePassword.php diff --git a/src/wp-includes/canonical.php b/src/wp-includes/canonical.php index 964e56c6148fa..3a933b66496e3 100644 --- a/src/wp-includes/canonical.php +++ b/src/wp-includes/canonical.php @@ -1067,4 +1067,22 @@ function wp_redirect_admin_locations() { wp_redirect( wp_login_url() ); exit; } + + $password_change_urls = array( + home_url( '.well-known/change-password', 'relative' ), + site_url( '.well-known/change-password', 'relative' ), + ); + + if ( in_array( untrailingslashit( $_SERVER['REQUEST_URI'] ), $password_change_urls, true ) ) { + /** + * Filters the URL to redirect to when a browser or password manager + * requests the well-known change-password URL (/.well-known/change-password). + * + * @since 6.9.0 + * + * @param string $url The URL to redirect to. Default is the user profile page. + */ + wp_redirect( apply_filters( 'wp_change_password_url', admin_url( 'profile.php' ) ) ); + exit; + } } diff --git a/tests/phpunit/tests/canonical/changePassword.php b/tests/phpunit/tests/canonical/changePassword.php new file mode 100644 index 0000000000000..a19f7967f85ec --- /dev/null +++ b/tests/phpunit/tests/canonical/changePassword.php @@ -0,0 +1,116 @@ +original_request_uri = $_SERVER['REQUEST_URI'] ?? ''; + $this->set_permalink_structure( '/%postname%/' ); + } + + public function tear_down() { + $_SERVER['REQUEST_URI'] = $this->original_request_uri; + parent::tear_down(); + } + + /** + * Returns the redirect URL triggered by wp_redirect_admin_locations() + * for the given REQUEST_URI, or null if no redirect fires. + * + * @param string $request_uri + * @return string|null + */ + private function get_redirect_for( $request_uri ) { + $_SERVER['REQUEST_URI'] = $request_uri; + + global $wp_query; + $wp_query->is_404 = true; + + $captured = null; + + $capture = static function ( $location ) use ( &$captured ) { + $captured = $location; + // Return empty string so wp_redirect() sends no Location header + // and doesn't exit; execution returns to our caller. + return ''; + }; + + add_filter( 'wp_redirect', $capture ); + wp_redirect_admin_locations(); + remove_filter( 'wp_redirect', $capture ); + + $wp_query->is_404 = false; + + return $captured; + } + + /** + * @covers ::wp_redirect_admin_locations + */ + public function test_well_known_change_password_redirects_to_profile() { + $redirect = $this->get_redirect_for( '/.well-known/change-password' ); + $this->assertStringContainsString( 'profile.php', $redirect, 'Should redirect to the profile page.' ); + } + + /** + * @covers ::wp_redirect_admin_locations + */ + public function test_well_known_change_password_with_trailing_slash_does_not_redirect() { + $redirect = $this->get_redirect_for( '/.well-known/change-password/' ); + $this->assertNull( $redirect, 'Trailing slash variant should not redirect (untrailingslashit handles exact match only).' ); + } + + /** + * @covers ::wp_redirect_admin_locations + */ + public function test_well_known_change_password_no_redirect_without_pretty_permalinks() { + $this->set_permalink_structure( '' ); + + $_SERVER['REQUEST_URI'] = '/.well-known/change-password'; + + global $wp_query; + $wp_query->is_404 = true; + + $captured = null; + $capture = static function ( $location ) use ( &$captured ) { + $captured = $location; + return ''; + }; + + add_filter( 'wp_redirect', $capture ); + wp_redirect_admin_locations(); + remove_filter( 'wp_redirect', $capture ); + + $wp_query->is_404 = false; + + $this->assertNull( $captured, 'Should not redirect when pretty permalinks are disabled.' ); + } + + /** + * @covers ::wp_redirect_admin_locations + */ + public function test_wp_change_password_url_filter() { + $custom_url = 'https://example.com/my-account/change-password/'; + + add_filter( 'wp_change_password_url', static fn() => $custom_url ); + $redirect = $this->get_redirect_for( '/.well-known/change-password' ); + remove_all_filters( 'wp_change_password_url' ); + + $this->assertSame( $custom_url, $redirect ); + } +} From a9135c90b3c45ccd5bc79556aeab386283011645 Mon Sep 17 00:00:00 2001 From: HasnainAshfaq Date: Tue, 21 Jul 2026 15:39:32 +0500 Subject: [PATCH 2/4] Login and Registration: Address code review feedback for #51173 - Add root-relative `/.well-known/change-password` to the match array so subdirectory installs (e.g. example.com/blog/) are also covered - Fix test helper to use exception interception instead of empty-string filter return, preventing premature exit() from killing PHPUnit - Correct trailing-slash test: untrailingslashit() normalises the URI so it does match and redirect (test now asserts the redirect fires) - Use remove_filter() instead of remove_all_filters() in filter test Fixes #51173 --- src/wp-includes/canonical.php | 1 + .../tests/canonical/changePassword.php | 44 +++++++------------ 2 files changed, 17 insertions(+), 28 deletions(-) diff --git a/src/wp-includes/canonical.php b/src/wp-includes/canonical.php index 3a933b66496e3..b14e2ac5bcf37 100644 --- a/src/wp-includes/canonical.php +++ b/src/wp-includes/canonical.php @@ -1069,6 +1069,7 @@ function wp_redirect_admin_locations() { } $password_change_urls = array( + '/.well-known/change-password', home_url( '.well-known/change-password', 'relative' ), site_url( '.well-known/change-password', 'relative' ), ); diff --git a/tests/phpunit/tests/canonical/changePassword.php b/tests/phpunit/tests/canonical/changePassword.php index a19f7967f85ec..cf84f13103840 100644 --- a/tests/phpunit/tests/canonical/changePassword.php +++ b/tests/phpunit/tests/canonical/changePassword.php @@ -45,16 +45,18 @@ private function get_redirect_for( $request_uri ) { $capture = static function ( $location ) use ( &$captured ) { $captured = $location; - // Return empty string so wp_redirect() sends no Location header - // and doesn't exit; execution returns to our caller. - return ''; + throw new RuntimeException( 'Redirect intercepted.' ); }; add_filter( 'wp_redirect', $capture ); - wp_redirect_admin_locations(); - remove_filter( 'wp_redirect', $capture ); - - $wp_query->is_404 = false; + try { + wp_redirect_admin_locations(); + } catch ( RuntimeException $e ) { + // Redirect was intercepted; $captured holds the URL. + } finally { + remove_filter( 'wp_redirect', $capture ); + $wp_query->is_404 = false; + } return $captured; } @@ -70,9 +72,9 @@ public function test_well_known_change_password_redirects_to_profile() { /** * @covers ::wp_redirect_admin_locations */ - public function test_well_known_change_password_with_trailing_slash_does_not_redirect() { + public function test_well_known_change_password_with_trailing_slash_redirects_to_profile() { $redirect = $this->get_redirect_for( '/.well-known/change-password/' ); - $this->assertNull( $redirect, 'Trailing slash variant should not redirect (untrailingslashit handles exact match only).' ); + $this->assertStringContainsString( 'profile.php', $redirect, 'Trailing slash variant should also redirect to the profile page.' ); } /** @@ -81,24 +83,9 @@ public function test_well_known_change_password_with_trailing_slash_does_not_red public function test_well_known_change_password_no_redirect_without_pretty_permalinks() { $this->set_permalink_structure( '' ); - $_SERVER['REQUEST_URI'] = '/.well-known/change-password'; - - global $wp_query; - $wp_query->is_404 = true; - - $captured = null; - $capture = static function ( $location ) use ( &$captured ) { - $captured = $location; - return ''; - }; - - add_filter( 'wp_redirect', $capture ); - wp_redirect_admin_locations(); - remove_filter( 'wp_redirect', $capture ); - - $wp_query->is_404 = false; + $redirect = $this->get_redirect_for( '/.well-known/change-password' ); - $this->assertNull( $captured, 'Should not redirect when pretty permalinks are disabled.' ); + $this->assertNull( $redirect, 'Should not redirect when pretty permalinks are disabled.' ); } /** @@ -107,9 +94,10 @@ public function test_well_known_change_password_no_redirect_without_pretty_perma public function test_wp_change_password_url_filter() { $custom_url = 'https://example.com/my-account/change-password/'; - add_filter( 'wp_change_password_url', static fn() => $custom_url ); + $filter = static fn() => $custom_url; + add_filter( 'wp_change_password_url', $filter ); $redirect = $this->get_redirect_for( '/.well-known/change-password' ); - remove_all_filters( 'wp_change_password_url' ); + remove_filter( 'wp_change_password_url', $filter ); $this->assertSame( $custom_url, $redirect ); } From cdcbb21c9c3d50fe2e4b3962ca2150921f7bace2 Mon Sep 17 00:00:00 2001 From: HasnainAshfaq Date: Tue, 21 Jul 2026 16:54:34 +0500 Subject: [PATCH 3/4] Coding Standards: Fix variable alignment in changePassword test Aligns $filter assignment with surrounding variables per WPCS rules. Fixes #51173 --- tests/phpunit/tests/canonical/changePassword.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpunit/tests/canonical/changePassword.php b/tests/phpunit/tests/canonical/changePassword.php index cf84f13103840..8cde6eb9cd768 100644 --- a/tests/phpunit/tests/canonical/changePassword.php +++ b/tests/phpunit/tests/canonical/changePassword.php @@ -93,8 +93,8 @@ public function test_well_known_change_password_no_redirect_without_pretty_perma */ public function test_wp_change_password_url_filter() { $custom_url = 'https://example.com/my-account/change-password/'; + $filter = static fn() => $custom_url; - $filter = static fn() => $custom_url; add_filter( 'wp_change_password_url', $filter ); $redirect = $this->get_redirect_for( '/.well-known/change-password' ); remove_filter( 'wp_change_password_url', $filter ); From 56e236c8bbdb685926704f8b5c89a059062f106f Mon Sep 17 00:00:00 2001 From: HasnainAshfaq Date: Tue, 21 Jul 2026 19:11:03 +0500 Subject: [PATCH 4/4] Login and Registration: Improve test isolation in changePassword tests - Restore REQUEST_URI to truly unset state if it was not set before the test ran, rather than forcing it to an empty string - Save and restore the original $wp_query->is_404 value in the helper instead of hard-coding false, preventing state leakage between tests - Add assertNotNull() before assertStringContainsString() calls so a missing redirect produces a clear failure rather than a type error Fixes #51173 --- .../tests/canonical/changePassword.php | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/canonical/changePassword.php b/tests/phpunit/tests/canonical/changePassword.php index 8cde6eb9cd768..9ffdf1b5760b3 100644 --- a/tests/phpunit/tests/canonical/changePassword.php +++ b/tests/phpunit/tests/canonical/changePassword.php @@ -10,6 +10,13 @@ */ class Tests_Canonical_ChangePassword extends WP_UnitTestCase { + /** + * Whether REQUEST_URI was set before the test. + * + * @var bool + */ + private $request_uri_was_set; + /** * Original REQUEST_URI value. * @@ -19,12 +26,17 @@ class Tests_Canonical_ChangePassword extends WP_UnitTestCase { public function set_up() { parent::set_up(); + $this->request_uri_was_set = isset( $_SERVER['REQUEST_URI'] ); $this->original_request_uri = $_SERVER['REQUEST_URI'] ?? ''; $this->set_permalink_structure( '/%postname%/' ); } public function tear_down() { - $_SERVER['REQUEST_URI'] = $this->original_request_uri; + if ( $this->request_uri_was_set ) { + $_SERVER['REQUEST_URI'] = $this->original_request_uri; + } else { + unset( $_SERVER['REQUEST_URI'] ); + } parent::tear_down(); } @@ -39,6 +51,7 @@ private function get_redirect_for( $request_uri ) { $_SERVER['REQUEST_URI'] = $request_uri; global $wp_query; + $original_is_404 = $wp_query->is_404; $wp_query->is_404 = true; $captured = null; @@ -55,7 +68,7 @@ private function get_redirect_for( $request_uri ) { // Redirect was intercepted; $captured holds the URL. } finally { remove_filter( 'wp_redirect', $capture ); - $wp_query->is_404 = false; + $wp_query->is_404 = $original_is_404; } return $captured; @@ -66,6 +79,7 @@ private function get_redirect_for( $request_uri ) { */ public function test_well_known_change_password_redirects_to_profile() { $redirect = $this->get_redirect_for( '/.well-known/change-password' ); + $this->assertNotNull( $redirect, 'A redirect should fire for the well-known change-password URL.' ); $this->assertStringContainsString( 'profile.php', $redirect, 'Should redirect to the profile page.' ); } @@ -74,6 +88,7 @@ public function test_well_known_change_password_redirects_to_profile() { */ public function test_well_known_change_password_with_trailing_slash_redirects_to_profile() { $redirect = $this->get_redirect_for( '/.well-known/change-password/' ); + $this->assertNotNull( $redirect, 'A redirect should fire for the trailing-slash variant.' ); $this->assertStringContainsString( 'profile.php', $redirect, 'Trailing slash variant should also redirect to the profile page.' ); }