diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ab05024..198e8d1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -54,6 +54,9 @@ jobs: - name: Hook registration regression test run: php tests/hook-registration-test.php + - name: OnePress waiver dispatch test + run: php tests/onepress-dispatch-test.php + build: name: Package Release Project runs-on: ubuntu-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1442198..d140e42 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -45,3 +45,6 @@ jobs: - name: Hook registration regression test run: php tests/hook-registration-test.php + + - name: OnePress waiver dispatch test + run: php tests/onepress-dispatch-test.php diff --git a/README.md b/README.md index b71d51d..752e8c8 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ This plugin implements server-level Basic Authentication for your WordPress site - Sites that need an additional layer of security before the WordPress login Key features: -- Forces Basic Authentication for all requests (except AJAX, CRON, and CLI) +- Forces Basic Authentication for all requests, except: WordPress AJAX, cron and WP-CLI; XML-RPC; the Jetpack, `wp/v2` and `wp/v3` REST namespaces requested under `/wp-json/`; a MyPressable WP Admin one-click login carrying a valid, unexpired Pressable OnePress Login token while that plugin is active; and any request from a user already logged in to WordPress - Integrates with WordPress user database for authentication - Handles logout functionality properly - Prevents caching of authentication requests diff --git a/pressable-basic-authentication.php b/pressable-basic-authentication.php index 30c783e..53ec8cb 100644 --- a/pressable-basic-authentication.php +++ b/pressable-basic-authentication.php @@ -8,7 +8,7 @@ /* Plugin Name: Hosting Basic Authentication Description: Forces all users to authenticate using Basic Authentication before accessing any page. -Version: 1.0.5 +Version: 1.0.6 License: GPL2 Text Domain: hosting-basic-authentication */ @@ -269,9 +269,113 @@ private function should_skip_auth() { return true; } + if ($this->is_valid_onepress_login_request()) { + return true; + } + return false; } + /** + * Whether this is a MyPressable WP Admin one-click login carrying a valid token. + * + * MyPressable sends the user to `wp-login.php?mpcp_token=…`, and the Pressable + * OnePress Login plugin validates that token on `plugins_loaded` priority 10. This + * plugin challenges on priority 1 and exits, so without this waiver the token is + * never seen and the one-click login can only ever end in a credential prompt. + * + * The waiver repeats OnePress's own checks -- stored token present, unexpired, + * same user agent, matching secret -- and is deliberately narrower than OnePress + * (the token must arrive in the query string, in exactly four parts), so it admits + * only requests OnePress will then log in. It is read-only: OnePress deletes the + * stored token as it consumes it, which keeps the token single-use, so a replayed + * URL fails here and is challenged as usual. + * + * OnePress registers on, and reads, `$_REQUEST`, which at `plugins_loaded` is still + * PHP's own, built from `request_order`. The query-string token must also be the + * one `$_REQUEST` holds. Otherwise a `request_order` without G would let wp-login.php + * through with OnePress never handling it, and a body value overriding the query one + * would have OnePress consume a token the waiver never checked. + * + * OnePress must also be ready to handle the request, or nothing would consume the + * token and the waiver would only expose the password form: loaded, and not stood + * down by its own readiness guard, which is repeated here in full. skip_request() + * does not make it redundant: it reads the DOING_AJAX and DOING_CRON constants, + * while OnePress reads wp_doing_ajax() and wp_doing_cron(), which are filterable. + * Every active plugin file is included before `plugins_loaded` fires, so the class + * check does not depend on load order. + * + * `$_GET`, `$_REQUEST` and `$_SERVER` are read raw on purpose: wp_magic_quotes() + * only runs after `plugins_loaded`, so nothing has been slashed yet and + * wp_unslash() would corrupt a value rather than restore it. That is also how + * OnePress reads them. + * + * @return bool + */ + private function is_valid_onepress_login_request() { + if ( ! class_exists( 'Pressable_OnePress_Login_Plugin', false ) ) { + return false; + } + + if ( wp_installing() || wp_doing_cron() || wp_doing_ajax() || ( defined( 'WP_CLI' ) && WP_CLI ) ) { + return false; + } + + if ( 'wp-login.php' !== ( $GLOBALS['pagenow'] ?? '' ) ) { + return false; + } + + $encoded = $_GET['mpcp_token'] ?? null; + + // A genuine token encodes about 120 bytes to about 160 characters. The cap bounds + // the decoding and splitting below, which run before any authentication. + if ( ! is_string( $encoded ) || '' === $encoded || strlen( $encoded ) > 256 ) { + return false; + } + + if ( ( $_REQUEST['mpcp_token'] ?? null ) !== $encoded ) { + return false; + } + + // MyPressable encodes URL-safe and unpadded, so map it to the standard alphabet. + // Strict mode returns false on any character outside that alphabet instead of + // silently discarding it; it still accepts padding and skips whitespace. + $decoded = base64_decode( strtr( $encoded, '-_', '+/' ), true ); + + if ( false === $decoded ) { + return false; + } + + // "---"; none of the parts can + // contain a hyphen, so any other count is not a token MyPressable minted. + $parts = explode( '-', $decoded ); + + if ( 4 !== count( $parts ) || 1 !== preg_match( '/\A[0-9]+\z/', $parts[0] ) ) { + return false; + } + + list( $user_id, $secret, , $user_agent_hash ) = $parts; + + $stored = get_user_meta( (int) $user_id, 'mpcp_auth_token', true ); + + if ( ! is_array( $stored ) || ! isset( $stored['value'], $stored['exp'] ) + || ! is_string( $stored['value'] ) || ! is_numeric( $stored['exp'] ) ) { + return false; + } + + if ( (int) $stored['exp'] < time() ) { + return false; + } + + $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? null; + + if ( ! is_string( $user_agent ) || ! hash_equals( md5( $user_agent ), $user_agent_hash ) ) { + return false; + } + + return hash_equals( $stored['value'], md5( $secret ) ); + } + /** * Whether a path prefix names a script the server would execute. * diff --git a/readme.txt b/readme.txt index 144252a..681014b 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: pressable, basic auth, authentication, security Requires at least: 6.7 Tested up to: 7.1 Requires PHP: 8.1 -Stable tag: 1.0.5 +Stable tag: 1.0.6 License: GPLv2 or later License URI: http://www.gnu.org/licenses/gpl-2.0.html @@ -17,6 +17,7 @@ The Pressable Basic Authentication plugin enforces HTTP Basic Authentication acr * Enforces HTTP Basic Authentication on all front-end and back-end pages. * Installed on sites to restrict public access. * Allows super administrators to bypass authentication for seamless management. +* Lets the MyPressable WP Admin button sign in: a request carrying a valid, unexpired Pressable OnePress Login token passes, while that plugin is active. * Integrates with WordPress's authentication system for user verification. * Provides a mechanism to log out of Basic Authentication sessions.​ @@ -44,6 +45,15 @@ No manual installation is necessary.​ == Changelog == += 1.0.6 = +* Fixed: the WP Admin button in MyPressable (Pressable OnePress Login) ended in a + Basic Authentication prompt instead of signing the user in. A one-click login + request now passes Basic Authentication only when it carries a token the + OnePress plugin will accept -- present, unexpired, presented with the same + User-Agent it was issued to, and matching the stored secret -- and only while + that plugin is active. Any other request to wp-login.php is still challenged + unless an existing exemption applies, such as an existing WordPress session. + = 1.0.5 = * Fixed: Basic Authentication could be bypassed entirely on any URL, with no credentials, by making a request to a gated page resemble one of the endpoints diff --git a/tests/fixtures/onepress-dispatch-router.php b/tests/fixtures/onepress-dispatch-router.php new file mode 100644 index 0000000..7eb8d82 --- /dev/null +++ b/tests/fixtures/onepress-dispatch-router.php @@ -0,0 +1,108 @@ + md5( DISPATCH_SECRET ), 'exp' => time() + 30 ); +} + +/** + * Stand-in for Pressable OnePress Login: registers on the same hook and priority, under + * the same condition, and reports that it was reached instead of logging anyone in. + */ +final class Pressable_OnePress_Login_Plugin { + public function __construct() { + if ( 'wp-login.php' === $GLOBALS['pagenow'] && isset( $_REQUEST['mpcp_token'] ) ) { + add_action( 'plugins_loaded', array( $this, 'handle_server_login_request' ) ); + } + } + + public function handle_server_login_request() { + echo 'ONEPRESS-HANDLED'; + exit; + } +} + +require __DIR__ . '/../../pressable-basic-authentication.php'; +new Pressable_OnePress_Login_Plugin(); + +do_action( 'plugins_loaded' ); + +echo 'NOT-CHALLENGED-NOR-HANDLED'; diff --git a/tests/hook-registration-test.php b/tests/hook-registration-test.php index 12ab148..863e74b 100644 --- a/tests/hook-registration-test.php +++ b/tests/hook-registration-test.php @@ -1,6 +1,8 @@ $hook, 'callback' => $callback, 'priority' => $priority ); } +$GLOBALS['user_meta'] = array(); +$GLOBALS['deleted_user_meta'] = array(); + +function get_user_meta( $user_id, $key = '', $single = false ) { + return $GLOBALS['user_meta'][ $user_id ][ $key ] ?? ''; +} + +function delete_user_meta( $user_id, $meta_key, $meta_value = '' ) { + $GLOBALS['deleted_user_meta'][] = array( $user_id, $meta_key ); + return true; +} + +function wp_installing() { + return ! empty( $GLOBALS['wp_installing'] ); +} + +// Filterable in WordPress, so either can be true without DOING_AJAX / DOING_CRON. +function wp_doing_ajax() { + return ! empty( $GLOBALS['wp_doing_ajax'] ); +} + +function wp_doing_cron() { + return ! empty( $GLOBALS['wp_doing_cron'] ); +} + require __DIR__ . '/../pressable-basic-authentication.php'; $failures = array(); @@ -161,9 +199,9 @@ function source_of( $method ) { /** * Whether should_skip_auth() would waive Basic Authentication for a request URI. * - * Invoked for real rather than inspected as source: the method touches only - * basename() and parse_url(), so it runs without WordPress, and a behavioural - * assertion cannot be satisfied by a rewrite that merely looks different. + * Invoked for real rather than inspected as source: the method touches only PHP + * built-ins and the WordPress stand-ins above, so it runs without WordPress, and a + * behavioural assertion cannot be satisfied by a rewrite that merely looks different. * * @param string $uri Value to place in REQUEST_URI. * @param string $script_name Value to place in SCRIPT_NAME -- the script the server @@ -308,6 +346,163 @@ function skips_auth_for( $uri, $script_name = '/index.php' ) { check( false === skips_auth_for( '/' ), 'an ordinary request is still gated' ); +const ONEPRESS_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'; +const ONEPRESS_SECRET = '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08'; + +/** + * Builds an mpcp_token the way MPCP's UpdateMpcpAuthToken does: URL-safe base64, + * unpadded, of "---". + * + * @param int|string $user_id WordPress user id segment. + * @param string $suffix Appended to the payload, to build malformed tokens. + * @return string + */ +function mpcp_token( $user_id = 7, $suffix = '' ) { + $payload = $user_id . '-' . ONEPRESS_SECRET . '-1000001-' . md5( ONEPRESS_UA ) . $suffix; + + return rtrim( strtr( base64_encode( $payload ), '+/', '-_' ), '=' ); +} + +/** + * Whether should_skip_auth() waives Basic Authentication for a OnePress one-click + * login request, with every input the check reads set for the duration of the call. + * + * @param array $request Overrides for: token (null for absent), user_id (whose meta + * is stored), meta (the stored mpcp_auth_token), user_agent, + * pagenow, installing, doing_ajax, doing_cron (the wp_*() + * results), request_token ($_REQUEST's mpcp_token, null for + * absent; defaults to the $_GET token, as request_order GP gives). + * @return bool + */ +function skips_auth_for_onepress( array $request = array() ) { + static $plugin = null; + + if ( null === $plugin ) { + $plugin = new Pressable_Basic_Auth(); + } + + $request += array( + 'token' => mpcp_token(), + 'user_id' => 7, + 'meta' => array( 'value' => md5( ONEPRESS_SECRET ), 'exp' => time() + 30 ), + 'user_agent' => ONEPRESS_UA, + 'pagenow' => 'wp-login.php', + 'installing' => false, + 'doing_ajax' => false, + 'doing_cron' => false, + ); + + if ( ! array_key_exists( 'request_token', $request ) ) { + $request['request_token'] = $request['token']; + } + + $GLOBALS['wp_installing'] = $request['installing']; + $GLOBALS['wp_doing_ajax'] = $request['doing_ajax']; + $GLOBALS['wp_doing_cron'] = $request['doing_cron']; + + $original_server = $_SERVER; + $original_get = $_GET; + $original_request = $_REQUEST; + $original_pagenow = $GLOBALS['pagenow'] ?? null; + + $_SERVER['REQUEST_URI'] = '/wp-login.php'; + $_SERVER['SCRIPT_NAME'] = '/wp-login.php'; + $_SERVER['HTTP_USER_AGENT'] = $request['user_agent']; + $_GET = null === $request['token'] ? array() : array( 'mpcp_token' => $request['token'] ); + $_REQUEST = null === $request['request_token'] ? array() : array( 'mpcp_token' => $request['request_token'] ); + $GLOBALS['pagenow'] = $request['pagenow']; + $GLOBALS['user_meta'] = array( $request['user_id'] => array( 'mpcp_auth_token' => $request['meta'] ) ); + + try { + $method = new ReflectionMethod( 'Pressable_Basic_Auth', 'should_skip_auth' ); + $method->setAccessible( true ); + + return (bool) $method->invoke( $plugin ); + } finally { + $_SERVER = $original_server; + $_GET = $original_get; + $_REQUEST = $original_request; + $GLOBALS['pagenow'] = $original_pagenow; + $GLOBALS['user_meta'] = array(); + $GLOBALS['wp_installing'] = false; + $GLOBALS['wp_doing_ajax'] = false; + $GLOBALS['wp_doing_cron'] = false; + } +} + +// Without OnePress loaded nothing would consume the token, so waiving the challenge +// would only expose the wp-login.php password form with no Basic Auth in front of it. +// Asserted before the stand-in class below is declared, which cannot be undone. +check( false === skips_auth_for_onepress(), 'a valid one-click token does not waive auth when OnePress is not active' ); + +if ( ! class_exists( 'Pressable_OnePress_Login_Plugin' ) ) { + final class Pressable_OnePress_Login_Plugin {} +} + +// The MPCP WP Admin button redirects to wp-login.php?mpcp_token=…, which OnePress +// validates on `plugins_loaded` priority 10 -- after this plugin's priority-1 challenge +// has already ended the request. A valid token must therefore get through here. +check( true === skips_auth_for_onepress(), 'a valid, unexpired one-click token on wp-login.php waives auth' ); + +// MPCP omits base64 padding. User id 7 gives a payload needing one `=`; 77 needs none. +check( + true === skips_auth_for_onepress( array( 'token' => mpcp_token( 77 ), 'user_id' => 77 ) ), + 'a one-click token whose encoding has no padding to omit also waives auth' +); + +$GLOBALS['deleted_user_meta'] = array(); +skips_auth_for_onepress(); +check( + array() === $GLOBALS['deleted_user_meta'], + 'validating the token does not consume it -- OnePress deletes it when it logs the user in' +); + +foreach ( array( + 'no token' => array( 'token' => null ), + 'an empty token' => array( 'token' => '' ), + 'a token passed as an array' => array( 'token' => array( mpcp_token() ) ), + 'a page other than wp-login.php' => array( 'pagenow' => 'index.php' ), + 'an expired token' => array( 'meta' => array( 'value' => md5( ONEPRESS_SECRET ), 'exp' => time() - 1 ) ), + 'a wrong secret' => array( 'meta' => array( 'value' => md5( 'other' ), 'exp' => time() + 30 ) ), + 'a different user agent' => array( 'user_agent' => 'curl/8.4.0' ), + 'no stored token' => array( 'meta' => '' ), + 'a stored token missing exp' => array( 'meta' => array( 'value' => md5( ONEPRESS_SECRET ) ) ), + 'a stored token missing value' => array( 'meta' => array( 'exp' => time() + 30 ) ), + 'a non-array stored token' => array( 'meta' => md5( ONEPRESS_SECRET ) ), + 'a token for another user' => array( 'user_id' => 8 ), + 'a token that is not base64' => array( 'token' => '!!!not*base64!!!' ), + 'a token with too few parts' => array( 'token' => rtrim( strtr( base64_encode( '7-' . ONEPRESS_SECRET . '-1000001' ), '+/', '-_' ), '=' ) ), + 'a token with extra parts' => array( 'token' => mpcp_token( 7, '-x' ) ), + // (int) '7abc' is 7, so this reaches user 7's real token unless the id is rejected first. + 'a non-numeric user id' => array( 'token' => mpcp_token( '7abc' ) ), + // Leading zeros keep the id numeric and (int) 7, so only the length cap rejects this. + 'an overlong token' => array( 'token' => mpcp_token( str_repeat( '0', 300 ) . '7' ) ), + // OnePress hashes the raw header, so a value it cannot hash must not be coerced here. + 'a non-string user agent' => array( 'user_agent' => array( ONEPRESS_UA ) ), + // OnePress registers no login handler while WordPress is installing. + 'WordPress installing' => array( 'installing' => true ), + // OnePress registers on, and reads, $_REQUEST. With a request_order that leaves out + // G it never sees a query-string token, and a body value can differ from the query one. + 'the token absent from $_REQUEST' => array( 'request_token' => null ), + 'a different $_REQUEST token' => array( 'request_token' => mpcp_token( 8 ) ), + // wp_doing_ajax() and wp_doing_cron() are filterable, so either can stand OnePress + // down while the DOING_* constants skip_request() checks are still unset. + 'wp_doing_ajax() filtered true' => array( 'doing_ajax' => true ), + 'wp_doing_cron() filtered true' => array( 'doing_cron' => true ), +) as $description => $request ) { + check( false === skips_auth_for_onepress( $request ), "a one-click request with $description does not waive auth" ); +} + +// OnePress rejects only `exp < time()`, so a token in its final second is still live. +// Run in the first half of a second so time() cannot tick between setup and check. +while ( fmod( microtime( true ), 1 ) > 0.5 ) { + usleep( 10000 ); +} +check( + true === skips_auth_for_onepress( array( 'meta' => array( 'value' => md5( ONEPRESS_SECRET ), 'exp' => time() ) ) ), + 'a one-click token expiring this second still waives auth, as OnePress accepts it' +); + // is_ajax_request() is tested directly rather than through skip_request(): this file runs // under the CLI SAPI, so is_cli_request() (a sibling arm of skip_request()) is always true // here and would mask everything else. @@ -352,6 +547,15 @@ function is_ajax_for( $x_requested_with ) { define( 'DOING_AJAX', true ); check( true === is_ajax_for( null ), 'a genuine DOING_AJAX request is still AJAX (bypasses)' ); +// OnePress registers no login handler under WP-CLI. Asserted last for the same reason. +define( 'WP_CLI', true ); +check( false === skips_auth_for_onepress(), 'a one-click request under WP-CLI does not waive auth' ); + +check( + array() === $GLOBALS['php_errors'], + 'no check raised a PHP warning or notice' . ( $GLOBALS['php_errors'] ? ': ' . implode( '; ', $GLOBALS['php_errors'] ) : '' ) +); + echo "\n"; if ( $failures ) { diff --git a/tests/onepress-dispatch-test.php b/tests/onepress-dispatch-test.php new file mode 100644 index 0000000..df2dae6 --- /dev/null +++ b/tests/onepress-dispatch-test.php @@ -0,0 +1,141 @@ + array( 'file', '/dev/null', 'w' ), 2 => array( 'file', '/dev/null', 'w' ) ), + $pipes +); + +register_shutdown_function( + function () use ( $server ) { + proc_terminate( $server ); + proc_close( $server ); + } +); + +$ready = false; + +for ( $attempt = 0; $attempt < 50 && ! $ready; $attempt++ ) { + $connection = @fsockopen( '127.0.0.1', $port, $errno, $errstr, 0.1 ); + + if ( $connection ) { + fclose( $connection ); + $ready = true; + } else { + usleep( 100000 ); + } +} + +if ( ! $ready ) { + echo "FAIL the built-in server did not start on port $port\n"; + exit( 1 ); +} + +/** + * Requests a path from the router and returns its status, challenge header and body. + * + * @param string $path Request path and query. + * @param string $user_agent User-Agent header to send. + * @return array{status: int, challenged: bool, body: string} + */ +function dispatch( $path, $user_agent = DISPATCH_UA ) { + global $port; + + $context = stream_context_create( + array( + 'http' => array( + 'header' => "User-Agent: $user_agent\r\n", + 'ignore_errors' => true, + 'timeout' => 10, + ), + ) + ); + + $body = (string) @file_get_contents( "http://127.0.0.1:$port$path", false, $context ); + $headers = $http_response_header ?? array(); + $status = preg_match( '#^HTTP/\S+ (\d{3})#', $headers[0] ?? '', $match ) ? (int) $match[1] : 0; + + return array( + 'status' => $status, + 'challenged' => (bool) preg_grep( '/^WWW-Authenticate: Basic/i', $headers ), + 'body' => $body, + ); +} + +echo "OnePress waiver dispatch (built-in server)\n"; + +$valid = dispatch( '/wp-login.php?mpcp_token=' . dispatch_token() ); +check( + 200 === $valid['status'] && ! $valid['challenged'] && 'ONEPRESS-HANDLED' === $valid['body'], + 'a valid one-click token reaches OnePress through plugins_loaded with no challenge' +); + +foreach ( array( + 'no token' => array( '/wp-login.php', DISPATCH_UA ), + 'a different user agent' => array( '/wp-login.php?mpcp_token=' . dispatch_token(), 'curl/8.4.0' ), + 'a token for another UA' => array( '/wp-login.php?mpcp_token=' . dispatch_token( 'curl/8.4.0' ), DISPATCH_UA ), + 'a page other than wp-login.php' => array( '/?mpcp_token=' . dispatch_token(), DISPATCH_UA ), +) as $description => list( $path, $user_agent ) ) { + $response = dispatch( $path, $user_agent ); + check( + 401 === $response['status'] && $response['challenged'] && 'ONEPRESS-HANDLED' !== $response['body'], + "a request with $description is challenged before OnePress" + ); +} + +echo "\n"; + +if ( $failures ) { + echo count( $failures ) . " failure(s)\n"; + exit( 1 ); +} + +echo "All checks passed\n"; +exit( 0 );