From 481eac6776dab60bcc49cde27a3ce8d4e369251f Mon Sep 17 00:00:00 2001 From: devinwalker Date: Fri, 16 Oct 2026 00:13:22 -0700 Subject: [PATCH 1/2] REST API, Meta: Only check meta authorization when a value changes. When a post is saved, the block editor sends back every registered meta field it received, including fields the current user is not allowed to edit. [56075] stopped checking authorization for single values that match the stored value, but a save still failed with a 403 when the request included: * The default value of a field with no stored value. Since [58831] compares against the raw stored value, this also fails when a default is registered. * The unchanged values of a multi field, because authorization was checked before the values were compared. * An empty list or `null` for a field with no stored value, which goes through the delete path and its authorization check. Authorization is now only checked when a value would change. Users who can edit a field still store its default value, as introduced in [58831]. Follow-up to [56075], [58831]. See #48426. --- .../fields/class-wp-rest-meta-fields.php | 77 ++++- .../tests/rest-api/rest-post-meta-fields.php | 283 ++++++++++++++++++ 2 files changed, 344 insertions(+), 16 deletions(-) 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..e357b408250a5 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.2.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.2.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.2.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.2.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 */ From 80549bfaeb1d88c6f84b63f96217338d5e6a97d4 Mon Sep 17 00:00:00 2001 From: devinwalker Date: Sun, 25 Oct 2026 16:00:58 -0700 Subject: [PATCH 2/2] Docs: Update the `@since` tags for the meta authorization changes to 7.3.0. --- .../rest-api/fields/class-wp-rest-meta-fields.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 e357b408250a5..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,7 +223,7 @@ public function update_value( $meta, $object_id ) { * Deletes a meta value for an object. * * @since 4.7.0 - * @since 7.2.0 Authorization is no longer checked when there is no stored value to delete. + * @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. @@ -272,7 +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.2.0 Authorization is only checked when the values are changed. + * @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. @@ -384,7 +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.2.0 Authorization is not checked when the default value is provided for a key with no stored 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. @@ -469,7 +469,7 @@ protected function is_meta_value_same_as_stored_value( $meta_key, $subtype, $sto * 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.2.0 + * @since 7.3.0 * * @param int $object_id Object ID the field belongs to. * @param string $meta_key The meta key being checked.