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
19 changes: 12 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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);
```

Expand All @@ -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 ) {
Expand Down
9 changes: 8 additions & 1 deletion src/parser/block-additions/core-block.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
}

Expand Down
66 changes: 38 additions & 28 deletions src/rest/rest-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@

namespace WPCOMVIP\BlockDataApi;

use Throwable;
use WP_Error;
use WP_REST_Request;

defined( 'ABSPATH' ) || die();

Expand Down Expand Up @@ -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 );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a breaking change for those customers that depended on this behaviour.

This should be noted in a breaking changes section.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@maxschmeling I think we should keep this behavior the same as before if possible. The vip_block_data_api__rest_validate_post_id filter existed to give developers an option to expose a post that our default logic blocked. If a developer wants to use special logic to expose a post in their own code using special rules (e.g. meta capabilities, auth token) I don't think we should block it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this is the code path to implementing preview functionality via this API?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Max rolled this back in 99cbf95, so the filter can still override default as I think it should work.


return $is_allowed;
},
'sanitize_callback' => function ( $param ) {
return intval( $param );
Expand Down Expand Up @@ -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 ) ) {
Expand All @@ -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;
}
}
}

Expand Down
26 changes: 26 additions & 0 deletions tests/mocks/constructor-throwing-rest-posts-controller-mock.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php
/**
* REST posts controller mock that throws during construction.
*
* @package vip-block-data-api
*/

namespace WPCOMVIP\BlockDataApi;

use Exception;
use WP_REST_Posts_Controller;

/**
* Test controller that throws during construction.
*/
class ConstructorThrowingRestPostsController extends WP_REST_Posts_Controller {
/**
* Throw instead of constructing the controller.
*
* @param string $post_type Post type.
* @throws Exception Always.
*/
public function __construct( $post_type ) {
throw new Exception( 'Controller constructor failure' );
}
}
27 changes: 27 additions & 0 deletions tests/mocks/permission-throwing-rest-posts-controller-mock.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php
/**
* REST posts controller mock that throws during the item permission check.
*
* @package vip-block-data-api
*/

namespace WPCOMVIP\BlockDataApi;

use Exception;
use WP_REST_Posts_Controller;
use WP_REST_Request;

/**
* Test controller that throws during the item permission check.
*/
class PermissionThrowingRestPostsController extends WP_REST_Posts_Controller {
/**
* Throw instead of checking permissions.
*
* @param WP_REST_Request $request Full details about the request.
* @throws Exception Always.
*/
public function get_item_permissions_check( $request ) {
throw new Exception( 'Permission check failure' );
}
}
26 changes: 26 additions & 0 deletions tests/mocks/rest-posts-controller-mock.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php
/**
* REST posts controller mock.
*
* @package vip-block-data-api
*/

namespace WPCOMVIP\BlockDataApi;

use WP_REST_Posts_Controller;
use WP_REST_Request;

/**
* Test controller that denies every item request.
*/
class DenyingRestPostsController extends WP_REST_Posts_Controller {
/**
* Deny access to every item.
*
* @param WP_REST_Request $request Full details about the request.
* @return false
*/
public function get_item_permissions_check( $request ) {
return false;
}
}
37 changes: 37 additions & 0 deletions tests/parser/test-synced-patterns.php
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,43 @@ public function test_missing_synced_pattern() {
$this->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( '<!-- wp:test/custom-block --><p>%s</p><!-- /wp:test/custom-block -->', $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( '<!-- wp:block {"ref":%d} /-->', $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() {
Expand Down
Loading
Loading