Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions modules/editorial-metadata/editorial-metadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ) );

Expand Down Expand Up @@ -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
****************************************************/
Expand Down
57 changes: 57 additions & 0 deletions tests/Integration/EditorialMetadataTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down