diff --git a/src/wp-includes/canonical.php b/src/wp-includes/canonical.php index 764668cccded4..2fb3d74bb9c3e 100644 --- a/src/wp-includes/canonical.php +++ b/src/wp-includes/canonical.php @@ -1078,4 +1078,37 @@ function wp_redirect_admin_locations() { wp_redirect( wp_login_url() ); exit; } + + /** + * Filters the well-known change password URL (/.well-known/change-password). + * + * By default visiting the well-known change password URL will redirect to + * the user's profile page when requested by a browser or password manager. + * + * Sites with a custom URL structure for modifying the passwords can modify + * the destination with this filter. + * + * The resulting URL is redirected using {@see wp_safe_redirect}, if the + * filtered URL is on an external host, that host will need to be included + * as an allowed host via the {@see 'allowed_redirect_hosts'} filter. + * + * @since 7.2.0 + * + * @param string $change_password_url The URL to redirect to. Default is the user profile page. + */ + $change_password_url = apply_filters( 'wp_change_password_url', admin_url( 'profile.php' ) ); + + /* + * The well-known change-password URL is always at the root of the origin + * per the spec (https://w3c.github.io/webappsec-change-password-url/), so + * only the root-relative path is matched here, regardless of whether + * WordPress is installed in a subdirectory. + */ + if ( + '/.well-known/change-password' === untrailingslashit( $_SERVER['REQUEST_URI'] ) + && $change_password_url + && wp_safe_redirect( $change_password_url ) + ) { + exit; + } } diff --git a/tests/phpunit/tests/canonical/changePassword.php b/tests/phpunit/tests/canonical/changePassword.php new file mode 100644 index 0000000000000..c72052930d286 --- /dev/null +++ b/tests/phpunit/tests/canonical/changePassword.php @@ -0,0 +1,91 @@ +set_permalink_structure( '/%postname%/' ); + } + + /** + * 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 Requested URL. + * @return string|null Redirect destination, null if no redirect. + */ + 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 false; + }; + + // Runs late to ensure filters added by other tests fire first. + add_filter( 'wp_redirect', $capture, 20 ); + wp_redirect_admin_locations(); + 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->assertNotNull( $redirect, 'A redirect should fire for the well-known change-password URL.' ); + $this->assertSame( admin_url( 'profile.php' ), $redirect, 'Should redirect to the profile page.' ); + } + + /** + * @covers ::wp_redirect_admin_locations + */ + 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->assertSame( admin_url( 'profile.php' ), $redirect, 'Trailing slash variant should also redirect to the profile page.' ); + } + + /** + * @covers ::wp_redirect_admin_locations + */ + public function test_well_known_change_password_no_redirect_without_pretty_permalinks() { + $this->set_permalink_structure( '' ); + + $redirect = $this->get_redirect_for( '/.well-known/change-password' ); + + $this->assertNull( $redirect, '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/'; + $url_filter = static fn() => $custom_url; + + add_filter( + 'allowed_redirect_hosts', + static function ( $hosts ) { + $hosts[] = 'example.com'; + return $hosts; + } + ); + + add_filter( 'wp_change_password_url', $url_filter ); + $redirect = $this->get_redirect_for( '/.well-known/change-password' ); + $this->assertSame( $custom_url, $redirect ); + } +}