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 +390 -65 5.2 → 6.2.3 View file →
@@ -12,11 +12,41 @@
12 12 defined( 'ABSPATH' ) || exit;
13 13
14 14 /**
15 15 * REST API authentication class.
16 + *
17 + * This class handles authentication for MainWP's custom REST API endpoints.
18 + * It supports Basic Authentication, OAuth 1.0a, and Bearer Token authentication.
19 + *
20 + * IMPORTANT: This authentication is ONLY applied to MainWP REST endpoints
21 + * (namespaces starting with 'mainwp/' or 'mainwp-'). Other REST APIs,
22 + * including the WordPress Abilities API (wp-abilities/v1/abilities/mainwp/...),
23 + * use their own native WordPress authentication and MUST NOT be intercepted
24 + * by MainWP's REST authentication. See is_request_to_rest_api() for details.
16 25 */
17 26 class MainWP_REST_Authentication { //phpcs:ignore -- NOSONAR - maximumMethodThreshold.
18 27
28 + /**
29 + * MainWP REST API namespace prefix.
30 + *
31 + * Used to identify requests that should be authenticated by MainWP's REST auth system.
32 + * This prefix matches routes like /wp-json/mainwp/v2/sites but NOT routes like
33 + * /wp-json/wp-abilities/v1/abilities/mainwp/list-sites-v1 (which use native WP auth).
34 + *
35 + * @var string
36 + */
37 + const MAINWP_REST_NAMESPACE = 'mainwp/';
38 +
39 + /**
40 + * MainWP extension REST API namespace prefix.
41 + *
42 + * Used to allow third-party MainWP extensions to use MainWP's REST auth.
43 + * Matches routes like /wp-json/mainwp-extension/v1/...
44 + *
45 + * @var string
46 + */
47 + const MAINWP_EXTENSION_REST_NAMESPACE = 'mainwp-';
48 +
19 49 //phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared
20 50
21 51 /**
22 52 * Authentication error.
@@ -83,30 +113,124 @@
83 113 add_filter( 'rest_authentication_errors', array( $this, 'authentication_fallback' ) );
84 114 add_filter( 'rest_authentication_errors', array( $this, 'check_authentication_error' ), 15 );
85 115 add_filter( 'rest_post_dispatch', array( $this, 'send_unauthorized_headers' ), 50 );
86 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' ) );
87 120 }
88 121
89 122 /**
90 - * Check if is request to our REST API.
123 + * Delete an owner's API keys and their cache entries.
91 124 *
92 - * @return bool
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.
93 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.
140 + *
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.
149 + *
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.
159 + *
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.
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 + *
202 + * @return bool True if request is to MainWP REST API, false otherwise.
203 + */
94 204 protected function is_request_to_rest_api() {
95 - if ( empty( $_SERVER['REQUEST_URI'] ) ) {
205 + $route = $GLOBALS['wp']->query_vars['rest_route'] ?? '';
206 + if ( empty( $route ) || ! is_string( $route ) ) {
96 207 return false;
97 208 }
98 209
99 - $rest_prefix = trailingslashit( rest_get_url_prefix() );
100 - $request_uri = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
210 + /**
211 + * Filter whether a request is considered to be to the MainWP REST API.
212 + *
213 + * IMPORTANT: Do NOT add Abilities API endpoints (wp-abilities/v1/abilities/mainwp/...)
214 + * to this filter. Those endpoints use native WordPress authentication and should
215 + * NOT be processed by MainWP's REST authentication system.
216 + *
217 + * @param bool $is_mainwp_api True if this is a MainWP REST API request.
218 + */
219 + return apply_filters( 'mainwp_rest_is_request_to_rest_api', $this->is_mainwp_route( $route ) );
220 + }
101 221
102 - // Check if the request is the API endpoints.
103 - $mainwp_api = ( false !== strpos( $request_uri, $rest_prefix . 'mainwp/' ) );
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] );
104 231
105 - // Allow third party plugins use our authentication methods.
106 - $extension_api = ( false !== strpos( $request_uri, $rest_prefix . 'mainwp-' ) );
107 -
108 - return apply_filters( 'mainwp_rest_is_request_to_rest_api', $mainwp_api || $extension_api );
232 + return rtrim( self::MAINWP_REST_NAMESPACE, '/' ) === $namespace || 0 === strpos( $namespace, self::MAINWP_EXTENSION_REST_NAMESPACE );
109 233 }
110 234
111 235 /**
112 236 * Authenticate user.
@@ -119,9 +243,9 @@
119 243 if ( ! empty( $user_id ) || ! $this->is_request_to_rest_api() ) {
120 244 return $user_id;
121 245 }
122 246
123 - if ( is_ssl() ) {
247 + if ( $this->is_ssl_request() ) {
124 248 $user_id = $this->perform_basic_authentication();
125 249 }
126 250
127 251 if ( $user_id ) {
@@ -132,14 +256,52 @@
132 256 if ( $user_id ) {
133 257 return $user_id;
134 258 }
135 259
136 - if ( is_ssl() ) {
260 + if ( ! $this->is_ssl_request() ) {
261 + $this->set_error( new \WP_Error( 'mainwp_rest_authentication_bad_request', __( 'Requests to the MainWP REST API must be over HTTPS.', 'mainwp' ), array( 'status' => 400 ) ) );
262 + return false;
263 + } else {
137 264 return $this->perform_basic_token_authentication();
138 265 }
139 266 }
140 267
141 268 /**
269 + * Method is_ssl_request().
270 + *
271 + * Check is https request.
272 + *
273 + * @return bool
274 + */
275 + private function is_ssl_request() {
276 + if ( is_ssl() || wp_is_using_https() || $this->is_local_request() ) {
277 + return true;
278 + }
279 + return false;
280 + }
281 +
282 + /**
283 + * Method is_local_request().
284 + *
285 + * @return bool
286 + */
287 + private function is_local_request() {
288 +
289 + if ( ! isset( $_SERVER['REMOTE_ADDR'] ) ) {
290 + return false;
291 + }
292 +
293 + $ip = $_SERVER['REMOTE_ADDR']; //phpcs:ignore -- ok.
294 +
295 + // Always allow localhost.
296 + if ( '127.0.0.1' === $ip || '::1' === $ip ) {
297 + return true;
298 + }
299 + return false;
300 + }
301 +
302 +
303 + /**
142 304 * Authenticate the user if authentication wasn't performed during the
143 305 * determine_current_user action.
144 306 *
145 307 * Necessary in cases where wp_get_current_user() is called before the plugin is loaded.
@@ -176,12 +338,38 @@
176 338 if ( ! empty( $error ) ) {
177 339 return $error;
178 340 }
179 341
342 + // Only return MainWP auth errors for MainWP API endpoints.
343 + // Other REST endpoints (e.g., wp-abilities, core WordPress) should use
344 + // their own authentication mechanisms without MainWP interference.
345 + if ( ! $this->is_request_to_rest_api() ) {
346 + return $error;
347 + }
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 +
180 355 return $this->get_error();
181 356 }
182 357
183 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 + /**
184 372 * Set authentication error.
185 373 *
186 374 * @param WP_Error $error Authentication error data.
187 375 */
@@ -201,8 +389,62 @@
201 389 return $this->error;
202 390 }
203 391
204 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 + /**
205 447 * Basic Authentication.
206 448 *
207 449 * SSL-encrypted requests are not subject to sniffing or man-in-the-middle
208 450 * attacks, so the request can be authenticated by simply looking up the user
@@ -235,17 +477,18 @@
235 477 return false;
236 478 }
237 479
238 480 // Get user data.
239 - $this->user = $this->get_user_data_by_consumer_key( $consumer_key );
481 + $user = $this->get_user_data_by_consumer_key( $consumer_key );
240 482
241 - if ( empty( $this->user ) ) {
483 + if ( empty( $user ) ) {
242 484 return false;
243 485 }
486 + $this->user = $user;
244 487
245 - // Validate user secret.
246 - if ( ! hash_equals( $this->user->consumer_secret, $consumer_secret ) ) { // @codingStandardsIgnoreLine
247 - $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() );
248 491
249 492 return false;
250 493 }
251 494
@@ -381,14 +624,9 @@
381 624
382 625 // If we have at least one supplied piece of data, and we have an error,
383 626 // then it's a failed authentication.
384 627 if ( ! empty( $errors ) ) {
385 - $message = sprintf(
386 - /* translators: %s: amount of errors */
387 - _n( 'Missing OAuth parameter %s', 'Missing OAuth parameters %s', count( $errors ), 'mainwp' ),
388 - implode( ', ', $errors )
389 - );
390 -
628 + $message = __( 'Invalid OAuth parameter.', 'mainwp' );
391 629 $this->set_error( new WP_Error( 'mainwp_rest_authentication_missing_parameter', $message, array( 'status' => 401 ) ) );
392 630
393 631 return array();
394 632 }
@@ -420,16 +658,20 @@
420 658 if ( empty( $params ) ) {
421 659 return false;
422 660 }
423 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 +
424 667 // Fetch WP user by consumer key.
425 - $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'] );
426 669
427 - if ( empty( $this->user ) ) {
428 - $this->set_error( new WP_Error( 'mainwp_rest_authentication_error', __( 'Consumer key is invalid.', 'mainwp' ), array( 'status' => 401 ) ) );
429 -
670 + if ( empty( $user ) ) {
430 671 return false;
431 672 }
673 + $this->user = $user;
432 674
433 675 // Perform OAuth validation.
434 676 $signature = $this->check_oauth_signature( $this->user, $params );
435 677 if ( is_wp_error( $signature ) ) {
@@ -480,15 +722,16 @@
480 722 if ( ! $consumer_key || ! $consumer_secret ) {
481 723 return false;
482 724 }
483 725 // Get user data.
484 - $this->user = $this->get_user_data_by_consumer_key( $consumer_key );
485 - if ( empty( $this->user ) ) {
726 + $user = $this->get_user_data_by_consumer_key( $consumer_key );
727 + if ( empty( $user ) ) {
486 728 return false;
487 729 }
488 - // Validate user secret.
489 - if ( ! hash_equals( $this->user->consumer_secret, $consumer_secret ) ) { // @codingStandardsIgnoreLine
490 - $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() );
491 734
492 735 return false;
493 736 }
494 737 return $this->user->user_id;
@@ -543,16 +786,10 @@
543 786
544 787 // If we have at least one supplied piece of data, and we have an error,
545 788 // then it's a failed authentication.
546 789 if ( ! empty( $errors ) ) {
547 - $message = sprintf(
548 - /* translators: %s: amount of errors */
549 - _n( 'Missing OAuth parameter %s', 'Missing OAuth parameters %s', count( $errors ), 'mainwp' ),
550 - implode( ', ', $errors )
551 - );
552 -
790 + $message = __( 'Invalid OAuth parameter.', 'mainwp' );
553 791 $this->set_error( new WP_Error( 'mainwp_rest_authentication_missing_parameter', $message, array( 'status' => 401 ) ) );
554 -
555 792 return array();
556 793 }
557 794
558 795 return $params;
@@ -572,10 +809,15 @@
572 809 $wp_base = get_home_url( null, '/', 'relative' );
573 810 if ( substr( $request_path, 0, strlen( $wp_base ) ) === $wp_base ) {
574 811 $request_path = substr( $request_path, strlen( $wp_base ) );
575 812 }
576 - $base_request_uri = rawurlencode( get_home_url( null, $request_path, is_ssl() ? 'https' : 'http' ) );
813 + $base_request_uri = rawurlencode( get_home_url( null, $request_path, $this->is_ssl_request() ? 'https' : 'http' ) );
577 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 +
578 820 // Get the signature provided by the consumer and remove it from the parameters prior to checking the signature.
579 821 $consumer_signature = rawurldecode( str_replace( ' ', '+', $params['oauth_signature'] ) );
580 822 unset( $params['oauth_signature'] );
581 823
@@ -588,10 +830,12 @@
588 830 $params = $this->normalize_parameters( $params );
589 831 $query_string = implode( '%26', $this->join_with_equals_sign( $params ) ); // Join with ampersand.
590 832 $string_to_sign = $http_method . '&' . $base_request_uri . '&' . $query_string;
591 833
592 - if ( 'HMAC-SHA1' !== $params['oauth_signature_method'] && 'HMAC-SHA256' !== $params['oauth_signature_method'] ) {
593 - 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();
594 838 }
595 839
596 840 $hash_algorithm = strtolower( str_replace( 'HMAC-', '', $params['oauth_signature_method'] ) );
597 841 $secret = $user->consumer_secret . '&';
@@ -597,9 +841,9 @@
597 841 $secret = $user->consumer_secret . '&';
598 842 $signature = base64_encode( hash_hmac( $hash_algorithm, $string_to_sign, $secret, true ) ); //phpcs:ignore -- ok.
599 843
600 844 if ( ! hash_equals( $signature, $consumer_signature ) ) { // @codingStandardsIgnoreLine
601 - 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();
602 846 }
603 847 return true;
604 848 }
605 849
@@ -701,8 +945,10 @@
701 945 array( '%s' ),
702 946 array( '%d' )
703 947 );
704 948
949 + self::delete_api_key_cache( $user->consumer_key );
950 +
705 951 return true;
706 952 }
707 953
708 954 /**
@@ -708,30 +954,76 @@
708 954 /**
709 955 * Return the user data for the given consumer_key.
710 956 *
711 957 * @param string $consumer_key Consumer key.
712 - * @return array
958 + * @return mixed
713 959 */
714 960 private function get_user_data_by_consumer_key( $consumer_key ) {
715 961 global $wpdb;
716 962
717 963 $consumer_key = \mainwp_api_hash( sanitize_text_field( $consumer_key ) );
718 - $user = $wpdb->get_row(
719 - $wpdb->prepare(
720 - '
721 - SELECT key_id, user_id, permissions, consumer_key, consumer_secret, nonces, enabled FROM ' .
722 - MainWP_DB::instance()->get_table_name( 'api_keys' ) . '
723 - WHERE consumer_key = %s
724 - ',
725 - $consumer_key
726 - )
727 - );
964 + $cache_key = self::api_key_cache_key( $consumer_key );
965 + $user = wp_cache_get( $cache_key );
728 966
967 + if ( false === $user ) {
968 + $table_name = esc_sql( MainWP_DB::instance()->get_table_name( 'api_keys' ) );
969 + $user = $wpdb->get_row(
970 + $wpdb->prepare(
971 + 'SELECT * FROM ' . $table_name . ' WHERE consumer_key = %s',
972 + $consumer_key
973 + )
974 + );
975 + if ( $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 );
979 + }
980 + }
981 +
729 982 if ( empty( $user->enabled ) ) {
730 - $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() );
731 984 return false;
732 985 }
733 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 +
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 +
734 1026 return $user;
735 1027 }
736 1028
737 1029
@@ -742,32 +1034,42 @@
742 1034 * @return bool|WP_Error
743 1035 */
744 1036 private function check_permissions( $method ) {
745 1037 $permissions = $this->user->permissions;
746 -
1038 + $msg = '';
1039 + $flag = true;
747 1040 switch ( $method ) {
748 1041 case 'HEAD':
749 1042 case 'GET':
750 1043 if ( 'read' !== $permissions && 'read_write' !== $permissions ) {
751 - return new WP_Error( 'mainwp_rest_authentication_error', __( 'The API key provided does not have read permissions.', 'mainwp' ), array( 'status' => 401 ) );
1044 + $msg = __( 'The API key provided does not have read permissions.', 'mainwp' );
752 1045 }
753 1046 break;
754 1047 case 'POST':
755 1048 case 'PUT':
756 1049 case 'PATCH':
1050 + if ( 'write' !== $permissions && 'read_write' !== $permissions ) {
1051 + $msg = __( 'The API key provided does not have write permissions.', 'mainwp' );
1052 + }
1053 + break;
757 1054 case 'DELETE':
758 - if ( 'write' !== $permissions && 'read_write' !== $permissions ) {
759 - return new WP_Error( 'mainwp_rest_authentication_error', __( 'The API key provided does not have write permissions.', 'mainwp' ), array( 'status' => 401 ) );
1055 + if ( 'write' !== $permissions && 'read_write' !== $permissions && 'delete' !== $permissions ) {
1056 + $msg = __( 'The API key provided does not have delete permissions.', 'mainwp' );
760 1057 }
761 1058 break;
762 1059 case 'OPTIONS':
763 - return true;
1060 + $flag = true;
1061 + break;
1062 + default:
1063 + $msg = __( 'Unknown request method.', 'mainwp' );
1064 + }
764 1065
765 - default:
766 - return new WP_Error( 'mainwp_rest_authentication_error', __( 'Unknown request method.', 'mainwp' ), array( 'status' => 401 ) );
1066 + if ( ! empty( $msg ) ) {
1067 + return new WP_Error( 'mainwp_rest_authentication_error', $msg, array( 'status' => 401 ) );
1068 +
767 1069 }
768 1070
769 - return true;
1071 + return $flag;
770 1072 }
771 1073
772 1074 /**
773 1075 * Updated API Key last access datetime.
@@ -794,8 +1096,10 @@
794 1096 array( 'key_id' => $this->user->key_id ),
795 1097 array( '%s' ),
796 1098 array( '%d' )
797 1099 );
1100 +
1101 + self::delete_api_key_cache( $this->user->consumer_key );
798 1102 }
799 1103
800 1104 /**
801 1105 * If the consumer_key and consumer_secret $_GET parameters are NOT provided
@@ -824,8 +1128,12 @@
824 1128 */
825 1129 public function check_user_permissions( $result, $server, $request ) {
826 1130 unset( $server );
827 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 +
828 1136 // Check API Key permissions.
829 1137 $allowed = $this->check_permissions( $request->get_method() );
830 1138 if ( is_wp_error( $allowed ) ) {
831 1139 return $allowed;
@@ -842,15 +1150,32 @@
842 1150 return $result;
843 1151 }
844 1152
845 1153 /**
1154 + * Method get_rest_valid_user().
1155 + *
1156 + * @return mixed|object User api key object.
1157 + */
1158 + public function get_rest_valid_user() {
1159 + return $this->user;
1160 + }
1161 +
1162 + /**
846 1163 * Valid REST permissions.
847 1164 *
848 1165 * @param WP_REST_Request $request Request used to generate the response.
849 1166 *
850 - * @return objext user rest data.
1167 + * @return bool|\WP_Error True if valid, WP_Error otherwise.
851 1168 */
852 1169 public function is_valid_permissions( $request ) {
1170 + // If no user is authenticated, deny permission.
1171 + if ( null === $this->user ) {
1172 + return new WP_Error(
1173 + 'mainwp_rest_authentication_error',
1174 + __( 'You are not authenticated.', 'mainwp' ),
1175 + array( 'status' => 401 )
1176 + );
1177 + }
853 1178 return $this->check_permissions( $request->get_method() );
854 1179 }
855 1180 }
856 1181