From 6c6633a6c2537214d5d6be6c6ec0fa9ce956657f Mon Sep 17 00:00:00 2001 From: Max Schmeling Date: Fri, 25 Sep 2026 15:55:42 -0500 Subject: [PATCH 1/5] Fix REST access controls for protected content --- README.md | 17 +- src/parser/block-additions/core-block.php | 9 +- src/rest/rest-api.php | 57 +++---- tests/mocks/rest-posts-controller-mock.php | 26 ++++ tests/parser/test-synced-patterns.php | 37 +++++ tests/rest/test-rest-api.php | 173 +++++++++++++++++++++ 6 files changed, 283 insertions(+), 36 deletions(-) create mode 100644 tests/mocks/rest-posts-controller-mock.php diff --git a/README.md b/README.md index 7fac48e..e5e9310 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ 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. Review these [**Filters**](#filters) to learn more about limiting access to the REST endpoint: @@ -1186,26 +1186,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`. ```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, but cannot grant access to content denied by WordPress's REST controller or the Block Data API's password protection. + 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 +1231,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..cfb8cdd 100644 --- a/src/rest/rest-api.php +++ b/src/rest/rest-api.php @@ -8,6 +8,7 @@ namespace WPCOMVIP\BlockDataApi; use WP_Error; +use WP_REST_Request; defined( 'ABSPATH' ) || die(); @@ -59,18 +60,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 further restrict access, but cannot grant access to a post that + * WordPress or the Block Data API's password protection has denied. + * + * @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_readable && $is_allowed; }, 'sanitize_callback' => function ( $param ) { return intval( $param ); @@ -172,17 +179,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 +199,25 @@ private static function is_post_readable( $post_id ) { return false; } - if ( 'publish' === $post->post_status || current_user_can( 'read_post', $post->ID ) ) { - return true; - } - - $post_status_obj = get_post_status_object( $post->post_status ); - if ( $post_status_obj && $post_status_obj->public ) { - 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; } - // Use parent status if inheriting. - if ( 'inherit' === $post->post_status && $post->post_parent > 0 ) { - return self::is_post_readable( $post->post_parent ); + $rest_controller = $post_type->get_rest_controller(); + if ( empty( $rest_controller ) || ! is_callable( [ $rest_controller, 'get_item_permissions_check' ] ) ) { + return false; } - /* - * 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; - } + // 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. + $request = new WP_REST_Request( 'GET' ); + $request->set_param( 'id', $post->ID ); + $request->set_param( 'context', 'view' ); - return false; + return true === $rest_controller->get_item_permissions_check( $request ); } } diff --git a/tests/mocks/rest-posts-controller-mock.php b/tests/mocks/rest-posts-controller-mock.php new file mode 100644 index 0000000..b06ebbc --- /dev/null +++ b/tests/mocks/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..015423d 100644 --- a/tests/rest/test-rest-api.php +++ b/tests/rest/test-rest-api.php @@ -11,6 +11,8 @@ use WP_REST_Server; use WP_REST_Request; +require_once dirname( __DIR__ ) . '/mocks/rest-posts-controller-mock.php'; + /** * e2e tests to ensure that the REST API endpoint is available. */ @@ -734,6 +736,177 @@ public function test_rest_api_returns_error_for_unpublished_post() { 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 ); + } + + 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, + ] ); + + $post_id = $this->factory()->post->create( [ + 'post_title' => 'Controller-restricted post', + 'post_type' => $test_post_type->name, + 'post_content' => '

restricted content

', + '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'] ); + + wp_delete_post( $post_id ); + unregister_post_type( $test_post_type->name ); + } + + public function test_rest_validation_filter_cannot_restore_controller_denied_post() { + $post_id = $this->get_post_id_with_content( '

Draft content

', 'draft' ); + $allow_post = static function () { + return true; + }; + + add_filter( 'vip_block_data_api__rest_validate_post_id', $allow_post ); + $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 ); + + $this->assertEquals( 400, $response->get_status() ); + $this->assertSame( 'rest_invalid_param', $response->get_data()['code'] ); + + wp_delete_post( $post_id ); + } + public function test_rest_api_returns_error_for_classic_content() { $post_id = $this->get_post_id_with_content( '

Classic editor content

' ); From 99cbf953d4a35ff209aa286c26ee0ce778b97dc5 Mon Sep 17 00:00:00 2001 From: Max Schmeling Date: Mon, 28 Sep 2026 15:46:03 -0500 Subject: [PATCH 2/5] Restore REST validation filter overrides --- README.md | 2 +- src/rest/rest-api.php | 6 +++--- tests/rest/test-rest-api.php | 26 ++++++++++++++++++-------- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index e5e9310..cc6eac8 100644 --- a/README.md +++ b/README.md @@ -1201,7 +1201,7 @@ Limit which post IDs are valid in the REST API. By default, posts are queryable return apply_filters( 'vip_block_data_api__rest_validate_post_id', $is_readable, $post_id ); ``` -This filter can further restrict access, but cannot grant access to content denied by WordPress's REST controller or the Block Data API's password protection. +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: diff --git a/src/rest/rest-api.php b/src/rest/rest-api.php index cfb8cdd..e1c9c1c 100644 --- a/src/rest/rest-api.php +++ b/src/rest/rest-api.php @@ -67,8 +67,8 @@ public static function register_rest_routes() { * Validates that a post can be queried via the Block Data API REST endpoint. * Return false to disable access to a post. * - * This filter can further restrict access, but cannot grant access to a post that - * WordPress or the Block Data API's password protection has denied. + * 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 @@ -77,7 +77,7 @@ public static function register_rest_routes() { */ $is_allowed = apply_filters( 'vip_block_data_api__rest_validate_post_id', $is_readable, $post_id ); - return $is_readable && $is_allowed; + return $is_allowed; }, 'sanitize_callback' => function ( $param ) { return intval( $param ); diff --git a/tests/rest/test-rest-api.php b/tests/rest/test-rest-api.php index 015423d..9c5e0fb 100644 --- a/tests/rest/test-rest-api.php +++ b/tests/rest/test-rest-api.php @@ -890,19 +890,29 @@ public function test_rest_api_honors_custom_post_type_controller_permissions() { unregister_post_type( $test_post_type->name ); } - public function test_rest_validation_filter_cannot_restore_controller_denied_post() { - $post_id = $this->get_post_id_with_content( '

Draft content

', 'draft' ); - $allow_post = static function () { - return true; + 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 ); + 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 ); + remove_filter( 'vip_block_data_api__rest_validate_post_id', $allow_post, 10 ); - $this->assertEquals( 400, $response->get_status() ); - $this->assertSame( 'rest_invalid_param', $response->get_data()['code'] ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertStringContainsString( $marker, wp_json_encode( $response->get_data() ) ); wp_delete_post( $post_id ); } From 30793b1ad0fa39a5b4c8fc8cd51fcb6ee6752d8c Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Wed, 7 Oct 2026 13:21:04 -0600 Subject: [PATCH 3/5] Wrap REST controller check in try/catch, log error and do not allow access on failure --- src/rest/rest-api.php | 27 ++++--- ...or-throwing-rest-posts-controller-mock.php | 26 +++++++ ...on-throwing-rest-posts-controller-mock.php | 27 +++++++ tests/rest/test-rest-api.php | 71 +++++++++++++++++++ 4 files changed, 142 insertions(+), 9 deletions(-) create mode 100644 tests/mocks/constructor-throwing-rest-posts-controller-mock.php create mode 100644 tests/mocks/permission-throwing-rest-posts-controller-mock.php diff --git a/src/rest/rest-api.php b/src/rest/rest-api.php index e1c9c1c..1f1ec78 100644 --- a/src/rest/rest-api.php +++ b/src/rest/rest-api.php @@ -7,6 +7,7 @@ namespace WPCOMVIP\BlockDataApi; +use Throwable; use WP_Error; use WP_REST_Request; @@ -206,18 +207,26 @@ private static function is_post_readable( $post_id ) { return false; } - $rest_controller = $post_type->get_rest_controller(); - if ( empty( $rest_controller ) || ! is_callable( [ $rest_controller, 'get_item_permissions_check' ] ) ) { - return false; - } - // 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. - $request = new WP_REST_Request( 'GET' ); - $request->set_param( 'id', $post->ID ); - $request->set_param( 'context', 'view' ); + // 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; + } - return true === $rest_controller->get_item_permissions_check( $request ); + $request = new WP_REST_Request( 'GET' ); + $request->set_param( 'id', $post->ID ); + $request->set_param( 'context', 'view' ); + + 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; + } } } 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 @@ +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' => [ From fae29069dabd5f5b018aa7d977bb451aa69eb757 Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Wed, 7 Oct 2026 13:25:04 -0600 Subject: [PATCH 4/5] Add README clarifications about security change --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cc6eac8..c68309b 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,8 @@ The REST URL is located at: 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: - [`vip_block_data_api__rest_validate_post_id`](#vip_block_data_api__rest_validate_post_id) @@ -1186,7 +1188,7 @@ 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 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`. +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 /** From 632676464c7ab6231a05f6c4f35e0c0cae74bbbd Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Wed, 7 Oct 2026 13:26:12 -0600 Subject: [PATCH 5/5] Add additional tests for wp_block and password-protected posts --- tests/rest/test-rest-api.php | 65 ++++++++++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/tests/rest/test-rest-api.php b/tests/rest/test-rest-api.php index 1321f0e..a01cea3 100644 --- a/tests/rest/test-rest-api.php +++ b/tests/rest/test-rest-api.php @@ -718,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', ] ); @@ -734,6 +735,7 @@ 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 ); } @@ -868,6 +870,63 @@ public function test_rest_api_returns_blocks_for_synced_pattern_when_user_can_re 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, @@ -875,10 +934,11 @@ public function test_rest_api_honors_custom_post_type_controller_permissions() { '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' => '

restricted content

', + 'post_content' => sprintf( '

%s

', $marker ), 'post_status' => 'publish', ] ); @@ -887,6 +947,7 @@ public function test_rest_api_honors_custom_post_type_controller_permissions() { $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 );