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 +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;