Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
a330867
REST API: Restore the global post after preparing a revision.
MicahelE Jun 21, 2026
a624357
Merge branch 'trunk' into fix/65495-revisions-controller-post-leak
t-hamano Jul 28, 2026
8a9d73e
REST API: Apply review feedback on the revision post restore.
MicahelE Jul 28, 2026
0a3b366
Merge branch 'trunk' into fix/65495-revisions-controller-post-leak
westonruter Jul 30, 2026
f033301
REST API: Strengthen the revision global post restoration tests.
westonruter Jul 31, 2026
fc1557a
Add type hints to test class properties
westonruter Jul 31, 2026
ea3274a
Use $post global in prepare_item_for_response
westonruter Jul 31, 2026
d1bfad5
Simplify truthy check in reset_post_data
westonruter Jul 31, 2026
f645950
REST API: Pass the revision to rest_prepare_revision before restoring…
westonruter Jul 31, 2026
96d7001
REST API: Keep the global post unset when the main query has a post.
westonruter Jul 31, 2026
4bbacdc
REST API: Align the autosave postdata test with the revision ones.
westonruter Jul 31, 2026
d7c23d3
REST API: Keep the revision local while preparing the response.
westonruter Jul 31, 2026
2d1eff5
REST API: Document the restored global post with an @since tag.
westonruter Jul 31, 2026
36b74d4
REST API: Rename reset_post_data() to restore_post_data().
westonruter Jul 31, 2026
100f4e6
REST API: Add missing @covers tags to the postdata tests.
westonruter Jul 31, 2026
0629a28
REST API: Document what restore_post_data() does not restore.
westonruter Jul 31, 2026
7712697
REST API: Clear the global post by assignment rather than unsetting it.
westonruter Jul 31, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -595,14 +595,29 @@ protected function prepare_items_query( $prepared_args = array(), $request = nul
*
* @since 4.7.0
* @since 5.9.0 Renamed `$post` to `$item` to match parent class for PHP 8 named parameter support.
* @since 7.1.0 The global post is now restored to its previous value before returning.
*
* @global WP_Post $post Global post object.
* @global WP_Post|null $post Global post object.
*
* @param WP_Post $item Post revision object.
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response Response object.
*/
public function prepare_item_for_response( $item, $request ) {
/*
* Save the previous global post so it can be restored before returning.
* Preparing the revision sets up the global post and post data, which
* must not leak into the rest of the request (e.g. the autosaves endpoint
* is preloaded in the block editor, where a leaked global post can cause
* the editor to be initialized with the wrong post).
*
* Note that $post is intentionally not declared as a global here. It must
* remain local to this method so that a filter which reassigns the global
* post while the response is being prepared (for example on 'the_content')
* cannot change which post the remaining fields are read from.
*/
$previous_post = isset( $GLOBALS['post'] ) && $GLOBALS['post'] instanceof WP_Post ? $GLOBALS['post'] : null;

// Restores the more descriptive, specific name for use within this method.
$post = $item;

Expand All @@ -613,7 +628,11 @@ public function prepare_item_for_response( $item, $request ) {
// Don't prepare the response body for HEAD requests.
if ( $request->is_method( 'HEAD' ) ) {
/** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php */
return apply_filters( 'rest_prepare_revision', new WP_REST_Response( array() ), $post, $request );
$response = apply_filters( 'rest_prepare_revision', new WP_REST_Response( array() ), $post, $request );

$this->restore_post_data( $previous_post );

return $response;
}

$fields = $this->get_fields_for_response( $request );
Expand Down Expand Up @@ -717,7 +736,55 @@ public function prepare_item_for_response( $item, $request ) {
* @param WP_Post $post The original revision object.
* @param WP_REST_Request $request Request used to generate the response.
*/
return apply_filters( 'rest_prepare_revision', $response, $post, $request );
$response = apply_filters( 'rest_prepare_revision', $response, $post, $request );

$this->restore_post_data( $previous_post );

return $response;
}

/**
* Restores the global post to its previous value after preparing a revision.
*
* Preparing a revision overwrites the global post and post data via
* setup_postdata(). This restores the global post that was in place
* beforehand so the change does not leak into the rest of the request.
*
* Only the global post is guaranteed to be restored. When there was no
* previous global post and the main query has no post either, which is the
* usual state during a REST request, wp_reset_postdata() has nothing to
* restore from, so the remaining globals set by setup_postdata() (such as
* $id, $authordata and $pages) are left describing the revision. Clearing
* those would mean unsetting each one by hand, which is beyond what is
* needed to keep the global post from leaking.
*
* @since 7.1.0
*
* @param WP_Post|null $previous_post The global post to restore, or null if there was none.
*/
private function restore_post_data( ?WP_Post $previous_post ): void {
if ( $previous_post ) {
$GLOBALS['post'] = $previous_post;
setup_postdata( $previous_post );
return;
}

/*
* There was no global post to restore, so clear the revision's post data.
* This runs before clearing the global post because wp_reset_postdata()
* repopulates it from the main query whenever that query has a post. Note
* that it is a no-op when the main query has no post, in which case only
* the global post below is cleared.
*/
wp_reset_postdata();

/*
* Assigned rather than unset so that any `global $post` binding made before
* this request keeps pointing at the global. Unsetting removes the entry from
* the symbol table, which detaches those bindings, and a later write through
* one of them would no longer be visible to get_post().
*/
$GLOBALS['post'] = null;
}

/**
Expand Down
66 changes: 48 additions & 18 deletions tests/phpunit/tests/rest-api/rest-autosaves-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,21 @@
* @group restapi
*/
class WP_Test_REST_Autosaves_Controller extends WP_Test_REST_Post_Type_Controller_Testcase {
protected static $post_id;
protected static $page_id;
protected static $draft_page_id;
protected static int $post_id;
protected static int $page_id;
protected static int $draft_page_id;

protected static $autosave_post_id;
protected static $autosave_page_id;
protected static int $autosave_post_id;
protected static int $autosave_page_id;

protected static $editor_id;
protected static $contributor_id;
protected static int $editor_id;
protected static int $contributor_id;

protected static $parent_page_id;
protected static $child_page_id;
protected static $child_draft_page_id;
protected static int $parent_page_id;
protected static int $child_page_id;
protected static int $child_draft_page_id;

private $post_autosave;
private WP_Post $post_autosave;

protected function set_post_data( $args = array() ) {
$defaults = array(
Expand Down Expand Up @@ -731,16 +731,46 @@ protected function check_get_autosave_response( $response, $autosave ) {
$this->assertSame( rest_url( '/wp/v2/' . $parent_base . '/' . $autosave->post_parent ), $links['parent'][0]['href'] );
}

public function test_get_item_sets_up_postdata() {
/**
* The autosave's postdata should be set up while preparing the response,
* so rendered fields reflect the autosave, without leaking into the global
* post after the request completes.
*
* @ticket 65495
*
* @global int|null $id ID from the set up global post data.
*
* @covers WP_REST_Autosaves_Controller::prepare_item_for_response
*/
public function test_get_item_sets_up_postdata_without_leaking_global_post() {
global $id;

// Populate the global $wp_query with the post and set it up.
wp_set_current_user( self::$editor_id );
$request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves/' . self::$autosave_post_id );
rest_get_server()->dispatch( $request );
query_posts( array( 'p' => self::$post_id ) );
the_post();

$post = get_post();
$parent_post_id = wp_is_post_revision( $post->ID );
// Assert initial state.
$post = get_post();
$this->assertInstanceOf( WP_Post::class, $post, 'The global post should be set up before the request.' );
$this->assertSame( self::$post_id, $post->ID, 'The global post should be the parent post before the request.' );
$this->assertSame( self::$post_id, $id, 'The global $id should be the parent post ID before the request.' );

$this->assertSame( $post->ID, self::$autosave_post_id );
$this->assertSame( $parent_post_id, self::$post_id );
// Make the request to get the autosave.
$request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves/' . self::$autosave_post_id );
$response = rest_get_server()->dispatch( $request );
$this->assertSame( 200, $response->get_status() );
$data = $response->get_data();
$this->assertIsArray( $data );
$this->assertArrayHasKey( 'title', $data );
$this->assertIsArray( $data['title'] );
$this->assertSame( get_the_title( self::$autosave_post_id ), $data['title']['rendered'], 'Expected the rendered title to reflect the autosave, proving postdata was set up during preparation.' );

// The global post is restored to the post that was set before the request.
$post = get_post();
$this->assertInstanceOf( WP_Post::class, $post, 'The global post should still be set after the request.' );
$this->assertSame( self::$post_id, $post->ID, 'The global post should be restored to the post that was set before the request.' );
$this->assertSame( self::$post_id, $id, 'The global $id should be restored to the post that was set before the request.' );
}

public function test_update_item_draft_page_with_parent() {
Expand Down
Loading
Loading