← All changes
|
includes/rest-api/class-mainwp-rest-authentication.php
+87
-11
6.0.12
→
6.2.2
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 | |