From 5d8ccde84b0b6838f8aaf0a90a4caf8b8c04d679 Mon Sep 17 00:00:00 2001 From: Aayushkalikote Date: Fri, 18 Sep 2026 11:04:30 +0545 Subject: [PATCH] Application Passwords: verify Basic Auth with the server before blocking. The presence of PHP_AUTH_USER or PHP_AUTH_PW is not proof that the server enforces HTTP Basic Auth. Under the CGI/FastCGI SAPI, PHP populates those keys from any Authorization header the client sends, and browsers keep replaying cached credentials for the rest of the session, so the Application Passwords warning persists after Basic Auth is disabled. Confirm with the server via an unauthenticated loopback request when credentials are present, looking for a 401 with a Basic challenge. The check is skipped entirely when no credentials are present, the result is cached in a short-lived transient, and a failed loopback request preserves the previous behaviour. Adds test coverage for wp_is_site_protected_by_basic_auth(), which had none. Props ethicaladitya, khokansardar, smeunus. Fixes #66000. Co-Authored-By: Claude Opus 5 (1M context) --- src/wp-includes/load.php | 70 +++++ .../load/wpIsSiteProtectedByBasicAuth.php | 292 ++++++++++++++++++ 2 files changed, 362 insertions(+) create mode 100644 tests/phpunit/tests/load/wpIsSiteProtectedByBasicAuth.php diff --git a/src/wp-includes/load.php b/src/wp-includes/load.php index 061754e8b4e52..dd13a282249f6 100644 --- a/src/wp-includes/load.php +++ b/src/wp-includes/load.php @@ -2069,6 +2069,18 @@ function wp_is_site_protected_by_basic_auth( $context = '' ) { $is_protected = ! empty( $_SERVER['PHP_AUTH_USER'] ) || ! empty( $_SERVER['PHP_AUTH_PW'] ); + /* + * Credentials in the request are not proof that the server enforces Basic Auth. + * Under the CGI/FastCGI SAPI, PHP populates these keys from any `Authorization: Basic` + * header the client chooses to send, and browsers keep replaying cached credentials + * for the rest of the session after Basic Auth has been switched off. + * + * Confirm with the server before trusting the request. + */ + if ( $is_protected ) { + $is_protected = wp_is_basic_auth_enforced_by_server(); + } + /** * Filters whether a site is protected by HTTP Basic Auth. * @@ -2079,3 +2091,61 @@ function wp_is_site_protected_by_basic_auth( $context = '' ) { */ return apply_filters( 'wp_is_site_protected_by_basic_auth', $is_protected, $context ); } + +/** + * Checks whether the server itself enforces HTTP Basic Auth. + * + * Makes an unauthenticated loopback request to the home URL and looks for the + * `WWW-Authenticate: Basic` challenge a server protected by Basic Auth must send. + * + * The result is cached briefly. The check only runs when Basic Auth credentials + * are present in the current request, so sites that are not affected never make + * the request. + * + * If the loopback request cannot be completed, the server is assumed to enforce + * Basic Auth, preserving the behavior from before this check existed. + * + * @since 7.2.0 + * @access private + * + * @return bool Whether the server enforces Basic Auth. + */ +function wp_is_basic_auth_enforced_by_server() { + $cached = get_transient( 'wp_basic_auth_enforced' ); + + if ( false !== $cached ) { + return '1' === $cached; + } + + $response = wp_remote_head( + home_url( '/' ), + array( + 'timeout' => 3, + 'redirection' => 0, + 'sslverify' => false, + ) + ); + + if ( is_wp_error( $response ) ) { + // Do not cache a failure to reach the site; the next request can try again. + return true; + } + + $challenge = wp_remote_retrieve_header( $response, 'www-authenticate' ); + + // A server may send more than one challenge. + if ( is_array( $challenge ) ) { + $challenge = implode( ', ', $challenge ); + } + + $is_enforced = 401 === wp_remote_retrieve_response_code( $response ) + && str_starts_with( strtolower( $challenge ), 'basic' ); + + /* + * Kept short: the server's configuration can change at any time, and a stale + * result is wrong in both directions until it expires. + */ + set_transient( 'wp_basic_auth_enforced', $is_enforced ? '1' : '0', 15 * MINUTE_IN_SECONDS ); + + return $is_enforced; +} diff --git a/tests/phpunit/tests/load/wpIsSiteProtectedByBasicAuth.php b/tests/phpunit/tests/load/wpIsSiteProtectedByBasicAuth.php new file mode 100644 index 0000000000000..ac8bfe67b18a3 --- /dev/null +++ b/tests/phpunit/tests/load/wpIsSiteProtectedByBasicAuth.php @@ -0,0 +1,292 @@ +server_backup[ $key ] = isset( $_SERVER[ $key ] ) ? $_SERVER[ $key ] : null; + unset( $_SERVER[ $key ] ); + } + + delete_transient( 'wp_basic_auth_enforced' ); + } + + public function tear_down() { + foreach ( $this->server_backup as $key => $value ) { + if ( null === $value ) { + unset( $_SERVER[ $key ] ); + } else { + $_SERVER[ $key ] = $value; + } + } + + delete_transient( 'wp_basic_auth_enforced' ); + + parent::tear_down(); + } + + /** + * Short-circuits the loopback request with a given response. + * + * @param int $status Response status code. + * @param array $headers Response headers. Default empty array. + */ + private function mock_loopback_response( $status, $headers = array() ) { + add_filter( + 'pre_http_request', + static function () use ( $status, $headers ) { + return array( + 'headers' => $headers, + 'body' => '', + 'response' => array( + 'code' => $status, + 'message' => get_status_header_desc( $status ), + ), + ); + } + ); + } + + /** + * @ticket 66000 + */ + public function test_should_return_false_when_no_credentials_are_present() { + $this->assertFalse( wp_is_site_protected_by_basic_auth( 'front' ) ); + } + + /** + * @ticket 66000 + */ + public function test_should_not_make_a_loopback_request_when_no_credentials_are_present() { + $requests = 0; + + add_filter( + 'pre_http_request', + static function ( $preempt ) use ( &$requests ) { + ++$requests; + return $preempt; + } + ); + + wp_is_site_protected_by_basic_auth( 'front' ); + + $this->assertSame( 0, $requests ); + } + + /** + * @ticket 66000 + * + * @dataProvider data_credentials + * + * @param array $server Values to set on $_SERVER. + * @param bool $expected Expected result. + */ + public function test_should_detect_credentials( $server, $expected ) { + foreach ( $server as $key => $value ) { + $_SERVER[ $key ] = $value; + } + + $this->mock_loopback_response( 401, array( 'www-authenticate' => 'Basic realm="Restricted"' ) ); + + $this->assertSame( $expected, wp_is_site_protected_by_basic_auth( 'front' ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_credentials() { + return array( + 'a user only' => array( array( 'PHP_AUTH_USER' => 'user' ), true ), + 'a password only' => array( array( 'PHP_AUTH_PW' => 'pass' ), true ), + 'a user and password' => array( + array( + 'PHP_AUTH_USER' => 'user', + 'PHP_AUTH_PW' => 'pass', + ), + true, + ), + 'an empty user' => array( array( 'PHP_AUTH_USER' => '' ), false ), + 'an empty password' => array( array( 'PHP_AUTH_PW' => '' ), false ), + ); + } + + /** + * Stale credentials replayed by the browser must not count as protection. + * + * @ticket 66000 + */ + public function test_should_return_false_for_stale_credentials() { + $_SERVER['PHP_AUTH_USER'] = 'staleuser'; + $_SERVER['PHP_AUTH_PW'] = 'stalepass'; + + $this->mock_loopback_response( 200 ); + + $this->assertFalse( wp_is_site_protected_by_basic_auth( 'front' ) ); + } + + /** + * A 401 without a Basic challenge is some other authentication scheme. + * + * @ticket 66000 + */ + public function test_should_return_false_for_a_non_basic_challenge() { + $_SERVER['PHP_AUTH_USER'] = 'user'; + + $this->mock_loopback_response( 401, array( 'www-authenticate' => 'Bearer realm="api"' ) ); + + $this->assertFalse( wp_is_site_protected_by_basic_auth( 'front' ) ); + } + + /** + * @ticket 66000 + */ + public function test_should_return_true_when_the_server_sends_a_basic_challenge() { + $_SERVER['PHP_AUTH_USER'] = 'user'; + + $this->mock_loopback_response( 401, array( 'www-authenticate' => 'Basic realm="Restricted"' ) ); + + $this->assertTrue( wp_is_site_protected_by_basic_auth( 'front' ) ); + } + + /** + * A blocked loopback request must not unlock Application Passwords. + * + * @ticket 66000 + */ + public function test_should_return_true_when_the_loopback_request_fails() { + $_SERVER['PHP_AUTH_USER'] = 'user'; + + add_filter( + 'pre_http_request', + static function () { + return new WP_Error( 'http_request_failed', 'Connection refused.' ); + } + ); + + $this->assertTrue( wp_is_site_protected_by_basic_auth( 'front' ) ); + $this->assertFalse( get_transient( 'wp_basic_auth_enforced' ), 'A failed request should not be cached.' ); + } + + /** + * @ticket 66000 + */ + public function test_should_only_make_one_loopback_request() { + $_SERVER['PHP_AUTH_USER'] = 'user'; + $requests = 0; + + add_filter( + 'pre_http_request', + static function () use ( &$requests ) { + ++$requests; + return array( + 'headers' => array(), + 'body' => '', + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + ); + } + ); + + wp_is_site_protected_by_basic_auth( 'front' ); + wp_is_site_protected_by_basic_auth( 'front' ); + + $this->assertSame( 1, $requests ); + } + + /** + * The documented escape hatch must keep working. + * + * @ticket 66000 + */ + public function test_filter_should_override_a_positive_detection() { + $_SERVER['PHP_AUTH_USER'] = 'user'; + + $this->mock_loopback_response( 401, array( 'www-authenticate' => 'Basic realm="Restricted"' ) ); + + add_filter( 'wp_is_site_protected_by_basic_auth', '__return_false' ); + + $this->assertFalse( wp_is_site_protected_by_basic_auth( 'front' ) ); + } + + /** + * @ticket 66000 + */ + public function test_filter_should_override_a_negative_detection() { + add_filter( 'wp_is_site_protected_by_basic_auth', '__return_true' ); + + $this->assertTrue( wp_is_site_protected_by_basic_auth( 'front' ) ); + } + + /** + * @ticket 66000 + */ + public function test_filter_should_receive_the_explicit_context() { + $context = null; + + add_filter( + 'wp_is_site_protected_by_basic_auth', + static function ( $is_protected, $passed_context ) use ( &$context ) { + $context = $passed_context; + return $is_protected; + }, + 10, + 2 + ); + + wp_is_site_protected_by_basic_auth( 'login' ); + + $this->assertSame( 'login', $context ); + } + + /** + * @ticket 66000 + */ + public function test_context_should_default_to_login_on_the_login_screen() { + global $pagenow; + + $original = $pagenow; + $pagenow = 'wp-login.php'; + $context = null; + + add_filter( + 'wp_is_site_protected_by_basic_auth', + static function ( $is_protected, $passed_context ) use ( &$context ) { + $context = $passed_context; + return $is_protected; + }, + 10, + 2 + ); + + wp_is_site_protected_by_basic_auth(); + + $pagenow = $original; + + $this->assertSame( 'login', $context ); + } +}