diff --git a/src/wp-includes/rest-api/fields/class-wp-rest-meta-fields.php b/src/wp-includes/rest-api/fields/class-wp-rest-meta-fields.php index 71be09ec423b6..97921f493dd18 100644 --- a/src/wp-includes/rest-api/fields/class-wp-rest-meta-fields.php +++ b/src/wp-includes/rest-api/fields/class-wp-rest-meta-fields.php @@ -223,6 +223,7 @@ public function update_value( $meta, $object_id ) { * Deletes a meta value for an object. * * @since 4.7.0 + * @since 7.3.0 Authorization is no longer checked when there is no stored value to delete. * * @param int $object_id Object ID the field belongs to. * @param string $meta_key Key for the field. @@ -234,6 +235,10 @@ public function update_value( $meta, $object_id ) { protected function delete_meta_value( $object_id, $meta_key, $name ) { $meta_type = $this->get_meta_type(); + if ( null === get_metadata_raw( $meta_type, $object_id, wp_slash( $meta_key ) ) ) { + return true; + } + if ( ! current_user_can( "delete_{$meta_type}_meta", $object_id, $meta_key ) ) { return new WP_Error( 'rest_cannot_delete', @@ -246,10 +251,6 @@ protected function delete_meta_value( $object_id, $meta_key, $name ) { ); } - if ( null === get_metadata_raw( $meta_type, $object_id, wp_slash( $meta_key ) ) ) { - return true; - } - if ( ! delete_metadata( $meta_type, $object_id, wp_slash( $meta_key ) ) ) { return new WP_Error( 'rest_meta_database_error', @@ -271,6 +272,7 @@ protected function delete_meta_value( $object_id, $meta_key, $name ) { * * @since 4.7.0 * @since 6.7.0 Stores values into DB even if provided registered default value. + * @since 7.3.0 Authorization is only checked when the values are changed. * * @param int $object_id Object ID to update. * @param string $meta_key Key for the custom field. @@ -283,18 +285,6 @@ protected function delete_meta_value( $object_id, $meta_key, $name ) { protected function update_multi_meta_value( $object_id, $meta_key, $name, $values ) { $meta_type = $this->get_meta_type(); - if ( ! current_user_can( "edit_{$meta_type}_meta", $object_id, $meta_key ) ) { - return new WP_Error( - 'rest_cannot_update', - /* translators: %s: Custom field key. */ - sprintf( __( 'Sorry, you are not allowed to edit the %s custom field.' ), $name ), - array( - 'key' => $name, - 'status' => rest_authorization_required_code(), - ) - ); - } - $current_values = get_metadata_raw( $meta_type, $object_id, $meta_key, false ); $subtype = get_object_subtype( $meta_type, $object_id ); @@ -337,6 +327,27 @@ function ( $stored_value ) use ( $meta_key, $subtype, $value ) { */ $to_remove = array_map( 'maybe_unserialize', array_unique( array_map( 'maybe_serialize', $to_remove ) ) ); + if ( empty( $to_add ) && empty( $to_remove ) ) { + return true; + } + + if ( ! current_user_can( "edit_{$meta_type}_meta", $object_id, $meta_key ) ) { + // Values that match the default for a key with no stored values are unchanged. + if ( empty( $current_values ) && $this->is_meta_value_same_as_default_value( $object_id, $meta_key, $values ) ) { + return true; + } + + return new WP_Error( + 'rest_cannot_update', + /* translators: %s: Custom field key. */ + sprintf( __( 'Sorry, you are not allowed to edit the %s custom field.' ), $name ), + array( + 'key' => $name, + 'status' => rest_authorization_required_code(), + ) + ); + } + foreach ( $to_remove as $value ) { if ( ! delete_metadata( $meta_type, $object_id, wp_slash( $meta_key ), wp_slash( $value ) ) ) { return new WP_Error( @@ -373,6 +384,7 @@ function ( $stored_value ) use ( $meta_key, $subtype, $value ) { * * @since 4.7.0 * @since 6.7.0 Stores values into DB even if provided registered default value. + * @since 7.3.0 Authorization is not checked when the default value is provided for a key with no stored value. * * @param int $object_id Object ID to update. * @param string $meta_key Key for the custom field. @@ -394,6 +406,14 @@ protected function update_meta_value( $object_id, $meta_key, $name, $value ) { } if ( ! current_user_can( "edit_{$meta_type}_meta", $object_id, $meta_key ) ) { + /* + * Clients such as the block editor send back the default value of a key with + * no stored value. That is not a change, so it does not need authorization. + */ + if ( empty( $old_value ) && $this->is_meta_value_same_as_default_value( $object_id, $meta_key, $value ) ) { + return true; + } + return new WP_Error( 'rest_cannot_update', /* translators: %s: Custom field key. */ @@ -443,6 +463,31 @@ protected function is_meta_value_same_as_stored_value( $meta_key, $subtype, $sto return $sanitized === $stored_value; } + /** + * Checks if the user provided value is equivalent to the value returned for a meta key with no stored value. + * + * This mirrors WP_REST_Meta_Fields::get_value(), which returns the default value of a single + * field, and the registered default value wrapped in an array for a multi field. + * + * @since 7.3.0 + * + * @param int $object_id Object ID the field belongs to. + * @param string $meta_key The meta key being checked. + * @param mixed $user_value The value provided by the user, sanitized against the field schema. + * @return bool Whether the value is the same as the default value. + */ + protected function is_meta_value_same_as_default_value( $object_id, $meta_key, $user_value ) { + $args = $this->get_registered_fields()[ $meta_key ]; + + if ( $args['single'] ) { + $default = $args['schema']['default']; + } else { + $default = get_metadata_default( $this->get_meta_type(), $object_id, $meta_key, false ); + } + + return rest_sanitize_value_from_schema( $default, $args['schema'] ) === $user_value; + } + /** * Retrieves all the registered meta fields. * diff --git a/tests/phpunit/tests/rest-api/rest-post-meta-fields.php b/tests/phpunit/tests/rest-api/rest-post-meta-fields.php index 1f39c557e253a..77a8fdfad1ba9 100644 --- a/tests/phpunit/tests/rest-api/rest-post-meta-fields.php +++ b/tests/phpunit/tests/rest-api/rest-post-meta-fields.php @@ -2349,6 +2349,289 @@ public function test_update_meta_with_unchanged_values_and_custom_authentication $this->assertFalse( $data['meta']['authenticated'] ); } + /** + * Tests that sending back the meta values from a response only requires + * authorization for the fields that were changed. + * + * @ticket 48426 + */ + public function test_update_meta_with_values_from_response_and_custom_authentication() { + register_post_meta( + 'post', + 'protected_boolean', + array( + 'single' => true, + 'type' => 'boolean', + 'show_in_rest' => true, + 'auth_callback' => '__return_false', + ) + ); + + $this->grant_write_permission(); + + $request = new WP_REST_Request( 'GET', sprintf( '/wp/v2/posts/%d', self::$post_id ) ); + $response = rest_get_server()->dispatch( $request ); + $meta = $response->get_data()['meta']; + + $meta['test_single'] = 'changed'; + + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', self::$post_id ) ); + $request->set_body_params( array( 'meta' => $meta ) ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Unchanged meta values should not require authorization.' ); + $this->assertSame( 'changed', get_post_meta( self::$post_id, 'test_single', true ), 'The changed meta value should be saved.' ); + $this->assertNull( get_metadata_raw( 'post', self::$post_id, 'protected_boolean' ), 'The unchanged boolean meta value should not be saved.' ); + $this->assertNull( get_metadata_raw( 'post', self::$post_id, 'test_bad_auth' ), 'The unchanged string meta value should not be saved.' ); + $this->assertNull( get_metadata_raw( 'post', self::$post_id, 'test_bad_auth_multi' ), 'The unchanged multi meta value should not be saved.' ); + } + + /** + * Tests that sending the default value of a meta key that has no stored value + * does not require authorization. + * + * @ticket 48426 + * + * @dataProvider data_scalar_default_values + * + * @param string $type Scalar type of default value: one of `boolean`, `integer`, `number`, or `string`. + * @param mixed $default_value Appropriate default value for given type. + * @param mixed $alternative_value Ignored in this test. + */ + public function test_update_meta_with_unchanged_default_value_and_custom_authentication( $type, $default_value, $alternative_value ) { + $meta_key = "protected_{$type}_default"; + + register_post_meta( + 'post', + $meta_key, + array( + 'single' => true, + 'type' => $type, + 'default' => $default_value, + 'show_in_rest' => true, + 'auth_callback' => '__return_false', + ) + ); + + $this->grant_write_permission(); + + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', self::$post_id ) ); + $request->set_body_params( + array( + 'meta' => array( + $meta_key => $default_value, + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Sending the default value should not require authorization.' ); + $this->assertNull( get_metadata_raw( 'post', self::$post_id, $meta_key ), 'The default value should not be saved without authorization.' ); + $this->assertSame( $default_value, $response->get_data()['meta'][ $meta_key ], 'The response should contain the default value.' ); + } + + /** + * Tests that changing a meta key that has no stored value away from its default + * value still requires authorization. + * + * @ticket 48426 + * + * @dataProvider data_scalar_default_values + * + * @param string $type Scalar type of default value: one of `boolean`, `integer`, `number`, or `string`. + * @param mixed $default_value Appropriate default value for given type. + * @param mixed $alternative_value Appropriate value for given type that doesn't match the default value. + */ + public function test_update_meta_with_changed_default_value_and_custom_authentication( $type, $default_value, $alternative_value ) { + $meta_key = "protected_{$type}_default"; + + register_post_meta( + 'post', + $meta_key, + array( + 'single' => true, + 'type' => $type, + 'default' => $default_value, + 'show_in_rest' => true, + 'auth_callback' => '__return_false', + ) + ); + + $this->grant_write_permission(); + + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', self::$post_id ) ); + $request->set_body_params( + array( + 'meta' => array( + $meta_key => $alternative_value, + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_update', $response, 403 ); + $this->assertNull( get_metadata_raw( 'post', self::$post_id, $meta_key ), 'No value should be saved without authorization.' ); + } + + /** + * Tests that sending the default value of a multi meta key that has no stored + * values does not require authorization. + * + * @ticket 48426 + * + * @dataProvider data_scalar_default_values + * + * @param string $type Scalar type of default value: one of `boolean`, `integer`, `number`, or `string`. + * @param mixed $default_value Appropriate default value for given type. + * @param mixed $alternative_value Ignored in this test. + */ + public function test_update_multi_meta_with_unchanged_default_value_and_custom_authentication( $type, $default_value, $alternative_value ) { + $meta_key = "protected_multi_{$type}_default"; + + register_post_meta( + 'post', + $meta_key, + array( + 'single' => false, + 'type' => $type, + 'default' => $default_value, + 'show_in_rest' => true, + 'auth_callback' => '__return_false', + ) + ); + + $this->grant_write_permission(); + + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', self::$post_id ) ); + $request->set_body_params( + array( + 'meta' => array( + $meta_key => array( $default_value ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Sending the default value should not require authorization.' ); + $this->assertNull( get_metadata_raw( 'post', self::$post_id, $meta_key ), 'The default value should not be saved without authorization.' ); + $this->assertSame( array( $default_value ), $response->get_data()['meta'][ $meta_key ], 'The response should contain the default value.' ); + } + + /** + * Tests that changing a multi meta key that has no stored values away from its + * default value still requires authorization. + * + * @ticket 48426 + * + * @dataProvider data_scalar_default_values + * + * @param string $type Scalar type of default value: one of `boolean`, `integer`, `number`, or `string`. + * @param mixed $default_value Appropriate default value for given type. + * @param mixed $alternative_value Appropriate value for given type that doesn't match the default value. + */ + public function test_update_multi_meta_with_changed_default_value_and_custom_authentication( $type, $default_value, $alternative_value ) { + $meta_key = "protected_multi_{$type}_default"; + + register_post_meta( + 'post', + $meta_key, + array( + 'single' => false, + 'type' => $type, + 'default' => $default_value, + 'show_in_rest' => true, + 'auth_callback' => '__return_false', + ) + ); + + $this->grant_write_permission(); + + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', self::$post_id ) ); + $request->set_body_params( + array( + 'meta' => array( + $meta_key => array( $default_value, $alternative_value ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_cannot_update', $response, 403 ); + $this->assertNull( get_metadata_raw( 'post', self::$post_id, $meta_key ), 'No values should be saved without authorization.' ); + } + + /** + * Tests that sending the stored values of a multi meta key unchanged does not + * require authorization. + * + * @ticket 48426 + */ + public function test_update_multi_meta_with_unchanged_values_and_custom_authentication() { + add_post_meta( self::$post_id, 'test_bad_auth_multi', 'value1' ); + add_post_meta( self::$post_id, 'test_bad_auth_multi', 'value2' ); + + $this->grant_write_permission(); + + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', self::$post_id ) ); + $request->set_body_params( + array( + 'meta' => array( + 'test_bad_auth_multi' => array( 'value1', 'value2' ), + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status(), 'Sending unchanged values should not require authorization.' ); + $this->assertSame( array( 'value1', 'value2' ), get_post_meta( self::$post_id, 'test_bad_auth_multi', false ), 'The stored values should not change.' ); + } + + /** + * Tests that deleting a meta key that has no stored value does not require + * authorization. + * + * @ticket 48426 + * + * @dataProvider data_delete_meta_with_no_stored_value_and_custom_authentication + * + * @param string $meta_key Meta key to delete. + * @param array|null $delete_value Value used to delete the meta key. + */ + public function test_delete_meta_with_no_stored_value_and_custom_authentication( $meta_key, $delete_value ) { + $this->grant_write_permission(); + + $request = new WP_REST_Request( 'POST', sprintf( '/wp/v2/posts/%d', self::$post_id ) ); + $request->set_body_params( + array( + 'meta' => array( + $meta_key => $delete_value, + ), + ) + ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * Data provider for test_delete_meta_with_no_stored_value_and_custom_authentication(). + * + * @return array[] Array of test parameters. + */ + public function data_delete_meta_with_no_stored_value_and_custom_authentication() { + return array( + 'single with null' => array( 'test_bad_auth', null ), + 'multi with null' => array( 'test_bad_auth_multi', null ), + 'multi with empty list' => array( 'test_bad_auth_multi', array() ), + ); + } + /** * @ticket 43392 */