← All changes
|
includes/rest-api/class-mainwp-rest-authentication.php
+246
-56
6.0.12
→
6.2.3
View file →
| @@ -113,49 +113,101 @@ | ||
| 113 | 113 | add_filter( 'rest_authentication_errors', array( $this, 'authentication_fallback' ) ); |
| 114 | 114 | add_filter( 'rest_authentication_errors', array( $this, 'check_authentication_error' ), 15 ); |
| 115 | 115 | add_filter( 'rest_post_dispatch', array( $this, 'send_unauthorized_headers' ), 50 ); |
| 116 | 116 | add_filter( 'rest_pre_dispatch', array( $this, 'check_user_permissions' ), 10, 3 ); |
| 117 | + add_action( 'deleted_user', array( $this, 'delete_user_api_keys' ), 10, 3 ); | |
| 118 | + add_action( 'remove_user_from_blog', array( $this, 'remove_user_from_blog_api_keys' ), 10, 3 ); | |
| 119 | + add_action( 'revoked_super_admin', array( $this, 'revoked_super_admin_api_keys' ) ); | |
| 117 | 120 | } |
| 118 | 121 | |
| 119 | 122 | /** |
| 120 | - * Check if this is a request to MainWP's REST API. | |
| 123 | + * Delete an owner's API keys and their cache entries. | |
| 121 | 124 | * |
| 122 | - * This method determines whether the current request should use MainWP's | |
| 123 | - * custom REST API authentication (API keys, OAuth, Bearer tokens). | |
| 125 | + * @param int $id Deleted user ID. | |
| 126 | + * @param int|null $reassign Content reassignment target. API keys are never reassigned. | |
| 127 | + * @param WP_User|null $user Deleted user. | |
| 128 | + */ | |
| 129 | + public function delete_user_api_keys( $id, $reassign = null, $user = null ) { | |
| 130 | + unset( $reassign, $user ); | |
| 131 | + $db = MainWP_DB::instance(); | |
| 132 | + $rows = $db->remove_rest_api_keys_by_user( $id, $db->get_table_name( 'api_keys' ) ); | |
| 133 | + foreach ( $rows as $row ) { | |
| 134 | + self::delete_api_key_cache( $row->consumer_key ); | |
| 135 | + } | |
| 136 | + } | |
| 137 | + | |
| 138 | + /** | |
| 139 | + * Invalidate the row cached for REST API authentication. | |
| 124 | 140 | * |
| 125 | - * Returns TRUE for: | |
| 126 | - * - /wp-json/mainwp/v2/sites (MainWP core REST API) | |
| 127 | - * - /wp-json/mainwp-extension/v1/foo (MainWP extension REST APIs) | |
| 141 | + * @param string $consumer_key Stored consumer key, already hashed with mainwp_api_hash(). | |
| 142 | + */ | |
| 143 | + public static function delete_api_key_cache( $consumer_key ) { | |
| 144 | + wp_cache_delete( self::api_key_cache_key( $consumer_key ) ); | |
| 145 | + } | |
| 146 | + | |
| 147 | + /** | |
| 148 | + * Object-cache key for a key row. | |
| 128 | 149 | * |
| 129 | - * Returns FALSE for: | |
| 130 | - * - /wp-json/wp-abilities/v1/abilities/mainwp/list-sites-v1 (Abilities API) | |
| 131 | - * - /wp-json/wp/v2/posts (WordPress core REST API) | |
| 132 | - * - Any other REST endpoint not starting with 'mainwp/' or 'mainwp-' | |
| 150 | + * @param string $consumer_key Stored consumer key, already hashed with mainwp_api_hash(). | |
| 151 | + * @return string | |
| 152 | + */ | |
| 153 | + private static function api_key_cache_key( $consumer_key ) { | |
| 154 | + return 'mainwp_api_key_' . md5( $consumer_key ); // NOSONAR - MD5 used for cache key generation only, not cryptographic (security) purposes. | |
| 155 | + } | |
| 156 | + | |
| 157 | + /** | |
| 158 | + * Delete API keys only when their owner leaves MainWP's blog. | |
| 133 | 159 | * |
| 134 | - * IMPORTANT: The Abilities API endpoints contain 'mainwp' in their path | |
| 135 | - * (as the ability provider namespace) but MUST use native WordPress | |
| 136 | - * authentication (cookie auth, application passwords, etc.) - NOT MainWP's | |
| 137 | - * API key system. This method's exact prefix matching ensures Abilities | |
| 138 | - * endpoints are excluded. | |
| 160 | + * @param int $user_id Removed user ID. | |
| 161 | + * @param int $blog_id Blog the user is leaving. | |
| 162 | + * @param int $reassign Content reassignment target. API keys are never reassigned. | |
| 163 | + */ | |
| 164 | + public function remove_user_from_blog_api_keys( $user_id, $blog_id, $reassign = 0 ) { | |
| 165 | + global $wpdb; | |
| 166 | + | |
| 167 | + unset( $reassign ); | |
| 168 | + if ( $wpdb->get_blog_prefix( $blog_id ) . 'mainwp_api_keys' !== MainWP_DB::instance()->get_table_name( 'api_keys' ) ) { | |
| 169 | + return; | |
| 170 | + } | |
| 171 | + $this->delete_user_api_keys( $user_id ); | |
| 172 | + } | |
| 173 | + | |
| 174 | + /** | |
| 175 | + * Delete a former super admin's API keys when they are not a member of MainWP's blog. | |
| 139 | 176 | * |
| 177 | + * Super admins may hold keys on a Dashboard subsite they were never added to. Once revoked, those keys | |
| 178 | + * are refused at use time, but would work again if super admin were re-granted. | |
| 179 | + * | |
| 180 | + * @param int $user_id User whose super admin privileges were revoked. | |
| 181 | + */ | |
| 182 | + public function revoked_super_admin_api_keys( $user_id ) { | |
| 183 | + global $wpdb; | |
| 184 | + | |
| 185 | + if ( $wpdb->get_blog_prefix() . 'mainwp_api_keys' !== MainWP_DB::instance()->get_table_name( 'api_keys' ) || is_user_member_of_blog( $user_id ) ) { | |
| 186 | + return; | |
| 187 | + } | |
| 188 | + $this->delete_user_api_keys( $user_id ); | |
| 189 | + } | |
| 190 | + | |
| 191 | + /** | |
| 192 | + * Check whether WordPress is dispatching a MainWP REST route. | |
| 193 | + * | |
| 194 | + * Uses the parsed rest_route query variable, which is available during REST | |
| 195 | + * serving. An empty or unset route means no REST request is being served. | |
| 196 | + * The first path segment must be 'mainwp' or start with 'mainwp-', compared | |
| 197 | + * case-insensitively after trimming and normalising the leading slash. | |
| 198 | + * | |
| 199 | + * Abilities routes such as /wp-abilities/v1/abilities/mainwp/list-sites-v1 | |
| 200 | + * MUST use native WordPress authentication and are excluded. | |
| 201 | + * | |
| 140 | 202 | * @return bool True if request is to MainWP REST API, false otherwise. |
| 141 | 203 | */ |
| 142 | 204 | protected function is_request_to_rest_api() { |
| 143 | - if ( empty( $_SERVER['REQUEST_URI'] ) ) { | |
| 205 | + $route = $GLOBALS['wp']->query_vars['rest_route'] ?? ''; | |
| 206 | + if ( empty( $route ) || ! is_string( $route ) ) { | |
| 144 | 207 | return false; |
| 145 | 208 | } |
| 146 | 209 | |
| 147 | - $rest_prefix = trailingslashit( rest_get_url_prefix() ); | |
| 148 | - $request_uri = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ); | |
| 149 | - | |
| 150 | - // Check if the request is to the MainWP API namespace. | |
| 151 | - // Use strpos with exact prefix to avoid false positives from other APIs | |
| 152 | - // that may have 'mainwp/' in their URL path (e.g., wp-abilities/v1/abilities/mainwp/...). | |
| 153 | - $mainwp_api = ( false !== strpos( $request_uri, '/' . $rest_prefix . self::MAINWP_REST_NAMESPACE ) ); | |
| 154 | - | |
| 155 | - // Allow third party plugins use our authentication methods. | |
| 156 | - $extension_api = ( false !== strpos( $request_uri, '/' . $rest_prefix . self::MAINWP_EXTENSION_REST_NAMESPACE ) ); | |
| 157 | - | |
| 158 | 210 | /** |
| 159 | 211 | * Filter whether a request is considered to be to the MainWP REST API. |
| 160 | 212 | * |
| 161 | 213 | * IMPORTANT: Do NOT add Abilities API endpoints (wp-abilities/v1/abilities/mainwp/...) |
| @@ -163,12 +215,25 @@ | ||
| 163 | 215 | * NOT be processed by MainWP's REST authentication system. |
| 164 | 216 | * |
| 165 | 217 | * @param bool $is_mainwp_api True if this is a MainWP REST API request. |
| 166 | 218 | */ |
| 167 | - return apply_filters( 'mainwp_rest_is_request_to_rest_api', $mainwp_api || $extension_api ); | |
| 219 | + return apply_filters( 'mainwp_rest_is_request_to_rest_api', $this->is_mainwp_route( $route ) ); | |
| 168 | 220 | } |
| 169 | 221 | |
| 170 | 222 | /** |
| 223 | + * Match the first route segment to a MainWP namespace. | |
| 224 | + * | |
| 225 | + * @param string $route REST route to classify. | |
| 226 | + * @return bool | |
| 227 | + */ | |
| 228 | + private function is_mainwp_route( string $route ) { | |
| 229 | + $route = '/' . ltrim( trim( $route ), '/' ); | |
| 230 | + $namespace = strtolower( explode( '/', $route )[1] ); | |
| 231 | + | |
| 232 | + return rtrim( self::MAINWP_REST_NAMESPACE, '/' ) === $namespace || 0 === strpos( $namespace, self::MAINWP_EXTENSION_REST_NAMESPACE ); | |
| 233 | + } | |
| 234 | + | |
| 235 | + /** | |
| 171 | 236 | * Authenticate user. |
| 172 | 237 | * |
| 173 | 238 | * @param int|false $user_id User ID if one has been determined, false otherwise. |
| 174 | 239 | * @return int|false |
| @@ -280,12 +345,31 @@ | ||
| 280 | 345 | if ( ! $this->is_request_to_rest_api() ) { |
| 281 | 346 | return $error; |
| 282 | 347 | } |
| 283 | 348 | |
| 349 | + // mainwp/v1 routes check their own option-stored v1 keys in each callback and never | |
| 350 | + // read the key user, so a failed v2 attempt there must look like no v2 key was sent. | |
| 351 | + if ( $this->is_v1_route() ) { | |
| 352 | + return $error; | |
| 353 | + } | |
| 354 | + | |
| 284 | 355 | return $this->get_error(); |
| 285 | 356 | } |
| 286 | 357 | |
| 287 | 358 | /** |
| 359 | + * Check whether WordPress is dispatching a mainwp/v1 route. | |
| 360 | + * | |
| 361 | + * Route matching in WP_REST_Server is case-insensitive, so the comparison is too. | |
| 362 | + * | |
| 363 | + * @return bool | |
| 364 | + */ | |
| 365 | + private function is_v1_route() { | |
| 366 | + $segments = explode( '/', strtolower( trim( trim( (string) ( $GLOBALS['wp']->query_vars['rest_route'] ?? '' ) ), '/' ) ) ); | |
| 367 | + | |
| 368 | + return 'mainwp' === $segments[0] && isset( $segments[1] ) && 'v1' === $segments[1]; | |
| 369 | + } | |
| 370 | + | |
| 371 | + /** | |
| 288 | 372 | * Set authentication error. |
| 289 | 373 | * |
| 290 | 374 | * @param WP_Error $error Authentication error data. |
| 291 | 375 | */ |
| @@ -305,8 +389,62 @@ | ||
| 305 | 389 | return $this->error; |
| 306 | 390 | } |
| 307 | 391 | |
| 308 | 392 | /** |
| 393 | + * Error for a submitted key that is unknown, disabled, ownerless or paired with the wrong secret. | |
| 394 | + * | |
| 395 | + * One response for all of them, so a caller holding only a consumer key cannot tell whether it exists. | |
| 396 | + * | |
| 397 | + * @return WP_Error | |
| 398 | + */ | |
| 399 | + private function refused_key_error() { | |
| 400 | + return new WP_Error( 'mainwp_rest_authentication_disabled_key', __( 'The REST API Key are disabled.', 'mainwp' ), array( 'status' => 401 ) ); | |
| 401 | + } | |
| 402 | + | |
| 403 | + /** | |
| 404 | + * Detect whether a stored consumer_secret value is still in legacy plaintext format. | |
| 405 | + * | |
| 406 | + * Legacy v2 keys were stored as `cs_` followed by hex digits (production uses | |
| 407 | + * 40 hex chars; some older fixtures use 32). New keys created after MWP-1540 | |
| 408 | + * are hashed via wp_hash_password and always begin with `$` (`$P$`, `$wp$`, | |
| 409 | + * `$argon2id$`, etc.). The presence of the `cs_` + hex prefix is sufficient | |
| 410 | + * to distinguish a plaintext token from any password-hash output. | |
| 411 | + * | |
| 412 | + * @todo MWP-1540 sunset (~v6.3): once the migration has shipped for a couple | |
| 413 | + * of minor versions and release notes have given customers time to | |
| 414 | + * rotate their keys, this helper and the legacy branch inside | |
| 415 | + * verify_consumer_secret() can both be removed. | |
| 416 | + * | |
| 417 | + * @param string $value Stored consumer_secret value. | |
| 418 | + * @return bool True when the value is legacy plaintext. | |
| 419 | + */ | |
| 420 | + private function is_legacy_plaintext_secret( $value ) { | |
| 421 | + return is_string( $value ) && (bool) preg_match( '/^cs_[a-f0-9]+$/', $value ); | |
| 422 | + } | |
| 423 | + | |
| 424 | + /** | |
| 425 | + * Verify a submitted consumer_secret against the stored value. | |
| 426 | + * | |
| 427 | + * Handles both legacy plaintext (constant-time compare via hash_equals) and | |
| 428 | + * hashed (wp_check_password) formats. Does NOT auto-rehash legacy rows; | |
| 429 | + * existing plaintext keys stay plaintext until the admin reissues them. | |
| 430 | + * | |
| 431 | + * @param string $stored Stored consumer_secret from the database. | |
| 432 | + * @param string $submitted Consumer_secret submitted in the request. | |
| 433 | + * @return bool True if the submitted secret matches the stored value. | |
| 434 | + */ | |
| 435 | + private function verify_consumer_secret( $stored, $submitted ) { | |
| 436 | + // An array secret (consumer_secret[]=x) would throw here, and only for a live key, which reveals that it exists. | |
| 437 | + if ( ! is_string( $submitted ) ) { | |
| 438 | + return false; | |
| 439 | + } | |
| 440 | + if ( $this->is_legacy_plaintext_secret( $stored ) ) { | |
| 441 | + return hash_equals( $stored, $submitted ); // @codingStandardsIgnoreLine | |
| 442 | + } | |
| 443 | + return wp_check_password( $submitted, $stored ); | |
| 444 | + } | |
| 445 | + | |
| 446 | + /** | |
| 309 | 447 | * Basic Authentication. |
| 310 | 448 | * |
| 311 | 449 | * SSL-encrypted requests are not subject to sniffing or man-in-the-middle |
| 312 | 450 | * attacks, so the request can be authenticated by simply looking up the user |
| @@ -339,17 +477,18 @@ | ||
| 339 | 477 | return false; |
| 340 | 478 | } |
| 341 | 479 | |
| 342 | 480 | // Get user data. |
| 343 | - $this->user = $this->get_user_data_by_consumer_key( $consumer_key ); | |
| 481 | + $user = $this->get_user_data_by_consumer_key( $consumer_key ); | |
| 344 | 482 | |
| 345 | - if ( empty( $this->user ) ) { | |
| 483 | + if ( empty( $user ) ) { | |
| 346 | 484 | return false; |
| 347 | 485 | } |
| 486 | + $this->user = $user; | |
| 348 | 487 | |
| 349 | - // Validate user secret. | |
| 350 | - if ( ! hash_equals( $this->user->consumer_secret, $consumer_secret ) ) { // @codingStandardsIgnoreLine | |
| 351 | - $this->set_error( new WP_Error( 'mainwp_rest_authentication_error', __( 'Consumer secret is invalid.', 'mainwp' ), array( 'status' => 401 ) ) ); | |
| 488 | + // Validate user secret. Handles both legacy plaintext rows and hashed rows. | |
| 489 | + if ( ! $this->verify_consumer_secret( $this->user->consumer_secret, $consumer_secret ) ) { | |
| 490 | + $this->set_error( $this->refused_key_error() ); | |
| 352 | 491 | |
| 353 | 492 | return false; |
| 354 | 493 | } |
| 355 | 494 | |
| @@ -519,16 +658,20 @@ | ||
| 519 | 658 | if ( empty( $params ) ) { |
| 520 | 659 | return false; |
| 521 | 660 | } |
| 522 | 661 | |
| 662 | + if ( ! in_array( $params['oauth_signature_method'], array( 'HMAC-SHA1', 'HMAC-SHA256' ), true ) ) { | |
| 663 | + $this->set_error( new WP_Error( 'mainwp_rest_authentication_error', __( 'Invalid signature - signature method is invalid.', 'mainwp' ), array( 'status' => 401 ) ) ); | |
| 664 | + return false; | |
| 665 | + } | |
| 666 | + | |
| 523 | 667 | // Fetch WP user by consumer key. |
| 524 | - $this->user = $this->get_user_data_by_consumer_key( $params['oauth_consumer_key'] ); | |
| 668 | + $user = $this->get_user_data_by_consumer_key( $params['oauth_consumer_key'] ); | |
| 525 | 669 | |
| 526 | - if ( empty( $this->user ) ) { | |
| 527 | - $this->set_error( new WP_Error( 'mainwp_rest_authentication_error', __( 'Consumer key is invalid.', 'mainwp' ), array( 'status' => 401 ) ) ); | |
| 528 | - | |
| 670 | + if ( empty( $user ) ) { | |
| 529 | 671 | return false; |
| 530 | 672 | } |
| 673 | + $this->user = $user; | |
| 531 | 674 | |
| 532 | 675 | // Perform OAuth validation. |
| 533 | 676 | $signature = $this->check_oauth_signature( $this->user, $params ); |
| 534 | 677 | if ( is_wp_error( $signature ) ) { |
| @@ -579,15 +722,16 @@ | ||
| 579 | 722 | if ( ! $consumer_key || ! $consumer_secret ) { |
| 580 | 723 | return false; |
| 581 | 724 | } |
| 582 | 725 | // Get user data. |
| 583 | - $this->user = $this->get_user_data_by_consumer_key( $consumer_key ); | |
| 584 | - if ( empty( $this->user ) ) { | |
| 726 | + $user = $this->get_user_data_by_consumer_key( $consumer_key ); | |
| 727 | + if ( empty( $user ) ) { | |
| 585 | 728 | return false; |
| 586 | 729 | } |
| 587 | - // Validate user secret. | |
| 588 | - if ( ! hash_equals( $this->user->consumer_secret, $consumer_secret ) ) { // @codingStandardsIgnoreLine | |
| 589 | - $this->set_error( new WP_Error( 'mainwp_rest_authentication_error', __( 'Consumer secret is invalid.', 'mainwp' ), array( 'status' => 401 ) ) ); | |
| 730 | + $this->user = $user; | |
| 731 | + // Validate user secret. Handles both legacy plaintext rows and hashed rows. | |
| 732 | + if ( ! $this->verify_consumer_secret( $this->user->consumer_secret, $consumer_secret ) ) { | |
| 733 | + $this->set_error( $this->refused_key_error() ); | |
| 590 | 734 | |
| 591 | 735 | return false; |
| 592 | 736 | } |
| 593 | 737 | return $this->user->user_id; |
| @@ -667,8 +811,13 @@ | ||
| 667 | 811 | $request_path = substr( $request_path, strlen( $wp_base ) ); |
| 668 | 812 | } |
| 669 | 813 | $base_request_uri = rawurlencode( get_home_url( null, $request_path, $this->is_ssl_request() ? 'https' : 'http' ) ); |
| 670 | 814 | |
| 815 | + // Same reasoning as verify_consumer_secret(): an array signature must not throw only for a live key. | |
| 816 | + if ( ! is_string( $params['oauth_signature'] ) ) { | |
| 817 | + return $this->refused_key_error(); | |
| 818 | + } | |
| 819 | + | |
| 671 | 820 | // Get the signature provided by the consumer and remove it from the parameters prior to checking the signature. |
| 672 | 821 | $consumer_signature = rawurldecode( str_replace( ' ', '+', $params['oauth_signature'] ) ); |
| 673 | 822 | unset( $params['oauth_signature'] ); |
| 674 | 823 | |
| @@ -681,10 +830,12 @@ | ||
| 681 | 830 | $params = $this->normalize_parameters( $params ); |
| 682 | 831 | $query_string = implode( '%26', $this->join_with_equals_sign( $params ) ); // Join with ampersand. |
| 683 | 832 | $string_to_sign = $http_method . '&' . $base_request_uri . '&' . $query_string; |
| 684 | 833 | |
| 685 | - if ( 'HMAC-SHA1' !== $params['oauth_signature_method'] && 'HMAC-SHA256' !== $params['oauth_signature_method'] ) { | |
| 686 | - return new WP_Error( 'mainwp_rest_authentication_error', __( 'Invalid signature - signature method is invalid.', 'mainwp' ), array( 'status' => 401 ) ); | |
| 834 | + // HMAC needs the plaintext secret. A distinct error for a hashed secret | |
| 835 | + // would reveal that the submitted consumer key exists. | |
| 836 | + if ( ! $this->is_legacy_plaintext_secret( $user->consumer_secret ) ) { | |
| 837 | + return $this->refused_key_error(); | |
| 687 | 838 | } |
| 688 | 839 | |
| 689 | 840 | $hash_algorithm = strtolower( str_replace( 'HMAC-', '', $params['oauth_signature_method'] ) ); |
| 690 | 841 | $secret = $user->consumer_secret . '&'; |
| @@ -690,9 +841,9 @@ | ||
| 690 | 841 | $secret = $user->consumer_secret . '&'; |
| 691 | 842 | $signature = base64_encode( hash_hmac( $hash_algorithm, $string_to_sign, $secret, true ) ); //phpcs:ignore -- ok. |
| 692 | 843 | |
| 693 | 844 | if ( ! hash_equals( $signature, $consumer_signature ) ) { // @codingStandardsIgnoreLine |
| 694 | - return new WP_Error( 'mainwp_rest_authentication_error', __( 'Invalid signature - provided signature does not match.', 'mainwp' ), array( 'status' => 401 ) ); | |
| 845 | + return $this->refused_key_error(); | |
| 695 | 846 | } |
| 696 | 847 | return true; |
| 697 | 848 | } |
| 698 | 849 | |
| @@ -794,9 +945,9 @@ | ||
| 794 | 945 | array( '%s' ), |
| 795 | 946 | array( '%d' ) |
| 796 | 947 | ); |
| 797 | 948 | |
| 798 | - wp_cache_delete( 'mainwp_api_key_' . md5( $user->consumer_key ) ); // NOSONAR - MD5 used for cache key generation only, not cryptographic (security) purposes. | |
| 949 | + self::delete_api_key_cache( $user->consumer_key ); | |
| 799 | 950 | |
| 800 | 951 | return true; |
| 801 | 952 | } |
| 802 | 953 | |
| @@ -809,14 +960,14 @@ | ||
| 809 | 960 | private function get_user_data_by_consumer_key( $consumer_key ) { |
| 810 | 961 | global $wpdb; |
| 811 | 962 | |
| 812 | 963 | $consumer_key = \mainwp_api_hash( sanitize_text_field( $consumer_key ) ); |
| 813 | - $cache_key = 'mainwp_api_key_' . md5( $consumer_key ); // NOSONAR - MD5 used for cache key generation only, not cryptographic (security) purposes. | |
| 964 | + $cache_key = self::api_key_cache_key( $consumer_key ); | |
| 814 | 965 | $user = wp_cache_get( $cache_key ); |
| 815 | 966 | |
| 816 | 967 | if ( false === $user ) { |
| 817 | 968 | $table_name = esc_sql( MainWP_DB::instance()->get_table_name( 'api_keys' ) ); |
| 818 | - $user = $wpdb->get_row( | |
| 969 | + $user = $wpdb->get_row( | |
| 819 | 970 | $wpdb->prepare( |
| 820 | 971 | 'SELECT * FROM ' . $table_name . ' WHERE consumer_key = %s', |
| 821 | 972 | $consumer_key |
| 822 | 973 | ) |
| @@ -821,23 +972,58 @@ | ||
| 821 | 972 | $consumer_key |
| 822 | 973 | ) |
| 823 | 974 | ); |
| 824 | 975 | if ( $user ) { |
| 825 | - wp_cache_set( $cache_key, $user ); | |
| 976 | + // Key edits and deletes clear this entry, but a request that read the row just before the write can | |
| 977 | + // store it again afterwards; the expiry bounds how long that stale row can authenticate. | |
| 978 | + wp_cache_set( $cache_key, $user, '', 5 * MINUTE_IN_SECONDS ); | |
| 826 | 979 | } |
| 827 | 980 | } |
| 828 | 981 | |
| 829 | 982 | if ( empty( $user->enabled ) ) { |
| 830 | - $this->set_error( new \WP_Error( 'mainwp_rest_authentication_disabled_key', __( 'The REST API Key are disabled.', 'mainwp' ), array( 'status' => 401 ) ) ); | |
| 983 | + $this->set_error( $this->refused_key_error() ); | |
| 831 | 984 | return false; |
| 832 | 985 | } |
| 833 | - // phpcs:disable WordPress.Security.NonceVerification | |
| 834 | - $pass = ! empty( $_REQUEST['key_pass'] ) ? wp_unslash( $_REQUEST['key_pass'] ) : ''; | |
| 835 | - // phpcs:enable WordPress.Security.NonceVerification | |
| 836 | - if ( 1 === (int) $user->key_type && $pass !== $user->key_pass ) { | |
| 837 | - $this->set_error( new \WP_Error( 'mainwp_rest_authentication_invalid_key_pass', __( 'The REST API passphrase is invalid.', 'mainwp' ), array( 'status' => 401 ) ) ); | |
| 986 | + | |
| 987 | + $user_id = (int) $user->user_id; | |
| 988 | + $user_exists = false !== get_userdata( $user_id ); | |
| 989 | + // Super admins can hold keys on a Dashboard subsite they were never added to. | |
| 990 | + $outside_blog = is_multisite() && ! is_super_admin( $user_id ) && ! is_user_member_of_blog( $user_id ); | |
| 991 | + if ( ! $user_exists || $outside_blog ) { | |
| 992 | + $this->set_error( $this->refused_key_error() ); | |
| 838 | 993 | return false; |
| 839 | 994 | } |
| 995 | + | |
| 996 | + // Legacy: passphrase enforcement for any rows where key_type=1. | |
| 997 | + // No current code path creates such rows (see MWP-1544) and no caller | |
| 998 | + // of insert_rest_api_key sets key_type to 1, so this branch is dormant | |
| 999 | + // on every install we have visibility into. It is preserved to avoid | |
| 1000 | + // silently weakening hypothetical pre-existing rows where someone | |
| 1001 | + // wired key_type=1 by hand or via removed migration code. The compare | |
| 1002 | + // is timing-safe (was a vanilla !== before MWP-1540) and we leave the | |
| 1003 | + // submitted value unsanitized so that a passphrase containing | |
| 1004 | + // whitespace or HTML-like content still matches a legitimately | |
| 1005 | + // stored value. | |
| 1006 | + // | |
| 1007 | + // TODO MWP-1544 sunset (~v6.3): remove this entire block. Nothing in | |
| 1008 | + // the dashboard creates key_type=1 rows anymore, and the column | |
| 1009 | + // itself can be dropped at the same time. | |
| 1010 | + if ( 1 === (int) $user->key_type ) { | |
| 1011 | + // The submitted value is only fed into hash_equals against the | |
| 1012 | + // stored passphrase; it is never echoed, stored, or used in a | |
| 1013 | + // query, so sanitization is intentionally skipped to preserve | |
| 1014 | + // an exact match against legacy plaintext values that may | |
| 1015 | + // contain whitespace or HTML-like characters. | |
| 1016 | + // phpcs:disable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized | |
| 1017 | + $pass = ! empty( $_REQUEST['key_pass'] ) && is_string( $_REQUEST['key_pass'] ) ? wp_unslash( $_REQUEST['key_pass'] ) : ''; | |
| 1018 | + // phpcs:enable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized | |
| 1019 | + $expected = isset( $user->key_pass ) ? (string) $user->key_pass : ''; | |
| 1020 | + if ( '' === $expected || ! hash_equals( $expected, $pass ) ) { | |
| 1021 | + $this->set_error( new \WP_Error( 'mainwp_rest_authentication_invalid_key_pass', __( 'The REST API passphrase is invalid.', 'mainwp' ), array( 'status' => 401 ) ) ); | |
| 1022 | + return false; | |
| 1023 | + } | |
| 1024 | + } | |
| 1025 | + | |
| 840 | 1026 | return $user; |
| 841 | 1027 | } |
| 842 | 1028 | |
| 843 | 1029 | |
| @@ -911,9 +1097,9 @@ | ||
| 911 | 1097 | array( '%s' ), |
| 912 | 1098 | array( '%d' ) |
| 913 | 1099 | ); |
| 914 | 1100 | |
| 915 | - wp_cache_delete( 'mainwp_api_key_' . md5( $this->user->consumer_key ) ); // NOSONAR - MD5 used for cache key generation only, not cryptographic (security) purposes. | |
| 1101 | + self::delete_api_key_cache( $this->user->consumer_key ); | |
| 916 | 1102 | } |
| 917 | 1103 | |
| 918 | 1104 | /** |
| 919 | 1105 | * If the consumer_key and consumer_secret $_GET parameters are NOT provided |
| @@ -942,8 +1128,12 @@ | ||
| 942 | 1128 | */ |
| 943 | 1129 | public function check_user_permissions( $result, $server, $request ) { |
| 944 | 1130 | unset( $server ); |
| 945 | 1131 | if ( $this->user ) { |
| 1132 | + if ( ! $this->is_mainwp_route( $request->get_route() ) ) { | |
| 1133 | + return new WP_Error( 'mainwp_rest_authentication_error', __( 'MainWP API keys can only authenticate MainWP REST routes.', 'mainwp' ), array( 'status' => 401 ) ); | |
| 1134 | + } | |
| 1135 | + | |
| 946 | 1136 | // Check API Key permissions. |
| 947 | 1137 | $allowed = $this->check_permissions( $request->get_method() ); |
| 948 | 1138 | if ( is_wp_error( $allowed ) ) { |
| 949 | 1139 | return $allowed; |