diff --git a/README.md b/README.md index 7fac48e..c68309b 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,9 @@ The REST URL is located at: // e.g. https://my-site.com/wp-json/vip-block-data-api/v1/posts/139/blocks ``` -This public endpoint will return editor block metadata as structured JSON for any published post, page, or published `WP_Post` object. +This public endpoint returns editor block metadata as structured JSON for published, unprotected posts, pages, and other `WP_Post` objects that the post type's registered REST controller permits the current user to read. + +Access is decided by the controller's item permission check. Once a post is permitted, blocks are parsed from the stored `post_content` of that post. The controller's response preparation is not run, so any content a custom controller removes or rewrites in its own REST response is still returned here. Synced patterns referenced from a post are only expanded when the pattern is a published, unprotected `wp_block` post. Review these [**Filters**](#filters) to learn more about limiting access to the REST endpoint: @@ -1186,26 +1188,29 @@ These filters and actions can be applied to limit access to the REST API and mod ### `vip_block_data_api__rest_validate_post_id` -Limit which post IDs are valid in the REST API. By default, posts that are available via the [WordPress `/posts` REST API][wordpress-rest-api-posts] are queryable. +Limit which post IDs are valid in the REST API. By default, posts are queryable when their registered WordPress REST controller permits access. Password-protected content also requires the current user to have permission to edit the post because this endpoint parses raw `post_content`. If the controller throws an exception while being constructed or while checking permissions, the post is treated as not readable. ```php /** * Validates that a post can be queried via the Block Data API REST endpoint. * Return false to disable access to a post. * - * @param boolean $is_valid Whether the post ID is valid for querying. Defaults to true - * when a post is available via the WordPress REST API. + * @param boolean $is_readable Whether the post ID is valid for querying. Defaults to true + * when the post's REST controller permits access and the + * post is not password-protected for the current user. * @param int $post_id The queried post ID. */ -return apply_filters( 'vip_block_data_api__rest_validate_post_id', $is_valid, $post_id ); +return apply_filters( 'vip_block_data_api__rest_validate_post_id', $is_readable, $post_id ); ``` +This filter can further restrict access or intentionally override the default access decision. Returning `true` when `$is_readable` is `false` makes the filter callback responsible for authorizing access to the post, including drafts, password-protected posts, and posts denied by their registered REST controller. Authorization overrides should require authentication and be narrowly scoped to the intended post. + For example, this filter can be used to allow only pages that are published to be available: ```php add_filter( 'vip_block_data_api__rest_validate_post_id', function( $is_valid, $post_id ) { // Only allow published pages - return 'page' === get_post_type( $post_id ) && 'publish' === get_post_status( $post_id ); + return $is_valid && 'page' === get_post_type( $post_id ) && 'publish' === get_post_status( $post_id ); }, 10, 2); ``` @@ -1228,7 +1233,7 @@ return apply_filters( 'vip_block_data_api__rest_permission_callback', true ); **Warning**: Authenticated requests to the Block Data API will bypass WPVIP's built-in REST API caching. Review [**Caching on WPVIP**](#caching-on-wpvip) for more information. -By default no authentication is required, as posts must be published to be available on the Block Data API. If limited access is desired (e.g. [via Application Password credentials][wordpress-application-passwords]) this filter can be used to check user permissions: +By default no authentication is required for public posts that pass their registered REST controller's permission checks. If limited access is desired (e.g. [via Application Password credentials][wordpress-application-passwords]) this filter can be used to check user permissions: ```php add_filter( 'vip_block_data_api__rest_permission_callback', function( $is_permitted ) { diff --git a/src/parser/block-additions/core-block.php b/src/parser/block-additions/core-block.php index 028a803..2413e20 100644 --- a/src/parser/block-additions/core-block.php +++ b/src/parser/block-additions/core-block.php @@ -85,7 +85,14 @@ public static function get_inner_blocks( array $inner_blocks, string|null $block $parser = new ContentParser(); $post = get_post( $parsed_block['attrs']['ref'] ); - if ( ! $post instanceof WP_Post ) { + // Match core's synced pattern rendering restrictions. Only published, + // unprotected wp_block posts may be expanded into public parent content. + if ( + ! $post instanceof WP_Post || + 'wp_block' !== $post->post_type || + 'publish' !== $post->post_status || + ! empty( $post->post_password ) + ) { return []; } diff --git a/src/rest/rest-api.php b/src/rest/rest-api.php index 697f864..1f1ec78 100644 --- a/src/rest/rest-api.php +++ b/src/rest/rest-api.php @@ -7,7 +7,9 @@ namespace WPCOMVIP\BlockDataApi; +use Throwable; use WP_Error; +use WP_REST_Request; defined( 'ABSPATH' ) || die(); @@ -59,18 +61,24 @@ public static function register_rest_routes() { 'id' => [ 'required' => true, 'validate_callback' => function ( $param ) { - $post_id = intval( $param ); - $is_valid = self::is_post_readable( $post_id ); + $post_id = intval( $param ); + $is_readable = self::is_post_readable( $post_id ); /** * Validates that a post can be queried via the Block Data API REST endpoint. * Return false to disable access to a post. * - * @param boolean $is_valid Whether the post ID is valid for querying. Defaults to true - * when a post is available via the WordPress REST API. + * This filter can restrict access or intentionally override the default access + * decision when an integration provides its own authorization logic. + * + * @param boolean $is_readable Whether the post ID is valid for querying. Defaults to true + * when the post's REST controller permits access and the + * post is not password-protected for the current user. * @param int $post_id The queried post ID. */ - return apply_filters( 'vip_block_data_api__rest_validate_post_id', $is_valid, $post_id ); + $is_allowed = apply_filters( 'vip_block_data_api__rest_validate_post_id', $is_readable, $post_id ); + + return $is_allowed; }, 'sanitize_callback' => function ( $param ) { return intval( $param ); @@ -172,17 +180,15 @@ public static function get_block_content( $params ) { * Validates that a post is valid or not, based on: * * - That it exists. - * - Is a post type that is REST-accessible - * - Is readable by the current user. + * - Is a post type that is REST-accessible. + * - Is readable by the current user according to that post type's REST controller. + * - Is not password-protected, unless the current user can edit the post. * * @param int $post_id the post ID to validate. * * @return bool true if it is, false otherwise. */ private static function is_post_readable( $post_id ) { - // Borrow logic from WP_REST_Posts_Controller->check_read_permission() - // to only allow REST-accessible posts by default. - $post = get_post( $post_id ); if ( empty( $post ) || empty( $post->ID ) ) { @@ -194,29 +200,33 @@ private static function is_post_readable( $post_id ) { return false; } - if ( 'publish' === $post->post_status || current_user_can( 'read_post', $post->ID ) ) { - return true; + // The REST posts controller permits the item itself, then redacts password-protected + // content while preparing the response. Since this endpoint parses raw post_content, + // require edit access before returning password-protected content. + if ( ! empty( $post->post_password ) && ! current_user_can( 'edit_post', $post->ID ) ) { + return false; } - $post_status_obj = get_post_status_object( $post->post_status ); - if ( $post_status_obj && $post_status_obj->public ) { - return true; - } + // Use the registered controller rather than copying the base posts controller's + // permission logic. Post types such as wp_block add stricter checks in subclasses. + // Custom controllers can throw during construction or permission checks, so fail + // closed instead of letting the exception reach the REST server. + try { + $rest_controller = $post_type->get_rest_controller(); + if ( empty( $rest_controller ) || ! is_callable( [ $rest_controller, 'get_item_permissions_check' ] ) ) { + return false; + } - // Use parent status if inheriting. - if ( 'inherit' === $post->post_status && $post->post_parent > 0 ) { - return self::is_post_readable( $post->post_parent ); - } + $request = new WP_REST_Request( 'GET' ); + $request->set_param( 'id', $post->ID ); + $request->set_param( 'context', 'view' ); - /* - * If there isn't a parent, but the status is set to inherit, assume - * it's published (as per get_post_status()). - */ - if ( 'inherit' === $post->post_status ) { - return true; - } + return true === $rest_controller->get_item_permissions_check( $request ); + } catch ( Throwable $error ) { + Analytics::record_error( new WP_Error( 'vip-block-data-api-rest-controller-error', sprintf( 'REST controller for post ID %d failed during permission check: %s', $post->ID, $error->getMessage() ) ) ); - return false; + return false; + } } } diff --git a/tests/mocks/constructor-throwing-rest-posts-controller-mock.php b/tests/mocks/constructor-throwing-rest-posts-controller-mock.php new file mode 100644 index 0000000..516e05a --- /dev/null +++ b/tests/mocks/constructor-throwing-rest-posts-controller-mock.php @@ -0,0 +1,26 @@ +assertEquals( $expected_blocks, $blocks['blocks'], sprintf( 'Blocks not equal: %s', wp_json_encode( $blocks['blocks'] ) ) ); } + /** + * @dataProvider inaccessible_synced_pattern_provider + */ + public function test_inaccessible_synced_pattern_content_is_not_returned( $post_type, $post_status, $post_password ) { + $this->register_block_with_attributes( 'test/custom-block', [ + 'content' => [ + 'type' => 'rich-text', + 'source' => 'rich-text', + 'selector' => 'p', + '__experimentalRole' => 'content', + ], + ] ); + + $marker = 'inaccessible-synced-pattern-marker'; + $referenced = $this->factory()->post->create_and_get( [ + 'post_content' => sprintf( '
%s
', $marker ), + 'post_status' => $post_status, + 'post_type' => $post_type, + 'post_password' => $post_password, + ] ); + + $content_parser = new ContentParser( $this->get_block_registry() ); + $blocks = $content_parser->parse( sprintf( '', $referenced->ID ) ); + + $this->assertArrayHasKey( 'blocks', $blocks, sprintf( 'Unexpected parser output: %s', wp_json_encode( $blocks ) ) ); + $this->assertStringNotContainsString( $marker, wp_json_encode( $blocks ) ); + $this->assertArrayNotHasKey( 'innerBlocks', $blocks['blocks'][0] ); + } + + public function inaccessible_synced_pattern_provider() { + return [ + 'wrong post type' => [ 'post', 'publish', '' ], + 'draft synced pattern' => [ 'wp_block', 'draft', '' ], + 'passworded synced pattern' => [ 'wp_block', 'publish', 'synthetic-password' ], + ]; + } + /* Synced pattern with override */ public function test_synced_pattern_with_override() { diff --git a/tests/rest/test-rest-api.php b/tests/rest/test-rest-api.php index bc6b538..a01cea3 100644 --- a/tests/rest/test-rest-api.php +++ b/tests/rest/test-rest-api.php @@ -11,6 +11,10 @@ use WP_REST_Server; use WP_REST_Request; +require_once dirname( __DIR__ ) . '/mocks/rest-posts-controller-mock.php'; +require_once dirname( __DIR__ ) . '/mocks/constructor-throwing-rest-posts-controller-mock.php'; +require_once dirname( __DIR__ ) . '/mocks/permission-throwing-rest-posts-controller-mock.php'; + /** * e2e tests to ensure that the REST API endpoint is available. */ @@ -714,10 +718,11 @@ public function test_rest_api_returns_error_for_unpublished_post() { ], ] ); + $marker = 'unpublished-content-marker'; $post_id = $this->factory()->post->create( [ 'post_title' => 'Unpublished post', 'post_type' => 'post', - 'post_content' => 'Unpublished content
', + 'post_content' => sprintf( '%s
', $marker ), 'post_status' => 'draft', ] ); @@ -730,6 +735,316 @@ public function test_rest_api_returns_error_for_unpublished_post() { $this->assertArrayNotHasKey( 'blocks', $result ); $this->assertArrayHasKey( 'code', $result ); $this->assertEquals( 'rest_invalid_param', $result['code'] ); + $this->assertStringNotContainsString( $marker, wp_json_encode( $result ) ); + + wp_delete_post( $post_id ); + } + + public function test_rest_api_returns_error_for_password_protected_post() { + $this->register_block_with_attributes( 'test/custom-paragraph', [ + 'content' => [ + 'type' => 'rich-text', + 'source' => 'rich-text', + 'selector' => 'p', + '__experimentalRole' => 'content', + ], + ] ); + + $marker = 'password-protected-content-marker'; + $post_id = $this->factory()->post->create( [ + 'post_title' => 'Password protected post', + 'post_type' => 'post', + 'post_content' => sprintf( '%s
', $marker ), + 'post_status' => 'publish', + 'post_password' => 'synthetic-password', + ] ); + + wp_set_current_user( 0 ); + + $request = new WP_REST_Request( 'GET', sprintf( '/vip-block-data-api/v1/posts/%d/blocks', $post_id ) ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 400, $response->get_status() ); + $this->assertSame( 'rest_invalid_param', $response->get_data()['code'] ); + $this->assertStringNotContainsString( $marker, wp_json_encode( $response->get_data() ) ); + + $request->set_query_params( [ 'password' => 'synthetic-password' ] ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 400, $response->get_status() ); + $this->assertStringNotContainsString( $marker, wp_json_encode( $response->get_data() ) ); + + wp_delete_post( $post_id ); + } + + public function test_rest_api_returns_password_protected_post_when_user_can_edit() { + $this->register_block_with_attributes( 'test/custom-paragraph', [ + 'content' => [ + 'type' => 'rich-text', + 'source' => 'rich-text', + 'selector' => 'p', + '__experimentalRole' => 'content', + ], + ] ); + + $marker = 'authorized-password-protected-content-marker'; + $post_id = $this->factory()->post->create( [ + 'post_title' => 'Password protected post', + 'post_type' => 'post', + 'post_content' => sprintf( '%s
', $marker ), + 'post_status' => 'publish', + 'post_password' => 'synthetic-password', + ] ); + $user_id = $this->factory()->user->create( [ 'role' => 'editor' ] ); + + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'GET', sprintf( '/vip-block-data-api/v1/posts/%d/blocks', $post_id ) ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertStringContainsString( $marker, wp_json_encode( $response->get_data() ) ); + + wp_set_current_user( 0 ); + wp_delete_post( $post_id ); + } + + public function test_rest_api_returns_error_for_anonymous_synced_pattern() { + $this->register_block_with_attributes( 'test/custom-paragraph', [ + 'content' => [ + 'type' => 'rich-text', + 'source' => 'rich-text', + 'selector' => 'p', + '__experimentalRole' => 'content', + ], + ] ); + + $marker = 'standalone-synced-pattern-marker'; + $post_id = $this->factory()->post->create( [ + 'post_title' => 'Standalone synced pattern', + 'post_type' => 'wp_block', + 'post_content' => sprintf( '%s
', $marker ), + 'post_status' => 'publish', + ] ); + + wp_set_current_user( 0 ); + + $request = new WP_REST_Request( 'GET', sprintf( '/vip-block-data-api/v1/posts/%d/blocks', $post_id ) ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 400, $response->get_status() ); + $this->assertSame( 'rest_invalid_param', $response->get_data()['code'] ); + $this->assertStringNotContainsString( $marker, wp_json_encode( $response->get_data() ) ); + + wp_delete_post( $post_id ); + } + + public function test_rest_api_returns_blocks_for_synced_pattern_when_user_can_read_it() { + $this->register_block_with_attributes( 'test/custom-paragraph', [ + 'content' => [ + 'type' => 'rich-text', + 'source' => 'rich-text', + 'selector' => 'p', + '__experimentalRole' => 'content', + ], + ] ); + + $marker = 'authorized-synced-pattern-marker'; + $post_id = $this->factory()->post->create( [ + 'post_title' => 'Authorized synced pattern', + 'post_type' => 'wp_block', + 'post_content' => sprintf( '%s
', $marker ), + 'post_status' => 'publish', + ] ); + $user_id = $this->factory()->user->create( [ 'role' => 'editor' ] ); + + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'GET', sprintf( '/vip-block-data-api/v1/posts/%d/blocks', $post_id ) ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertStringContainsString( $marker, wp_json_encode( $response->get_data() ) ); + + wp_set_current_user( 0 ); + wp_delete_post( $post_id ); + } + + /** + * @dataProvider inaccessible_synced_pattern_provider + */ + public function test_rest_api_does_not_expand_inaccessible_synced_pattern_in_public_post( $post_type, $post_status, $post_password ) { + $this->register_block_with_attributes( 'test/custom-paragraph', [ + 'content' => [ + 'type' => 'rich-text', + 'source' => 'rich-text', + 'selector' => 'p', + '__experimentalRole' => 'content', + ], + ] ); + + $pattern_marker = 'inaccessible-synced-pattern-marker'; + $pattern_id = $this->factory()->post->create( [ + 'post_title' => 'Inaccessible synced pattern', + 'post_type' => $post_type, + 'post_content' => sprintf( '%s
', $pattern_marker ), + 'post_status' => $post_status, + 'post_password' => $post_password, + ] ); + + $parent_marker = 'public-parent-content-marker'; + $parent_id = $this->get_post_id_with_content( sprintf( + '%s
', + $parent_marker, + $pattern_id + ) ); + + wp_set_current_user( 0 ); + + $request = new WP_REST_Request( 'GET', sprintf( '/vip-block-data-api/v1/posts/%d/blocks', $parent_id ) ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $result = $response->get_data(); + $encoded_response = wp_json_encode( $result ); + $this->assertStringContainsString( $parent_marker, $encoded_response ); + $this->assertStringNotContainsString( $pattern_marker, $encoded_response ); + + $this->assertCount( 2, $result['blocks'] ); + $this->assertSame( 'core/block', $result['blocks'][1]['name'] ); + $this->assertArrayNotHasKey( 'innerBlocks', $result['blocks'][1] ); + + wp_delete_post( $parent_id ); + wp_delete_post( $pattern_id ); + } + + public function inaccessible_synced_pattern_provider() { + return [ + 'wrong post type' => [ 'post', 'publish', '' ], + 'draft synced pattern' => [ 'wp_block', 'draft', '' ], + 'passworded synced pattern' => [ 'wp_block', 'publish', 'synthetic-password' ], + ]; + } + + public function test_rest_api_honors_custom_post_type_controller_permissions() { + $test_post_type = register_post_type( 'vip-test-denied', [ + 'public' => true, + 'show_in_rest' => true, + 'rest_controller_class' => DenyingRestPostsController::class, + ] ); + + $marker = 'controller-restricted-content-marker'; + $post_id = $this->factory()->post->create( [ + 'post_title' => 'Controller-restricted post', + 'post_type' => $test_post_type->name, + 'post_content' => sprintf( '%s
', $marker ), + 'post_status' => 'publish', + ] ); + + $request = new WP_REST_Request( 'GET', sprintf( '/vip-block-data-api/v1/posts/%d/blocks', $post_id ) ); + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 400, $response->get_status() ); + $this->assertSame( 'rest_invalid_param', $response->get_data()['code'] ); + $this->assertStringNotContainsString( $marker, wp_json_encode( $response->get_data() ) ); + + wp_delete_post( $post_id ); + unregister_post_type( $test_post_type->name ); + } + + /** + * @dataProvider throwing_rest_controller_provider + */ + public function test_rest_api_fails_closed_when_custom_controller_throws( $controller_class ) { + $this->register_block_with_attributes( 'test/custom-paragraph', [ + 'content' => [ + 'type' => 'rich-text', + 'source' => 'rich-text', + 'selector' => 'p', + '__experimentalRole' => 'content', + ], + ] ); + + $test_post_type = register_post_type( 'vip-test-throwing', [ + 'public' => true, + 'show_in_rest' => true, + 'rest_controller_class' => $controller_class, + ] ); + + $marker = 'throwing-controller-content-marker'; + $post_id = $this->factory()->post->create( [ + 'post_title' => 'Controller-throwing post', + 'post_type' => $test_post_type->name, + 'post_content' => sprintf( '%s
', $marker ), + 'post_status' => 'publish', + ] ); + + // The caught exception is logged as a warning, which PHPUnit would otherwise + // convert into a test error. Capture it so the response can be inspected. + $logged_warnings = []; + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Used for catching errors in tests. + set_error_handler( + static function ( int $errno, string $errstr ) use ( &$logged_warnings ): bool { + $logged_warnings[] = $errstr; + return true; + }, + E_USER_WARNING + ); + + try { + $request = new WP_REST_Request( 'GET', sprintf( '/vip-block-data-api/v1/posts/%d/blocks', $post_id ) ); + $response = $this->server->dispatch( $request ); + } finally { + restore_error_handler(); + + // Unregister before any assertion can fail. A leaked throwing controller would + // break every later test when WordPress rebuilds REST routes. + wp_delete_post( $post_id ); + unregister_post_type( $test_post_type->name ); + } + + $this->assertEquals( 400, $response->get_status() ); + $this->assertSame( 'rest_invalid_param', $response->get_data()['code'] ); + + $encoded_response = wp_json_encode( $response->get_data() ); + $this->assertStringNotContainsString( $marker, $encoded_response ); + $this->assertStringNotContainsString( 'failure', $encoded_response ); + + $this->assertCount( 1, $logged_warnings ); + $this->assertStringContainsString( 'vip-block-data-api-rest-controller-error', $logged_warnings[0] ); + } + + public function throwing_rest_controller_provider() { + return [ + 'constructor throws' => [ ConstructorThrowingRestPostsController::class ], + 'permission check throws' => [ PermissionThrowingRestPostsController::class ], + ]; + } + + public function test_rest_validation_filter_can_override_default_denial() { + $this->register_block_with_attributes( 'test/custom-paragraph', [ + 'content' => [ + 'type' => 'rich-text', + 'source' => 'rich-text', + 'selector' => 'p', + '__experimentalRole' => 'content', + ], + ] ); + + $marker = 'filter-authorized-draft-content'; + $post_id = $this->get_post_id_with_content( sprintf( '%s
', $marker ), 'draft' ); + $allow_post = static function ( $is_readable, $filtered_post_id ) use ( $post_id ) { + return $post_id === $filtered_post_id ? true : $is_readable; + }; + + add_filter( 'vip_block_data_api__rest_validate_post_id', $allow_post, 10, 2 ); + $request = new WP_REST_Request( 'GET', sprintf( '/vip-block-data-api/v1/posts/%d/blocks', $post_id ) ); + $response = $this->server->dispatch( $request ); + remove_filter( 'vip_block_data_api__rest_validate_post_id', $allow_post, 10 ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertStringContainsString( $marker, wp_json_encode( $response->get_data() ) ); wp_delete_post( $post_id ); }