PluginProbe
MainWP Dashboard: Self-hosted WordPress Management for Agencies / 6.2.3
MainWP Dashboard: Self-hosted WordPress Management for Agencies v6.2.3
6.3 6.2.3 6.2.2 6.2.1 6.2 6.1.8 6.1.7 6.1.6 6.1.5 6.1.4 6.1.3 6.1.2 6.1.1 6.1 6.0.12 6.0.11 4.6.0.1 5.0 5.0.1 5.0.2 5.0.3 5.0.3.1 5.0.3.2 5.1 5.1.1 All 157 releases
← All changes | includes/rest-api/class-mainwp-rest-authentication.php +173 -59 6.1.6 → 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,19 @@
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 + /**
309 404 * Detect whether a stored consumer_secret value is still in legacy plaintext format.
310 405 *
311 406 * Legacy v2 keys were stored as `cs_` followed by hex digits (production uses
312 407 * 40 hex chars; some older fixtures use 32). New keys created after MWP-1540
@@ -337,8 +432,12 @@
337 432 * @param string $submitted Consumer_secret submitted in the request.
338 433 * @return bool True if the submitted secret matches the stored value.
339 434 */
340 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 + }
341 440 if ( $this->is_legacy_plaintext_secret( $stored ) ) {
342 441 return hash_equals( $stored, $submitted ); // @codingStandardsIgnoreLine
343 442 }
344 443 return wp_check_password( $submitted, $stored );
@@ -378,17 +477,18 @@
378 477 return false;
379 478 }
380 479
381 480 // Get user data.
382 - $this->user = $this->get_user_data_by_consumer_key( $consumer_key );
481 + $user = $this->get_user_data_by_consumer_key( $consumer_key );
383 482
384 - if ( empty( $this->user ) ) {
483 + if ( empty( $user ) ) {
385 484 return false;
386 485 }
486 + $this->user = $user;
387 487
388 488 // Validate user secret. Handles both legacy plaintext rows and hashed rows.
389 489 if ( ! $this->verify_consumer_secret( $this->user->consumer_secret, $consumer_secret ) ) {
390 - $this->set_error( new WP_Error( 'mainwp_rest_authentication_error', __( 'Consumer secret is invalid.', 'mainwp' ), array( 'status' => 401 ) ) );
490 + $this->set_error( $this->refused_key_error() );
391 491
392 492 return false;
393 493 }
394 494
@@ -558,16 +658,20 @@
558 658 if ( empty( $params ) ) {
559 659 return false;
560 660 }
561 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 +
562 667 // Fetch WP user by consumer key.
563 - $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'] );
564 669
565 - if ( empty( $this->user ) ) {
566 - $this->set_error( new WP_Error( 'mainwp_rest_authentication_error', __( 'Consumer key is invalid.', 'mainwp' ), array( 'status' => 401 ) ) );
567 -
670 + if ( empty( $user ) ) {
568 671 return false;
569 672 }
673 + $this->user = $user;
570 674
571 675 // Perform OAuth validation.
572 676 $signature = $this->check_oauth_signature( $this->user, $params );
573 677 if ( is_wp_error( $signature ) ) {
@@ -618,15 +722,16 @@
618 722 if ( ! $consumer_key || ! $consumer_secret ) {
619 723 return false;
620 724 }
621 725 // Get user data.
622 - $this->user = $this->get_user_data_by_consumer_key( $consumer_key );
623 - if ( empty( $this->user ) ) {
726 + $user = $this->get_user_data_by_consumer_key( $consumer_key );
727 + if ( empty( $user ) ) {
624 728 return false;
625 729 }
730 + $this->user = $user;
626 731 // Validate user secret. Handles both legacy plaintext rows and hashed rows.
627 732 if ( ! $this->verify_consumer_secret( $this->user->consumer_secret, $consumer_secret ) ) {
628 - $this->set_error( new WP_Error( 'mainwp_rest_authentication_error', __( 'Consumer secret is invalid.', 'mainwp' ), array( 'status' => 401 ) ) );
733 + $this->set_error( $this->refused_key_error() );
629 734
630 735 return false;
631 736 }
632 737 return $this->user->user_id;
@@ -706,8 +811,13 @@
706 811 $request_path = substr( $request_path, strlen( $wp_base ) );
707 812 }
708 813 $base_request_uri = rawurlencode( get_home_url( null, $request_path, $this->is_ssl_request() ? 'https' : 'http' ) );
709 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 +
710 820 // Get the signature provided by the consumer and remove it from the parameters prior to checking the signature.
711 821 $consumer_signature = rawurldecode( str_replace( ' ', '+', $params['oauth_signature'] ) );
712 822 unset( $params['oauth_signature'] );
713 823
@@ -720,23 +830,12 @@
720 830 $params = $this->normalize_parameters( $params );
721 831 $query_string = implode( '%26', $this->join_with_equals_sign( $params ) ); // Join with ampersand.
722 832 $string_to_sign = $http_method . '&' . $base_request_uri . '&' . $query_string;
723 833
724 - if ( 'HMAC-SHA1' !== $params['oauth_signature_method'] && 'HMAC-SHA256' !== $params['oauth_signature_method'] ) {
725 - return new WP_Error( 'mainwp_rest_authentication_error', __( 'Invalid signature - signature method is invalid.', 'mainwp' ), array( 'status' => 401 ) );
726 - }
727 -
728 - // OAuth 1.0a HMAC verification requires the plaintext secret as the HMAC
729 - // key. Keys created after MWP-1540 store the secret hashed at rest and
730 - // therefore cannot be used with this auth method. Direct the caller to
731 - // Basic Auth over HTTPS or to reissue (and use the new key only via
732 - // Basic Auth).
733 - //
734 - // TODO MWP-1540 sunset (~v6.3): drop this branch. By the sunset target
735 - // any plaintext keys still in the wild will already be failing through
736 - // Basic Auth as well, so reissue is the only path forward anyway.
834 + // HMAC needs the plaintext secret. A distinct error for a hashed secret
835 + // would reveal that the submitted consumer key exists.
737 836 if ( ! $this->is_legacy_plaintext_secret( $user->consumer_secret ) ) {
738 - return new WP_Error( 'mainwp_rest_authentication_oauth1_unsupported', __( 'OAuth 1.0a is not supported for this API key. Use Basic Auth over HTTPS or reissue the key.', 'mainwp' ), array( 'status' => 401 ) );
837 + return $this->refused_key_error();
739 838 }
740 839
741 840 $hash_algorithm = strtolower( str_replace( 'HMAC-', '', $params['oauth_signature_method'] ) );
742 841 $secret = $user->consumer_secret . '&';
@@ -742,9 +841,9 @@
742 841 $secret = $user->consumer_secret . '&';
743 842 $signature = base64_encode( hash_hmac( $hash_algorithm, $string_to_sign, $secret, true ) ); //phpcs:ignore -- ok.
744 843
745 844 if ( ! hash_equals( $signature, $consumer_signature ) ) { // @codingStandardsIgnoreLine
746 - 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();
747 846 }
748 847 return true;
749 848 }
750 849
@@ -846,9 +945,9 @@
846 945 array( '%s' ),
847 946 array( '%d' )
848 947 );
849 948
850 - 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 );
851 950
852 951 return true;
853 952 }
854 953
@@ -861,9 +960,9 @@
861 960 private function get_user_data_by_consumer_key( $consumer_key ) {
862 961 global $wpdb;
863 962
864 963 $consumer_key = \mainwp_api_hash( sanitize_text_field( $consumer_key ) );
865 - $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 );
866 965 $user = wp_cache_get( $cache_key );
867 966
868 967 if ( false === $user ) {
869 968 $table_name = esc_sql( MainWP_DB::instance()->get_table_name( 'api_keys' ) );
@@ -873,17 +972,28 @@
873 972 $consumer_key
874 973 )
875 974 );
876 975 if ( $user ) {
877 - 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 );
878 979 }
879 980 }
880 981
881 982 if ( empty( $user->enabled ) ) {
882 - $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() );
883 984 return false;
884 985 }
885 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() );
993 + return false;
994 + }
995 +
886 996 // Legacy: passphrase enforcement for any rows where key_type=1.
887 997 // No current code path creates such rows (see MWP-1544) and no caller
888 998 // of insert_rest_api_key sets key_type to 1, so this branch is dormant
889 999 // on every install we have visibility into. It is preserved to avoid
@@ -903,9 +1013,9 @@
903 1013 // query, so sanitization is intentionally skipped to preserve
904 1014 // an exact match against legacy plaintext values that may
905 1015 // contain whitespace or HTML-like characters.
906 1016 // phpcs:disable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
907 - $pass = ! empty( $_REQUEST['key_pass'] ) ? wp_unslash( $_REQUEST['key_pass'] ) : '';
1017 + $pass = ! empty( $_REQUEST['key_pass'] ) && is_string( $_REQUEST['key_pass'] ) ? wp_unslash( $_REQUEST['key_pass'] ) : '';
908 1018 // phpcs:enable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
909 1019 $expected = isset( $user->key_pass ) ? (string) $user->key_pass : '';
910 1020 if ( '' === $expected || ! hash_equals( $expected, $pass ) ) {
911 1021 $this->set_error( new \WP_Error( 'mainwp_rest_authentication_invalid_key_pass', __( 'The REST API passphrase is invalid.', 'mainwp' ), array( 'status' => 401 ) ) );
@@ -987,9 +1097,9 @@
987 1097 array( '%s' ),
988 1098 array( '%d' )
989 1099 );
990 1100
991 - 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 );
992 1102 }
993 1103
994 1104 /**
995 1105 * If the consumer_key and consumer_secret $_GET parameters are NOT provided
@@ -1018,8 +1128,12 @@
1018 1128 */
1019 1129 public function check_user_permissions( $result, $server, $request ) {
1020 1130 unset( $server );
1021 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 +
1022 1136 // Check API Key permissions.
1023 1137 $allowed = $this->check_permissions( $request->get_method() );
1024 1138 if ( is_wp_error( $allowed ) ) {
1025 1139 return $allowed;