diff --git a/modules/editorial-metadata/editorial-metadata.php b/modules/editorial-metadata/editorial-metadata.php index 6a21ea51..f0b1a4a5 100644 --- a/modules/editorial-metadata/editorial-metadata.php +++ b/modules/editorial-metadata/editorial-metadata.php @@ -105,6 +105,9 @@ public function init() { // Register post meta for REST API support (enables Gutenberg saving). $this->register_metadata_for_rest_api(); + // Hide editorial metadata from REST reads for users who cannot edit the post. + add_action( 'rest_api_init', array( $this, 'register_rest_api_filters' ) ); + // Anything that needs to happen in the admin. add_action( 'admin_init', array( $this, 'action_admin_init' ) ); @@ -403,6 +406,52 @@ private function register_metadata_for_rest_api() { } } + /** + * Register REST response filters that hide editorial metadata from unauthorised readers. + * + * The write-path registration sets show_in_rest so Gutenberg can save the fields, but + * show_in_rest also exposes the values on read to anyone who can read the post, and a + * published post is public. Editorial metadata is internal newsroom data, so a + * rest_prepare_{post_type} filter strips it from responses for readers who cannot edit the + * post. Registration is gated on the module's supported post types, mirroring the write path. + */ + public function register_rest_api_filters() { + $supported_post_types = $this->get_post_types_for_module( $this->module ); + foreach ( $supported_post_types as $post_type ) { + add_filter( "rest_prepare_{$post_type}", array( $this, 'filter_rest_editorial_metadata' ), 10, 2 ); + } + } + + /** + * Remove editorial metadata keys from a REST response for readers who cannot edit the post. + * + * The auth_callback registered with the meta only governs writes; core exposes show_in_rest + * meta on read to anyone who can read the object. The capability check is per-post so the + * collection endpoint, which returns many posts in one response, is covered too. + * + * @param WP_REST_Response $response The response object. + * @param WP_Post $post The post being prepared for the response. + * @return WP_REST_Response The response with editorial metadata stripped where unauthorised. + */ + public function filter_rest_editorial_metadata( $response, $post ) { + if ( current_user_can( 'edit_post', $post->ID ) ) { + return $response; + } + + $data = $response->get_data(); + if ( empty( $data['meta'] ) || ! is_array( $data['meta'] ) ) { + return $response; + } + + foreach ( $this->get_editorial_metadata_terms() as $term ) { + unset( $data['meta'][ $this->get_postmeta_key( $term ) ] ); + } + + $response->set_data( $data ); + + return $response; + } + /***************************************************** * Post meta box generation and processing ****************************************************/ diff --git a/tests/Integration/EditorialMetadataTest.php b/tests/Integration/EditorialMetadataTest.php index e7643590..4d6733f7 100644 --- a/tests/Integration/EditorialMetadataTest.php +++ b/tests/Integration/EditorialMetadataTest.php @@ -196,6 +196,63 @@ function test_rest_meta_registers_a_sanitize_callback() { $this->assertSame( 'sanitize_text_field', $registered[ $number_key ]['sanitize_callback'] ); } + /** + * Editorial metadata must not leak to unauthorised REST readers. + * + * Registering show_in_rest lets Gutenberg save the fields, but it also exposes the values + * on read to anyone who can read the post, and a published post is public. A + * rest_prepare_{post_type} filter strips the fields for readers who cannot edit the post, + * while editors still receive them so the block editor keeps working. + */ + function test_rest_read_hides_editorial_metadata_from_unauthorised_users() { + global $edit_flow; + + $em = $edit_flow->editorial_metadata; + + // Ensure the module reports a post type so REST meta and the read filter apply to it. + if ( ! isset( $em->module->options ) || ! is_object( $em->module->options ) ) { + $em->module->options = new \stdClass(); + } + $em->module->options->post_types = array( 'post' => 'on' ); + + // Register the write-path meta (show_in_rest) and the read-path filter, as they run at init. + $register = new \ReflectionMethod( $em, 'register_metadata_for_rest_api' ); + $register->setAccessible( true ); + $register->invoke( $em ); + $em->register_rest_api_filters(); + + $term = $em->get_editorial_metadata_term_by( 'slug', 'assignment' ); // type: paragraph. + $meta_key = $em->get_postmeta_key( $term ); + $secret = 'Source is Jane Roe, mobile 07700 900123.'; + + $post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_author' => self::$admin_user_id, + 'post_title' => 'Public story', + ) + ); + update_post_meta( $post_id, $meta_key, $secret ); + + // Anonymous read must not expose the field. + wp_set_current_user( 0 ); + $request = new \WP_REST_Request( 'GET', sprintf( '/wp/v2/posts/%d', $post_id ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status(), 'Published post should be readable.' ); + $this->assertArrayNotHasKey( $meta_key, $data['meta'], 'Editorial metadata must not be exposed to anonymous readers.' ); + + // An editor must still receive the field so the block editor keeps working. + wp_set_current_user( self::$admin_user_id ); + $request = new \WP_REST_Request( 'GET', sprintf( '/wp/v2/posts/%d', $post_id ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayHasKey( $meta_key, $data['meta'], 'Editors must still receive editorial metadata over REST.' ); + $this->assertSame( $secret, $data['meta'][ $meta_key ], 'Editors must receive the stored editorial metadata value.' ); + } + /** * Renaming a term to a name already used by ANOTHER term must report a conflict. This guards * the strict term_exists() comparison so it keeps detecting genuine name collisions.