PluginProbe
MainWP Dashboard: Self-hosted WordPress Management for Agencies / 6.2.1
MainWP Dashboard: Self-hosted WordPress Management for Agencies v6.2.1
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 5.2 5.2.1 All 155 releases
← All changes | includes/rest-api/class-mainwp-rest-authentication.php +256 -45 5.2 → 6.2.1 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.
@@ -86,11 +116,29 @@
86 116 add_filter( 'rest_pre_dispatch', array( $this, 'check_user_permissions' ), 10, 3 );
87 117 }
88 118
89 119 /**
90 - * Check if is request to our REST API.
120 + * Check if this is a request to MainWP's REST API.
91 121 *
92 - * @return bool
122 + * This method determines whether the current request should use MainWP's
123 + * custom REST API authentication (API keys, OAuth, Bearer tokens).
124 + *
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)
128 + *
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-'
133 + *
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.
139 + *
140 + * @return bool True if request is to MainWP REST API, false otherwise.
93 141 */
94 142 protected function is_request_to_rest_api() {
95 143 if ( empty( $_SERVER['REQUEST_URI'] ) ) {
96 144 return false;
@@ -98,14 +146,25 @@
98 146
99 147 $rest_prefix = trailingslashit( rest_get_url_prefix() );
100 148 $request_uri = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
101 149
102 - // Check if the request is the API endpoints.
103 - $mainwp_api = ( false !== strpos( $request_uri, $rest_prefix . 'mainwp/' ) );
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 ) );
104 154
105 155 // Allow third party plugins use our authentication methods.
106 - $extension_api = ( false !== strpos( $request_uri, $rest_prefix . 'mainwp-' ) );
156 + $extension_api = ( false !== strpos( $request_uri, '/' . $rest_prefix . self::MAINWP_EXTENSION_REST_NAMESPACE ) );
107 157
158 + /**
159 + * Filter whether a request is considered to be to the MainWP REST API.
160 + *
161 + * IMPORTANT: Do NOT add Abilities API endpoints (wp-abilities/v1/abilities/mainwp/...)
162 + * to this filter. Those endpoints use native WordPress authentication and should
163 + * NOT be processed by MainWP's REST authentication system.
164 + *
165 + * @param bool $is_mainwp_api True if this is a MainWP REST API request.
166 + */
108 167 return apply_filters( 'mainwp_rest_is_request_to_rest_api', $mainwp_api || $extension_api );
109 168 }
110 169
111 170 /**
@@ -119,9 +178,9 @@
119 178 if ( ! empty( $user_id ) || ! $this->is_request_to_rest_api() ) {
120 179 return $user_id;
121 180 }
122 181
123 - if ( is_ssl() ) {
182 + if ( $this->is_ssl_request() ) {
124 183 $user_id = $this->perform_basic_authentication();
125 184 }
126 185
127 186 if ( $user_id ) {
@@ -132,14 +191,52 @@
132 191 if ( $user_id ) {
133 192 return $user_id;
134 193 }
135 194
136 - if ( is_ssl() ) {
195 + if ( ! $this->is_ssl_request() ) {
196 + $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 ) ) );
197 + return false;
198 + } else {
137 199 return $this->perform_basic_token_authentication();
138 200 }
139 201 }
140 202
141 203 /**
204 + * Method is_ssl_request().
205 + *
206 + * Check is https request.
207 + *
208 + * @return bool
209 + */
210 + private function is_ssl_request() {
211 + if ( is_ssl() || wp_is_using_https() || $this->is_local_request() ) {
212 + return true;
213 + }
214 + return false;
215 + }
216 +
217 + /**
218 + * Method is_local_request().
219 + *
220 + * @return bool
221 + */
222 + private function is_local_request() {
223 +
224 + if ( ! isset( $_SERVER['REMOTE_ADDR'] ) ) {
225 + return false;
226 + }
227 +
228 + $ip = $_SERVER['REMOTE_ADDR']; //phpcs:ignore -- ok.
229 +
230 + // Always allow localhost.
231 + if ( '127.0.0.1' === $ip || '::1' === $ip ) {
232 + return true;
233 + }
234 + return false;
235 + }
236 +
237 +
238 + /**
142 239 * Authenticate the user if authentication wasn't performed during the
143 240 * determine_current_user action.
144 241 *
145 242 * Necessary in cases where wp_get_current_user() is called before the plugin is loaded.
@@ -176,8 +273,15 @@
176 273 if ( ! empty( $error ) ) {
177 274 return $error;
178 275 }
179 276
277 + // Only return MainWP auth errors for MainWP API endpoints.
278 + // Other REST endpoints (e.g., wp-abilities, core WordPress) should use
279 + // their own authentication mechanisms without MainWP interference.
280 + if ( ! $this->is_request_to_rest_api() ) {
281 + return $error;
282 + }
283 +
180 284 return $this->get_error();
181 285 }
182 286
183 287 /**
@@ -201,8 +305,47 @@
201 305 return $this->error;
202 306 }
203 307
204 308 /**
309 + * Detect whether a stored consumer_secret value is still in legacy plaintext format.
310 + *
311 + * Legacy v2 keys were stored as `cs_` followed by hex digits (production uses
312 + * 40 hex chars; some older fixtures use 32). New keys created after MWP-1540
313 + * are hashed via wp_hash_password and always begin with `$` (`$P$`, `$wp$`,
314 + * `$argon2id$`, etc.). The presence of the `cs_` + hex prefix is sufficient
315 + * to distinguish a plaintext token from any password-hash output.
316 + *
317 + * @todo MWP-1540 sunset (~v6.3): once the migration has shipped for a couple
318 + * of minor versions and release notes have given customers time to
319 + * rotate their keys, this helper and the legacy branch inside
320 + * verify_consumer_secret() can both be removed.
321 + *
322 + * @param string $value Stored consumer_secret value.
323 + * @return bool True when the value is legacy plaintext.
324 + */
325 + private function is_legacy_plaintext_secret( $value ) {
326 + return is_string( $value ) && (bool) preg_match( '/^cs_[a-f0-9]+$/', $value );
327 + }
328 +
329 + /**
330 + * Verify a submitted consumer_secret against the stored value.
331 + *
332 + * Handles both legacy plaintext (constant-time compare via hash_equals) and
333 + * hashed (wp_check_password) formats. Does NOT auto-rehash legacy rows;
334 + * existing plaintext keys stay plaintext until the admin reissues them.
335 + *
336 + * @param string $stored Stored consumer_secret from the database.
337 + * @param string $submitted Consumer_secret submitted in the request.
338 + * @return bool True if the submitted secret matches the stored value.
339 + */
340 + private function verify_consumer_secret( $stored, $submitted ) {
341 + if ( $this->is_legacy_plaintext_secret( $stored ) ) {
342 + return hash_equals( $stored, $submitted ); // @codingStandardsIgnoreLine
343 + }
344 + return wp_check_password( $submitted, $stored );
345 + }
346 +
347 + /**
205 348 * Basic Authentication.
206 349 *
207 350 * SSL-encrypted requests are not subject to sniffing or man-in-the-middle
208 351 * attacks, so the request can be authenticated by simply looking up the user
@@ -241,10 +384,10 @@
241 384 if ( empty( $this->user ) ) {
242 385 return false;
243 386 }
244 387
245 - // Validate user secret.
246 - if ( ! hash_equals( $this->user->consumer_secret, $consumer_secret ) ) { // @codingStandardsIgnoreLine
388 + // Validate user secret. Handles both legacy plaintext rows and hashed rows.
389 + if ( ! $this->verify_consumer_secret( $this->user->consumer_secret, $consumer_secret ) ) {
247 390 $this->set_error( new WP_Error( 'mainwp_rest_authentication_error', __( 'Consumer secret is invalid.', 'mainwp' ), array( 'status' => 401 ) ) );
248 391
249 392 return false;
250 393 }
@@ -381,14 +524,9 @@
381 524
382 525 // If we have at least one supplied piece of data, and we have an error,
383 526 // then it's a failed authentication.
384 527 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 -
528 + $message = __( 'Invalid OAuth parameter.', 'mainwp' );
391 529 $this->set_error( new WP_Error( 'mainwp_rest_authentication_missing_parameter', $message, array( 'status' => 401 ) ) );
392 530
393 531 return array();
394 532 }
@@ -484,10 +622,10 @@
484 622 $this->user = $this->get_user_data_by_consumer_key( $consumer_key );
485 623 if ( empty( $this->user ) ) {
486 624 return false;
487 625 }
488 - // Validate user secret.
489 - if ( ! hash_equals( $this->user->consumer_secret, $consumer_secret ) ) { // @codingStandardsIgnoreLine
626 + // Validate user secret. Handles both legacy plaintext rows and hashed rows.
627 + if ( ! $this->verify_consumer_secret( $this->user->consumer_secret, $consumer_secret ) ) {
490 628 $this->set_error( new WP_Error( 'mainwp_rest_authentication_error', __( 'Consumer secret is invalid.', 'mainwp' ), array( 'status' => 401 ) ) );
491 629
492 630 return false;
493 631 }
@@ -543,16 +681,10 @@
543 681
544 682 // If we have at least one supplied piece of data, and we have an error,
545 683 // then it's a failed authentication.
546 684 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 -
685 + $message = __( 'Invalid OAuth parameter.', 'mainwp' );
553 686 $this->set_error( new WP_Error( 'mainwp_rest_authentication_missing_parameter', $message, array( 'status' => 401 ) ) );
554 -
555 687 return array();
556 688 }
557 689
558 690 return $params;
@@ -572,9 +704,9 @@
572 704 $wp_base = get_home_url( null, '/', 'relative' );
573 705 if ( substr( $request_path, 0, strlen( $wp_base ) ) === $wp_base ) {
574 706 $request_path = substr( $request_path, strlen( $wp_base ) );
575 707 }
576 - $base_request_uri = rawurlencode( get_home_url( null, $request_path, is_ssl() ? 'https' : 'http' ) );
708 + $base_request_uri = rawurlencode( get_home_url( null, $request_path, $this->is_ssl_request() ? 'https' : 'http' ) );
577 709
578 710 // Get the signature provided by the consumer and remove it from the parameters prior to checking the signature.
579 711 $consumer_signature = rawurldecode( str_replace( ' ', '+', $params['oauth_signature'] ) );
580 712 unset( $params['oauth_signature'] );
@@ -592,8 +724,21 @@
592 724 if ( 'HMAC-SHA1' !== $params['oauth_signature_method'] && 'HMAC-SHA256' !== $params['oauth_signature_method'] ) {
593 725 return new WP_Error( 'mainwp_rest_authentication_error', __( 'Invalid signature - signature method is invalid.', 'mainwp' ), array( 'status' => 401 ) );
594 726 }
595 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.
737 + 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 ) );
739 + }
740 +
596 741 $hash_algorithm = strtolower( str_replace( 'HMAC-', '', $params['oauth_signature_method'] ) );
597 742 $secret = $user->consumer_secret . '&';
598 743 $signature = base64_encode( hash_hmac( $hash_algorithm, $string_to_sign, $secret, true ) ); //phpcs:ignore -- ok.
599 744
@@ -701,8 +846,10 @@
701 846 array( '%s' ),
702 847 array( '%d' )
703 848 );
704 849
850 + wp_cache_delete( 'mainwp_api_key_' . md5( $user->consumer_key ) ); // NOSONAR - MD5 used for cache key generation only, not cryptographic (security) purposes.
851 +
705 852 return true;
706 853 }
707 854
708 855 /**
@@ -708,30 +855,65 @@
708 855 /**
709 856 * Return the user data for the given consumer_key.
710 857 *
711 858 * @param string $consumer_key Consumer key.
712 - * @return array
859 + * @return mixed
713 860 */
714 861 private function get_user_data_by_consumer_key( $consumer_key ) {
715 862 global $wpdb;
716 863
717 864 $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 - );
865 + $cache_key = 'mainwp_api_key_' . md5( $consumer_key ); // NOSONAR - MD5 used for cache key generation only, not cryptographic (security) purposes.
866 + $user = wp_cache_get( $cache_key );
728 867
868 + if ( false === $user ) {
869 + $table_name = esc_sql( MainWP_DB::instance()->get_table_name( 'api_keys' ) );
870 + $user = $wpdb->get_row(
871 + $wpdb->prepare(
872 + 'SELECT * FROM ' . $table_name . ' WHERE consumer_key = %s',
873 + $consumer_key
874 + )
875 + );
876 + if ( $user ) {
877 + wp_cache_set( $cache_key, $user );
878 + }
879 + }
880 +
729 881 if ( empty( $user->enabled ) ) {
730 882 $this->set_error( new \WP_Error( 'mainwp_rest_authentication_disabled_key', __( 'The REST API Key are disabled.', 'mainwp' ), array( 'status' => 401 ) ) );
731 883 return false;
732 884 }
733 885
886 + // Legacy: passphrase enforcement for any rows where key_type=1.
887 + // No current code path creates such rows (see MWP-1544) and no caller
888 + // of insert_rest_api_key sets key_type to 1, so this branch is dormant
889 + // on every install we have visibility into. It is preserved to avoid
890 + // silently weakening hypothetical pre-existing rows where someone
891 + // wired key_type=1 by hand or via removed migration code. The compare
892 + // is timing-safe (was a vanilla !== before MWP-1540) and we leave the
893 + // submitted value unsanitized so that a passphrase containing
894 + // whitespace or HTML-like content still matches a legitimately
895 + // stored value.
896 + //
897 + // TODO MWP-1544 sunset (~v6.3): remove this entire block. Nothing in
898 + // the dashboard creates key_type=1 rows anymore, and the column
899 + // itself can be dropped at the same time.
900 + if ( 1 === (int) $user->key_type ) {
901 + // The submitted value is only fed into hash_equals against the
902 + // stored passphrase; it is never echoed, stored, or used in a
903 + // query, so sanitization is intentionally skipped to preserve
904 + // an exact match against legacy plaintext values that may
905 + // contain whitespace or HTML-like characters.
906 + // phpcs:disable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
907 + $pass = ! empty( $_REQUEST['key_pass'] ) ? wp_unslash( $_REQUEST['key_pass'] ) : '';
908 + // phpcs:enable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
909 + $expected = isset( $user->key_pass ) ? (string) $user->key_pass : '';
910 + if ( '' === $expected || ! hash_equals( $expected, $pass ) ) {
911 + $this->set_error( new \WP_Error( 'mainwp_rest_authentication_invalid_key_pass', __( 'The REST API passphrase is invalid.', 'mainwp' ), array( 'status' => 401 ) ) );
912 + return false;
913 + }
914 + }
915 +
734 916 return $user;
735 917 }
736 918
737 919
@@ -742,32 +924,42 @@
742 924 * @return bool|WP_Error
743 925 */
744 926 private function check_permissions( $method ) {
745 927 $permissions = $this->user->permissions;
746 -
928 + $msg = '';
929 + $flag = true;
747 930 switch ( $method ) {
748 931 case 'HEAD':
749 932 case 'GET':
750 933 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 ) );
934 + $msg = __( 'The API key provided does not have read permissions.', 'mainwp' );
752 935 }
753 936 break;
754 937 case 'POST':
755 938 case 'PUT':
756 939 case 'PATCH':
940 + if ( 'write' !== $permissions && 'read_write' !== $permissions ) {
941 + $msg = __( 'The API key provided does not have write permissions.', 'mainwp' );
942 + }
943 + break;
757 944 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 ) );
945 + if ( 'write' !== $permissions && 'read_write' !== $permissions && 'delete' !== $permissions ) {
946 + $msg = __( 'The API key provided does not have delete permissions.', 'mainwp' );
760 947 }
761 948 break;
762 949 case 'OPTIONS':
763 - return true;
950 + $flag = true;
951 + break;
952 + default:
953 + $msg = __( 'Unknown request method.', 'mainwp' );
954 + }
764 955
765 - default:
766 - return new WP_Error( 'mainwp_rest_authentication_error', __( 'Unknown request method.', 'mainwp' ), array( 'status' => 401 ) );
956 + if ( ! empty( $msg ) ) {
957 + return new WP_Error( 'mainwp_rest_authentication_error', $msg, array( 'status' => 401 ) );
958 +
767 959 }
768 960
769 - return true;
961 + return $flag;
770 962 }
771 963
772 964 /**
773 965 * Updated API Key last access datetime.
@@ -794,8 +986,10 @@
794 986 array( 'key_id' => $this->user->key_id ),
795 987 array( '%s' ),
796 988 array( '%d' )
797 989 );
990 +
991 + wp_cache_delete( 'mainwp_api_key_' . md5( $this->user->consumer_key ) ); // NOSONAR - MD5 used for cache key generation only, not cryptographic (security) purposes.
798 992 }
799 993
800 994 /**
801 995 * If the consumer_key and consumer_secret $_GET parameters are NOT provided
@@ -842,15 +1036,32 @@
842 1036 return $result;
843 1037 }
844 1038
845 1039 /**
1040 + * Method get_rest_valid_user().
1041 + *
1042 + * @return mixed|object User api key object.
1043 + */
1044 + public function get_rest_valid_user() {
1045 + return $this->user;
1046 + }
1047 +
1048 + /**
846 1049 * Valid REST permissions.
847 1050 *
848 1051 * @param WP_REST_Request $request Request used to generate the response.
849 1052 *
850 - * @return objext user rest data.
1053 + * @return bool|\WP_Error True if valid, WP_Error otherwise.
851 1054 */
852 1055 public function is_valid_permissions( $request ) {
1056 + // If no user is authenticated, deny permission.
1057 + if ( null === $this->user ) {
1058 + return new WP_Error(
1059 + 'mainwp_rest_authentication_error',
1060 + __( 'You are not authenticated.', 'mainwp' ),
1061 + array( 'status' => 401 )
1062 + );
1063 + }
853 1064 return $this->check_permissions( $request->get_method() );
854 1065 }
855 1066 }
856 1067