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 +87 -11 6.0.11 → 6.2.1 View file →
@@ -305,8 +305,47 @@
305 305 return $this->error;
306 306 }
307 307
308 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 + /**
309 348 * Basic Authentication.
310 349 *
311 350 * SSL-encrypted requests are not subject to sniffing or man-in-the-middle
312 351 * attacks, so the request can be authenticated by simply looking up the user
@@ -345,10 +384,10 @@
345 384 if ( empty( $this->user ) ) {
346 385 return false;
347 386 }
348 387
349 - // Validate user secret.
350 - 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 ) ) {
351 390 $this->set_error( new WP_Error( 'mainwp_rest_authentication_error', __( 'Consumer secret is invalid.', 'mainwp' ), array( 'status' => 401 ) ) );
352 391
353 392 return false;
354 393 }
@@ -583,10 +622,10 @@
583 622 $this->user = $this->get_user_data_by_consumer_key( $consumer_key );
584 623 if ( empty( $this->user ) ) {
585 624 return false;
586 625 }
587 - // Validate user secret.
588 - 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 ) ) {
589 628 $this->set_error( new WP_Error( 'mainwp_rest_authentication_error', __( 'Consumer secret is invalid.', 'mainwp' ), array( 'status' => 401 ) ) );
590 629
591 630 return false;
592 631 }
@@ -685,8 +724,21 @@
685 724 if ( 'HMAC-SHA1' !== $params['oauth_signature_method'] && 'HMAC-SHA256' !== $params['oauth_signature_method'] ) {
686 725 return new WP_Error( 'mainwp_rest_authentication_error', __( 'Invalid signature - signature method is invalid.', 'mainwp' ), array( 'status' => 401 ) );
687 726 }
688 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 +
689 741 $hash_algorithm = strtolower( str_replace( 'HMAC-', '', $params['oauth_signature_method'] ) );
690 742 $secret = $user->consumer_secret . '&';
691 743 $signature = base64_encode( hash_hmac( $hash_algorithm, $string_to_sign, $secret, true ) ); //phpcs:ignore -- ok.
692 744
@@ -814,9 +866,9 @@
814 866 $user = wp_cache_get( $cache_key );
815 867
816 868 if ( false === $user ) {
817 869 $table_name = esc_sql( MainWP_DB::instance()->get_table_name( 'api_keys' ) );
818 - $user = $wpdb->get_row(
870 + $user = $wpdb->get_row(
819 871 $wpdb->prepare(
820 872 'SELECT * FROM ' . $table_name . ' WHERE consumer_key = %s',
821 873 $consumer_key
822 874 )
@@ -829,15 +881,39 @@
829 881 if ( empty( $user->enabled ) ) {
830 882 $this->set_error( new \WP_Error( 'mainwp_rest_authentication_disabled_key', __( 'The REST API Key are disabled.', 'mainwp' ), array( 'status' => 401 ) ) );
831 883 return false;
832 884 }
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 ) ) );
838 - return false;
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 + }
839 914 }
915 +
840 916 return $user;
841 917 }
842 918
843 919