From a3308673bf984d8856bda29ab91871d692ff1ca3 Mon Sep 17 00:00:00 2001 From: Michael Etokakpan Date: Sun, 21 Jun 2026 01:04:21 +0100 Subject: [PATCH 01/15] REST API: Restore the global post after preparing a revision. WP_REST_Revisions_Controller::prepare_item_for_response() set the global $post and called setup_postdata() without restoring them, so the change leaked for the rest of the request. The autosaves controller delegates here, so preloading that endpoint in the block editor could leave the global $post pointing at an autosave and initialize the editor with the wrong post. Capture the previous global post and restore it on every return path. Update the existing "sets up postdata" tests to confirm rendered fields still reflect the revision while the global post no longer leaks. Fixes #65495. --- .../class-wp-rest-revisions-controller.php | 34 ++++++ .../rest-api/rest-autosaves-controller.php | 32 ++++-- .../rest-api/rest-revisions-controller.php | 102 ++++++++++++++++-- 3 files changed, 154 insertions(+), 14 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php index 73a888d6eac48..f0f13b97f100f 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php @@ -606,12 +606,23 @@ public function prepare_item_for_response( $item, $request ) { // Restores the more descriptive, specific name for use within this method. $post = $item; + /* + * 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). + */ + $previous_post = isset( $GLOBALS['post'] ) ? $GLOBALS['post'] : null; + $GLOBALS['post'] = $post; setup_postdata( $post ); // Don't prepare the response body for HEAD requests. if ( $request->is_method( 'HEAD' ) ) { + $this->reset_post_data( $previous_post ); + /** 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 ); } @@ -706,6 +717,8 @@ public function prepare_item_for_response( $item, $request ) { $response->add_link( 'parent', rest_url( rest_get_route_for_post( $data['parent'] ) ) ); } + $this->reset_post_data( $previous_post ); + /** * Filters a revision returned from the REST API. * @@ -720,6 +733,27 @@ public function prepare_item_for_response( $item, $request ) { return apply_filters( 'rest_prepare_revision', $response, $post, $request ); } + /** + * 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. + * + * @since 7.0.1 + * + * @param WP_Post|null $previous_post The global post to restore, or null if there was none. + */ + private function reset_post_data( $previous_post ) { + if ( $previous_post instanceof WP_Post ) { + $GLOBALS['post'] = $previous_post; + setup_postdata( $previous_post ); + } else { + unset( $GLOBALS['post'] ); + wp_reset_postdata(); + } + } + /** * Checks the post_date_gmt or modified_gmt and prepare any post or * modified date for single post output. diff --git a/tests/phpunit/tests/rest-api/rest-autosaves-controller.php b/tests/phpunit/tests/rest-api/rest-autosaves-controller.php index 7815f8ced23c9..11b294283c96d 100644 --- a/tests/phpunit/tests/rest-api/rest-autosaves-controller.php +++ b/tests/phpunit/tests/rest-api/rest-autosaves-controller.php @@ -731,16 +731,34 @@ 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 + */ + public function test_get_item_sets_up_postdata_without_leaking_global_post() { 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 ); - $post = get_post(); - $parent_post_id = wp_is_post_revision( $post->ID ); + $GLOBALS['post'] = get_post( self::$post_id ); + setup_postdata( $GLOBALS['post'] ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/autosaves/' . self::$autosave_post_id ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $autosave = get_post( self::$autosave_post_id ); - $this->assertSame( $post->ID, self::$autosave_post_id ); - $this->assertSame( $parent_post_id, self::$post_id ); + // The rendered title reflects the autosave, proving postdata was set up during preparation. + $this->assertSame( get_the_title( $autosave->ID ), $data['title']['rendered'] ); + + // The global post is restored to the post that was set before the request. + $this->assertSame( + self::$post_id, + $GLOBALS['post']->ID, + 'The global post should not leak the autosave after the request.' + ); } public function test_update_item_draft_page_with_parent() { diff --git a/tests/phpunit/tests/rest-api/rest-revisions-controller.php b/tests/phpunit/tests/rest-api/rest-revisions-controller.php index 52011afcb9318..160617b0fbd24 100644 --- a/tests/phpunit/tests/rest-api/rest-revisions-controller.php +++ b/tests/phpunit/tests/rest-api/rest-revisions-controller.php @@ -266,6 +266,78 @@ public function test_get_item() { $this->assertSame( self::$editor_id, $data['author'] ); } + /** + * Preparing a revision must not leak the revision into the global post. + * + * @ticket 65495 + * + * @covers WP_REST_Revisions_Controller::prepare_item_for_response + */ + public function test_get_items_restores_global_post() { + wp_set_current_user( self::$editor_id ); + + $GLOBALS['post'] = get_post( self::$post_id ); + setup_postdata( $GLOBALS['post'] ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions' ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertInstanceOf( 'WP_Post', $GLOBALS['post'], 'The global post should still be set after the request.' ); + $this->assertSame( + self::$post_id, + $GLOBALS['post']->ID, + 'The global post should be restored to the post that was set before the request.' + ); + } + + /** + * Preparing a revision for a HEAD request must also restore the global post. + * + * @ticket 65495 + * + * @covers WP_REST_Revisions_Controller::prepare_item_for_response + */ + public function test_get_items_head_request_restores_global_post() { + wp_set_current_user( self::$editor_id ); + + $GLOBALS['post'] = get_post( self::$post_id ); + setup_postdata( $GLOBALS['post'] ); + + $request = new WP_REST_Request( 'HEAD', '/wp/v2/posts/' . self::$post_id . '/revisions' ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertInstanceOf( 'WP_Post', $GLOBALS['post'], 'The global post should still be set after a HEAD request.' ); + $this->assertSame( + self::$post_id, + $GLOBALS['post']->ID, + 'The global post should be restored after a HEAD request.' + ); + } + + /** + * When there is no global post before the request, none should be set afterwards. + * + * @ticket 65495 + * + * @covers WP_REST_Revisions_Controller::prepare_item_for_response + */ + public function test_get_items_without_global_post_leaves_it_unset() { + wp_set_current_user( self::$editor_id ); + + unset( $GLOBALS['post'] ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions' ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertArrayNotHasKey( 'post', $GLOBALS, 'The global post should not be set when there was none before the request.' ); + } + /** * @dataProvider data_readable_http_methods * @ticket 56481 @@ -639,16 +711,32 @@ protected function check_get_revision_response( $response, $revision ) { $this->assertSame( rest_url( '/wp/v2/' . $parent_base . '/' . $revision->post_parent ), $links['parent'][0]['href'] ); } - public function test_get_item_sets_up_postdata() { + /** + * The revision's postdata should be set up while preparing the response, + * so rendered fields reflect the revision, without leaking into the global + * post after the request completes. + * + * @ticket 65495 + */ + public function test_get_item_sets_up_postdata_without_leaking_global_post() { wp_set_current_user( self::$editor_id ); - $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions/' . $this->revision_id1 ); - rest_get_server()->dispatch( $request ); - $post = get_post(); - $parent_post_id = wp_is_post_revision( $post->ID ); + $GLOBALS['post'] = get_post( self::$post_id ); + setup_postdata( $GLOBALS['post'] ); - $this->assertSame( $post->ID, $this->revision_id1 ); - $this->assertSame( $parent_post_id, self::$post_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions/' . $this->revision_id1 ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + // The rendered title reflects the revision, proving postdata was set up during preparation. + $this->assertSame( get_the_title( $this->revision_id1 ), $data['title']['rendered'] ); + + // The global post is restored to the post that was set before the request. + $this->assertSame( + self::$post_id, + $GLOBALS['post']->ID, + 'The global post should not leak the revision after the request.' + ); } /** From 8a9d73e7cfce0c2ac7fff4b84d4d0726cface075 Mon Sep 17 00:00:00 2001 From: Michael Etokakpan Date: Tue, 28 Jul 2026 11:15:11 +0100 Subject: [PATCH 02/15] REST API: Apply review feedback on the revision post restore. Corrects the @since tag to 7.1.0, adds a nullable WP_Post type hint and void return type to reset_post_data(), and guards the saved global post with an instanceof check. Follow-up to [62161]. --- .../endpoints/class-wp-rest-revisions-controller.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php index f0f13b97f100f..9c99ec1bf99de 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php @@ -613,7 +613,7 @@ public function prepare_item_for_response( $item, $request ) { * is preloaded in the block editor, where a leaked global post can cause * the editor to be initialized with the wrong post). */ - $previous_post = isset( $GLOBALS['post'] ) ? $GLOBALS['post'] : null; + $previous_post = isset( $GLOBALS['post'] ) && $GLOBALS['post'] instanceof WP_Post ? $GLOBALS['post'] : null; $GLOBALS['post'] = $post; @@ -740,12 +740,12 @@ public function prepare_item_for_response( $item, $request ) { * setup_postdata(). This restores the global post that was in place * beforehand so the change does not leak into the rest of the request. * - * @since 7.0.1 + * @since 7.1.0 * * @param WP_Post|null $previous_post The global post to restore, or null if there was none. */ - private function reset_post_data( $previous_post ) { - if ( $previous_post instanceof WP_Post ) { + private function reset_post_data( ?WP_Post $previous_post ): void { + if ( null !== $previous_post ) { $GLOBALS['post'] = $previous_post; setup_postdata( $previous_post ); } else { From f033301fa553def6ccd22d07aa7ad530a7f666cb Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 30 Jul 2026 19:33:15 -0700 Subject: [PATCH 03/15] REST API: Strengthen the revision global post restoration tests. Set up the global post with `query_posts()` and `the_post()` so the full set of postdata globals is populated, then assert against `get_post()` and the global `$id` rather than reading `$GLOBALS['post']` directly. The previous assertions passed even when the restore skipped `setup_postdata()`, leaving the rest of the postdata globals pointing at the revision. Point the HEAD request test at the single revision route. The collection endpoint short-circuits before preparing items for HEAD requests, so the test never reached the branch it was meant to cover. Rename it to match the route it now exercises. Assert the response status and shape before indexing into the data, and use core's `Type|null` syntax for the `@global` tags. Co-Authored-By: Claude Opus 5 (1M context) --- .../rest-api/rest-revisions-controller.php | 115 ++++++++++++------ 1 file changed, 81 insertions(+), 34 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-revisions-controller.php b/tests/phpunit/tests/rest-api/rest-revisions-controller.php index 160617b0fbd24..1a237c111e1ef 100644 --- a/tests/phpunit/tests/rest-api/rest-revisions-controller.php +++ b/tests/phpunit/tests/rest-api/rest-revisions-controller.php @@ -271,51 +271,76 @@ public function test_get_item() { * * @ticket 65495 * + * @global int|null $id ID from the set up global post data. + * * @covers WP_REST_Revisions_Controller::prepare_item_for_response */ public function test_get_items_restores_global_post() { + global $id; + + // Populate the global $wp_query with the post and set it up. wp_set_current_user( self::$editor_id ); + query_posts( array( 'p' => self::$post_id ) ); + the_post(); - $GLOBALS['post'] = get_post( self::$post_id ); - setup_postdata( $GLOBALS['post'] ); + // 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.' ); + // Make the request to get revisions. $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions' ); $request->set_param( 'context', 'edit' ); $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); - $this->assertInstanceOf( 'WP_Post', $GLOBALS['post'], 'The global post should still be set after the request.' ); - $this->assertSame( - self::$post_id, - $GLOBALS['post']->ID, - 'The global post should be restored to the post that was set before the request.' - ); + + // 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.' ); } /** * Preparing a revision for a HEAD request must also restore the global post. * + * The collection endpoint short-circuits before preparing items for HEAD + * requests, so the single revision endpoint is used to reach the HEAD + * branch of prepare_item_for_response(). + * * @ticket 65495 * + * @global int|null $id ID from the set up global post data. + * * @covers WP_REST_Revisions_Controller::prepare_item_for_response */ - public function test_get_items_head_request_restores_global_post() { + public function test_get_item_head_request_restores_global_post() { + global $id; + + // Populate the global $wp_query with the post and set it up. wp_set_current_user( self::$editor_id ); + query_posts( array( 'p' => self::$post_id ) ); + the_post(); - $GLOBALS['post'] = get_post( self::$post_id ); - setup_postdata( $GLOBALS['post'] ); + // 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.' ); - $request = new WP_REST_Request( 'HEAD', '/wp/v2/posts/' . self::$post_id . '/revisions' ); + // Make the HEAD request to get a revision. + $request = new WP_REST_Request( 'HEAD', '/wp/v2/posts/' . self::$post_id . '/revisions/' . $this->revision_id1 ); $request->set_param( 'context', 'edit' ); $response = rest_get_server()->dispatch( $request ); - $this->assertSame( 200, $response->get_status() ); - $this->assertInstanceOf( 'WP_Post', $GLOBALS['post'], 'The global post should still be set after a HEAD request.' ); - $this->assertSame( - self::$post_id, - $GLOBALS['post']->ID, - 'The global post should be restored after a HEAD request.' - ); + + // 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.' ); } /** @@ -323,19 +348,29 @@ public function test_get_items_head_request_restores_global_post() { * * @ticket 65495 * + * @global WP_Query|null $wp_query The global WP_Query. + * * @covers WP_REST_Revisions_Controller::prepare_item_for_response */ public function test_get_items_without_global_post_leaves_it_unset() { - wp_set_current_user( self::$editor_id ); + global $wp_query; - unset( $GLOBALS['post'] ); + // Leave the global $wp_query without a post, so there is no post data to restore. + wp_set_current_user( self::$editor_id ); + $this->assertInstanceOf( WP_Query::class, $wp_query, 'The WP_Query global must be set for wp_reset_postdata() to have anything to restore from.' ); + $this->assertNull( get_post(), 'The global post should not have been initially set.' ); + // Make the request to get a revision. $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions' ); $request->set_param( 'context', 'edit' ); $response = rest_get_server()->dispatch( $request ); - $this->assertSame( 200, $response->get_status() ); - $this->assertArrayNotHasKey( 'post', $GLOBALS, 'The global post should not be set when there was none before the request.' ); + + /* + * Note: At this point, the global $id is still populated because there was no $wp_query->post to begin with, + * so WP_Query::reset_postdata() has nothing to set it to. It does not null out any globals when there is no post. + */ + $this->assertNull( get_post(), 'The global post should not be set when there was none before the request.' ); } /** @@ -716,27 +751,39 @@ protected function check_get_revision_response( $response, $revision ) { * so rendered fields reflect the revision, without leaking into the global * post after the request completes. * + * @global int|null $id ID from the set up global post data. + * * @ticket 65495 */ 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 ); + query_posts( array( 'p' => self::$post_id ) ); + the_post(); - $GLOBALS['post'] = get_post( self::$post_id ); - setup_postdata( $GLOBALS['post'] ); + // 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.' ); + // Make the request to get a revision. $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions/' . $this->revision_id1 ); $response = rest_get_server()->dispatch( $request ); - $data = $response->get_data(); - - // The rendered title reflects the revision, proving postdata was set up during preparation. - $this->assertSame( get_the_title( $this->revision_id1 ), $data['title']['rendered'] ); + $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( $this->revision_id1 ), $data['title']['rendered'], 'Expected the rendered title to reflect the revision, proving postdata was set up during preparation.' ); // The global post is restored to the post that was set before the request. - $this->assertSame( - self::$post_id, - $GLOBALS['post']->ID, - 'The global post should not leak the revision after 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.' ); } /** From fc1557a535465d64a7e811439c4cc3ad04e51bbe Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 30 Jul 2026 19:37:24 -0700 Subject: [PATCH 04/15] Add type hints to test class properties --- .../rest-api/rest-autosaves-controller.php | 22 +++++++-------- .../rest-api/rest-revisions-controller.php | 28 ++++++++++--------- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-autosaves-controller.php b/tests/phpunit/tests/rest-api/rest-autosaves-controller.php index 11b294283c96d..1ed0927bcf4df 100644 --- a/tests/phpunit/tests/rest-api/rest-autosaves-controller.php +++ b/tests/phpunit/tests/rest-api/rest-autosaves-controller.php @@ -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( diff --git a/tests/phpunit/tests/rest-api/rest-revisions-controller.php b/tests/phpunit/tests/rest-api/rest-revisions-controller.php index 1a237c111e1ef..c4468b4208025 100644 --- a/tests/phpunit/tests/rest-api/rest-revisions-controller.php +++ b/tests/phpunit/tests/rest-api/rest-revisions-controller.php @@ -8,22 +8,24 @@ * @group restapi */ class WP_Test_REST_Revisions_Controller extends WP_Test_REST_Controller_Testcase { - protected static $post_id; - protected static $post_id_2; - protected static $page_id; + protected static int $post_id; + protected static int $post_id_2; + protected static int $page_id; - protected static $editor_id; - protected static $contributor_id; + protected static int $editor_id; + protected static int $contributor_id; + + private int $total_revisions; - private $total_revisions; private $revisions; - private $revision_1; - private $revision_id1; - private $revision_2; - private $revision_id2; - private $revision_3; - private $revision_id3; - private $revision_2_1_id; + + private WP_Post $revision_1; + private int $revision_id1; + private WP_Post $revision_2; + private int $revision_id2; + private WP_Post $revision_3; + private int $revision_id3; + private int $revision_2_1_id; public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { self::$post_id = $factory->post->create(); From ea3274a6979ded0991a9087bb740772bd052127b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 30 Jul 2026 19:38:00 -0700 Subject: [PATCH 05/15] Use $post global in prepare_item_for_response --- .../endpoints/class-wp-rest-revisions-controller.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php index 6f559dd2cf343..daaf3efffb05b 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php @@ -596,15 +596,14 @@ 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. * - * @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 ) { - // Restores the more descriptive, specific name for use within this method. - $post = $item; + global $post; /* * Save the previous global post so it can be restored before returning. @@ -613,9 +612,10 @@ public function prepare_item_for_response( $item, $request ) { * is preloaded in the block editor, where a leaked global post can cause * the editor to be initialized with the wrong post). */ - $previous_post = isset( $GLOBALS['post'] ) && $GLOBALS['post'] instanceof WP_Post ? $GLOBALS['post'] : null; + $previous_post = $post instanceof WP_Post ? $post : null; - $GLOBALS['post'] = $post; + // Restores the more descriptive, specific name for use within this method. + $post = $item; setup_postdata( $post ); From d1bfad518cd94eacaee7e10269880c6bb50b3664 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 30 Jul 2026 19:38:19 -0700 Subject: [PATCH 06/15] Simplify truthy check in reset_post_data --- .../rest-api/endpoints/class-wp-rest-revisions-controller.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php index daaf3efffb05b..79ba1555958e1 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php @@ -745,7 +745,7 @@ public function prepare_item_for_response( $item, $request ) { * @param WP_Post|null $previous_post The global post to restore, or null if there was none. */ private function reset_post_data( ?WP_Post $previous_post ): void { - if ( null !== $previous_post ) { + if ( $previous_post ) { $GLOBALS['post'] = $previous_post; setup_postdata( $previous_post ); } else { From f6459500be1c18cece9384de455f40dd5007be77 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 30 Jul 2026 20:32:26 -0700 Subject: [PATCH 07/15] REST API: Pass the revision to rest_prepare_revision before restoring the global post. Importing the global post into prepare_item_for_response() aliased the local `$post` to `$GLOBALS['post']`. Restoring the previous global post therefore wrote through that alias and overwrote `$post` before it was passed to the `rest_prepare_revision` filter, so the filter received the previous global post instead of the revision it documents. This only happened when a global post was set beforehand. Without one, the restore takes the `unset( $GLOBALS['post'] )` branch, which detaches the reference rather than writing through it, leaving `$post` intact. Apply the filter before restoring the global post at both return points. This also keeps the globals that filter callbacks see pointing at the revision, as they did previously. Assert the filter arguments in the existing tests with MockAction. Co-Authored-By: Copilot <198982749+Copilot@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) --- .../class-wp-rest-revisions-controller.php | 14 ++-- .../rest-api/rest-revisions-controller.php | 64 +++++++++++++++++++ 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php index 79ba1555958e1..52df9e410b911 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php @@ -621,10 +621,12 @@ 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 */ + $response = apply_filters( 'rest_prepare_revision', new WP_REST_Response( array() ), $post, $request ); + $this->reset_post_data( $previous_post ); - /** 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 ); + return $response; } $fields = $this->get_fields_for_response( $request ); @@ -717,8 +719,6 @@ public function prepare_item_for_response( $item, $request ) { $response->add_link( 'parent', rest_url( rest_get_route_for_post( $data['parent'] ) ) ); } - $this->reset_post_data( $previous_post ); - /** * Filters a revision returned from the REST API. * @@ -730,7 +730,11 @@ 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->reset_post_data( $previous_post ); + + return $response; } /** diff --git a/tests/phpunit/tests/rest-api/rest-revisions-controller.php b/tests/phpunit/tests/rest-api/rest-revisions-controller.php index c4468b4208025..0d93ee90b8cfc 100644 --- a/tests/phpunit/tests/rest-api/rest-revisions-controller.php +++ b/tests/phpunit/tests/rest-api/rest-revisions-controller.php @@ -291,6 +291,10 @@ public function test_get_items_restores_global_post() { $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.' ); + // Capture the arguments the rest_prepare_revision filter receives. + $mock = new MockAction(); + add_filter( 'rest_prepare_revision', array( $mock, 'filter' ), 10, 3 ); + // Make the request to get revisions. $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions' ); $request->set_param( 'context', 'edit' ); @@ -298,6 +302,13 @@ public function test_get_items_restores_global_post() { $this->assertSame( 200, $response->get_status() ); + // The filter is passed each revision, not the global post restored afterwards. + $this->check_rest_prepare_revision_filter_args( + $mock, + array( $this->revision_id3, $this->revision_id2, $this->revision_id1 ), + $request + ); + // 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.' ); @@ -332,12 +343,19 @@ public function test_get_item_head_request_restores_global_post() { $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.' ); + // Capture the arguments the rest_prepare_revision filter receives. + $mock = new MockAction(); + add_filter( 'rest_prepare_revision', array( $mock, 'filter' ), 10, 3 ); + // Make the HEAD request to get a revision. $request = new WP_REST_Request( 'HEAD', '/wp/v2/posts/' . self::$post_id . '/revisions/' . $this->revision_id1 ); $request->set_param( 'context', 'edit' ); $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); + // The filter is passed the revision, not the global post restored afterwards. + $this->check_rest_prepare_revision_filter_args( $mock, array( $this->revision_id1 ), $request ); + // 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.' ); @@ -362,12 +380,23 @@ public function test_get_items_without_global_post_leaves_it_unset() { $this->assertInstanceOf( WP_Query::class, $wp_query, 'The WP_Query global must be set for wp_reset_postdata() to have anything to restore from.' ); $this->assertNull( get_post(), 'The global post should not have been initially set.' ); + // Capture the arguments the rest_prepare_revision filter receives. + $mock = new MockAction(); + add_filter( 'rest_prepare_revision', array( $mock, 'filter' ), 10, 3 ); + // Make the request to get a revision. $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions' ); $request->set_param( 'context', 'edit' ); $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); + // The filter is passed each revision, even though there is no global post to restore. + $this->check_rest_prepare_revision_filter_args( + $mock, + array( $this->revision_id3, $this->revision_id2, $this->revision_id1 ), + $request + ); + /* * Note: At this point, the global $id is still populated because there was no $wp_query->post to begin with, * so WP_Query::reset_postdata() has nothing to set it to. It does not null out any globals when there is no post. @@ -710,6 +739,33 @@ public function additional_field_update_callback( $value, $post, $field_name ) { update_post_meta( $post->ID, $field_name, $value ); } + /** + * Checks the arguments the rest_prepare_revision filter received. + * + * The filter must be passed the revision that was prepared. Restoring the + * global post afterwards must not replace it with the previous global post. + * + * @param MockAction $mock Mock registered on the rest_prepare_revision filter. + * @param int[] $revision_ids Expected revision IDs, in the order the filter is expected to fire. + * @param WP_REST_Request $request Request the revisions were prepared for. + */ + private function check_rest_prepare_revision_filter_args( MockAction $mock, array $revision_ids, WP_REST_Request $request ): void { + $filter_args = $mock->get_args(); + + $this->assertCount( count( $revision_ids ), $filter_args, 'The rest_prepare_revision filter should fire once per prepared revision.' ); + + foreach ( $filter_args as $index => $args ) { + $call = 'Filter call ' . $index . ': '; + + $this->assertCount( 3, $args, $call . 'the filter should receive three arguments.' ); + $this->assertInstanceOf( WP_REST_Response::class, $args[0], $call . 'the first argument should be the response.' ); + $this->assertInstanceOf( WP_Post::class, $args[1], $call . 'the second argument should be a post object.' ); + $this->assertSame( 'revision', $args[1]->post_type, $call . 'the second argument should be a revision, not the restored global post.' ); + $this->assertSame( $revision_ids[ $index ], $args[1]->ID, $call . 'the second argument should be the revision that was prepared.' ); + $this->assertSame( $request, $args[2], $call . 'the third argument should be the request.' ); + } + } + protected function check_get_revision_response( $response, $revision ) { if ( $response instanceof WP_REST_Response ) { $links = $response->get_links(); @@ -771,10 +827,18 @@ public function test_get_item_sets_up_postdata_without_leaking_global_post() { $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.' ); + // Capture the arguments the rest_prepare_revision filter receives. + $mock = new MockAction(); + add_filter( 'rest_prepare_revision', array( $mock, 'filter' ), 10, 3 ); + // Make the request to get a revision. $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions/' . $this->revision_id1 ); $response = rest_get_server()->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); + + // The filter is passed the revision, not the global post restored afterwards. + $this->check_rest_prepare_revision_filter_args( $mock, array( $this->revision_id1 ), $request ); + $data = $response->get_data(); $this->assertIsArray( $data ); $this->assertArrayHasKey( 'title', $data ); From 96d7001202f3de0c115751cde9b0a5a0476a8fbf Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 30 Jul 2026 20:54:10 -0700 Subject: [PATCH 08/15] REST API: Keep the global post unset when the main query has a post. When there was no global post to restore, reset_post_data() unset the global post and then called wp_reset_postdata(). That order defeated the unset: WP_Query::reset_postdata() assigns `$GLOBALS['post'] = $this->post` whenever the main query has a post, so a request could introduce a global post where there had been none. Reset the post data first, then unset the global post. The reset is still needed to clear the revision from the remaining post data globals. Add a test covering a main query that has a post while the global post is unset, which is the state a request is in before the loop runs. Co-Authored-By: Copilot <198982749+Copilot@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) --- .../class-wp-rest-revisions-controller.php | 12 ++++-- .../rest-api/rest-revisions-controller.php | 40 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php index 52df9e410b911..a0008658fd33a 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php @@ -752,10 +752,16 @@ private function reset_post_data( ?WP_Post $previous_post ): void { if ( $previous_post ) { $GLOBALS['post'] = $previous_post; setup_postdata( $previous_post ); - } else { - unset( $GLOBALS['post'] ); - wp_reset_postdata(); + return; } + + /* + * There was no global post to restore, so clear the revision's post data. + * This runs before unsetting the global post because wp_reset_postdata() + * repopulates it from the main query whenever that query has a post. + */ + wp_reset_postdata(); + unset( $GLOBALS['post'] ); } /** diff --git a/tests/phpunit/tests/rest-api/rest-revisions-controller.php b/tests/phpunit/tests/rest-api/rest-revisions-controller.php index 0d93ee90b8cfc..7882357be3da9 100644 --- a/tests/phpunit/tests/rest-api/rest-revisions-controller.php +++ b/tests/phpunit/tests/rest-api/rest-revisions-controller.php @@ -404,6 +404,46 @@ public function test_get_items_without_global_post_leaves_it_unset() { $this->assertNull( get_post(), 'The global post should not be set when there was none before the request.' ); } + /** + * A main query with a post must not cause a global post to be set where there was none. + * + * The restore calls wp_reset_postdata() to clear the revision's post data, and that + * repopulates the global post from the main query. The global post must be unset + * afterwards so the request does not introduce one that was not there before. + * + * @ticket 65495 + * + * @global WP_Query|null $wp_query The global WP_Query. + * @global int|null $id ID from the set up global post data. + * + * @covers WP_REST_Revisions_Controller::prepare_item_for_response + */ + public function test_get_items_without_global_post_leaves_it_unset_when_main_query_has_post() { + global $wp_query, $id; + + /* + * Populate the main query with the post, but do not run the loop, so the main + * query has a post to restore from while the global post remains unset. + */ + wp_set_current_user( self::$editor_id ); + query_posts( array( 'p' => self::$post_id ) ); + + // Assert initial state. + $this->assertInstanceOf( WP_Query::class, $wp_query, 'The WP_Query global must be set for wp_reset_postdata() to have anything to restore from.' ); + $this->assertInstanceOf( WP_Post::class, $wp_query->post, 'The main query should have a post for wp_reset_postdata() to restore from.' ); + $this->assertSame( self::$post_id, $wp_query->post->ID, 'The main query should have the parent post before the request.' ); + $this->assertNull( get_post(), 'The global post should not have been initially set.' ); + + // Make the request to get revisions. + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions' ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + + $this->assertNull( get_post(), 'The global post should not be set when there was none before the request, even though the main query has a post.' ); + $this->assertSame( self::$post_id, $id, 'The remaining post data should be reset to the main query post rather than left on the revision.' ); + } + /** * @dataProvider data_readable_http_methods * @ticket 56481 From 4bbacdc12e0e391b78d19930f35d86f7919630db Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 30 Jul 2026 21:06:05 -0700 Subject: [PATCH 09/15] REST API: Align the autosave postdata test with the revision ones. Assert the response status and shape before indexing into the data, so a failed request reports the status rather than an undefined index. Set the global post up with query_posts() and the_post() and assert against get_post() and the global `$id`, matching the revision tests. Reading `$GLOBALS['post']` directly passed even when the restore skipped setup_postdata(), leaving the remaining post data globals on the autosave. Co-Authored-By: Copilot <198982749+Copilot@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) --- .../rest-api/rest-autosaves-controller.php | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-autosaves-controller.php b/tests/phpunit/tests/rest-api/rest-autosaves-controller.php index 1ed0927bcf4df..9893fc5bab227 100644 --- a/tests/phpunit/tests/rest-api/rest-autosaves-controller.php +++ b/tests/phpunit/tests/rest-api/rest-autosaves-controller.php @@ -737,28 +737,38 @@ protected function check_get_autosave_response( $response, $autosave ) { * post after the request completes. * * @ticket 65495 + * + * @global int|null $id ID from the set up global post data. */ 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 ); + query_posts( array( 'p' => self::$post_id ) ); + the_post(); - $GLOBALS['post'] = get_post( self::$post_id ); - setup_postdata( $GLOBALS['post'] ); + // 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.' ); + // 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 ); - $data = $response->get_data(); - - $autosave = get_post( self::$autosave_post_id ); - - // The rendered title reflects the autosave, proving postdata was set up during preparation. - $this->assertSame( get_the_title( $autosave->ID ), $data['title']['rendered'] ); + $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. - $this->assertSame( - self::$post_id, - $GLOBALS['post']->ID, - 'The global post should not leak the autosave after 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() { From d7c23d3187756389782d4a19b7162104b88ea391 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 30 Jul 2026 21:39:14 -0700 Subject: [PATCH 10/15] REST API: Keep the revision local while preparing the response. Declaring `global $post` in `WP_REST_Revisions_Controller::prepare_item_for_response()` aliased the method's `$post` to the global. A filter that reassigned the global post while the response was being prepared -- as a plugin running a secondary loop on `the_content` may do -- therefore retargeted every field read afterwards, including `excerpt`, `meta`, the `parent` link, and the post passed to `rest_prepare_revision`. Restore the method-local `$post` and assign the global separately, as before, so that capturing the previous global post for later restoration no longer changes which post the remaining fields are read from. Add a regression test that swaps the global post on `the_content` and asserts the prepared fields and the `rest_prepare_revision` argument still come from the revision. Co-Authored-By: Claude Opus 5 (1M context) --- .../class-wp-rest-revisions-controller.php | 11 +++-- .../rest-api/rest-revisions-controller.php | 49 +++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php index a0008658fd33a..90fdc2aefb491 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php @@ -603,20 +603,25 @@ protected function prepare_items_query( $prepared_args = array(), $request = nul * @return WP_REST_Response Response object. */ public function prepare_item_for_response( $item, $request ) { - global $post; - /* * 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 = $post instanceof WP_Post ? $post : null; + $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; + $GLOBALS['post'] = $post; + setup_postdata( $post ); // Don't prepare the response body for HEAD requests. diff --git a/tests/phpunit/tests/rest-api/rest-revisions-controller.php b/tests/phpunit/tests/rest-api/rest-revisions-controller.php index 7882357be3da9..efb16056f67fb 100644 --- a/tests/phpunit/tests/rest-api/rest-revisions-controller.php +++ b/tests/phpunit/tests/rest-api/rest-revisions-controller.php @@ -444,6 +444,55 @@ public function test_get_items_without_global_post_leaves_it_unset_when_main_que $this->assertSame( self::$post_id, $id, 'The remaining post data should be reset to the main query post rather than left on the revision.' ); } + /** + * A filter that reassigns the global post must not change the revision being prepared. + * + * The revision is held in a method-local variable rather than the global post, so a + * filter which swaps the global out mid-preparation (as a plugin running a secondary + * loop on 'the_content' may do) cannot retarget the fields prepared after it. + * + * @ticket 65495 + * + * @covers WP_REST_Revisions_Controller::prepare_item_for_response + */ + public function test_prepare_item_for_response_is_unaffected_by_a_filter_reassigning_the_global_post() { + wp_set_current_user( self::$editor_id ); + + $decoy_post = get_post( self::$post_id_2 ); + $this->assertInstanceOf( WP_Post::class, $decoy_post ); + $this->assertNotSame( $this->revision_1->post_excerpt, $decoy_post->post_excerpt, 'The decoy post must have a different excerpt for this test to be meaningful.' ); + + // Simulate a plugin that leaves a different post in the global while filtering the content. + add_filter( + 'the_content', + static function ( $content ) use ( $decoy_post ) { + $GLOBALS['post'] = $decoy_post; + return $content; + } + ); + + // Capture the post the rest_prepare_revision filter receives. + $mock = new MockAction(); + add_filter( 'rest_prepare_revision', array( $mock, 'filter' ), 10, 3 ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions/' . $this->revision_id1 ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + // Fields prepared after 'the_content' still come from the revision. + $data = $response->get_data(); + $this->assertIsArray( $data ); + $this->assertArrayHasKey( 'excerpt', $data ); + $this->assertIsArray( $data['excerpt'] ); + $this->assertSame( $this->revision_id1, $data['id'], 'The prepared id should be the revision, not the post left in the global by the filter.' ); + $this->assertSame( $this->revision_1->post_excerpt, $data['excerpt']['raw'], 'The prepared excerpt should come from the revision, not the post left in the global by the filter.' ); + + // The filter is still passed the revision. + $this->check_rest_prepare_revision_filter_args( $mock, array( $this->revision_id1 ), $request ); + } + /** * @dataProvider data_readable_http_methods * @ticket 56481 From 2d1eff56afcff20fde2551124eee537e93e0bc98 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 30 Jul 2026 21:41:53 -0700 Subject: [PATCH 11/15] REST API: Document the restored global post with an @since tag. `WP_REST_Revisions_Controller::prepare_item_for_response()` now restores the global post before returning rather than leaving the revision in place. Record that behavior change in the docblock, per the documentation standards for changed behavior. Co-Authored-By: Claude Opus 5 (1M context) --- .../rest-api/endpoints/class-wp-rest-revisions-controller.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php index 90fdc2aefb491..4d1fcf9c8abb9 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php @@ -595,6 +595,7 @@ 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|null $post Global post object. * From 36b74d40a3fcc91c14db93cfa543e962a333125d Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 30 Jul 2026 21:44:54 -0700 Subject: [PATCH 12/15] REST API: Rename reset_post_data() to restore_post_data(). The method restores the global post that was in place before the revision was prepared, so `reset` described the opposite of its main branch and read confusingly next to core's `wp_reset_postdata()`, which it calls for the unrelated case where there was no previous global post to restore. The method is private, so there is no external effect. Co-Authored-By: Claude Opus 5 (1M context) --- .../endpoints/class-wp-rest-revisions-controller.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php index 4d1fcf9c8abb9..db7a8a0502db8 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php @@ -630,7 +630,7 @@ public function prepare_item_for_response( $item, $request ) { /** This filter is documented in wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php */ $response = apply_filters( 'rest_prepare_revision', new WP_REST_Response( array() ), $post, $request ); - $this->reset_post_data( $previous_post ); + $this->restore_post_data( $previous_post ); return $response; } @@ -738,7 +738,7 @@ public function prepare_item_for_response( $item, $request ) { */ $response = apply_filters( 'rest_prepare_revision', $response, $post, $request ); - $this->reset_post_data( $previous_post ); + $this->restore_post_data( $previous_post ); return $response; } @@ -754,7 +754,7 @@ public function prepare_item_for_response( $item, $request ) { * * @param WP_Post|null $previous_post The global post to restore, or null if there was none. */ - private function reset_post_data( ?WP_Post $previous_post ): void { + private function restore_post_data( ?WP_Post $previous_post ): void { if ( $previous_post ) { $GLOBALS['post'] = $previous_post; setup_postdata( $previous_post ); From 100f4e6fdf6411e218ab195be672a580a4e9264c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 30 Jul 2026 21:48:16 -0700 Subject: [PATCH 13/15] REST API: Add missing @covers tags to the postdata tests. The two `test_get_item_sets_up_postdata_without_leaking_global_post()` tests were the only ones added for this ticket without a `@covers` tag. Also move `@ticket` above `@global` in the revisions test so the tag order matches the surrounding tests. Co-Authored-By: Claude Opus 5 (1M context) --- tests/phpunit/tests/rest-api/rest-autosaves-controller.php | 2 ++ tests/phpunit/tests/rest-api/rest-revisions-controller.php | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/tests/rest-api/rest-autosaves-controller.php b/tests/phpunit/tests/rest-api/rest-autosaves-controller.php index 9893fc5bab227..179ce60e047df 100644 --- a/tests/phpunit/tests/rest-api/rest-autosaves-controller.php +++ b/tests/phpunit/tests/rest-api/rest-autosaves-controller.php @@ -739,6 +739,8 @@ protected function check_get_autosave_response( $response, $autosave ) { * @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; diff --git a/tests/phpunit/tests/rest-api/rest-revisions-controller.php b/tests/phpunit/tests/rest-api/rest-revisions-controller.php index efb16056f67fb..f288fb765c5c9 100644 --- a/tests/phpunit/tests/rest-api/rest-revisions-controller.php +++ b/tests/phpunit/tests/rest-api/rest-revisions-controller.php @@ -898,9 +898,11 @@ protected function check_get_revision_response( $response, $revision ) { * so rendered fields reflect the revision, without leaking into the global * post after the request completes. * + * @ticket 65495 + * * @global int|null $id ID from the set up global post data. * - * @ticket 65495 + * @covers WP_REST_Revisions_Controller::prepare_item_for_response */ public function test_get_item_sets_up_postdata_without_leaking_global_post() { global $id; From 0629a287154d7a800b4a375ca8feee5854f5255c Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 30 Jul 2026 21:59:03 -0700 Subject: [PATCH 14/15] REST API: Document what restore_post_data() does not restore. 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()` is a no-op, so the remaining globals set by `setup_postdata()` -- among them `$id`, `$authordata` and `$pages` -- are left describing the revision. The global post is what leaked into the block editor, and it is now cleared. Record the rest of the behavior in the docblock rather than leaving it noted only in a test comment, so the limitation is visible to anyone reading the method. Co-Authored-By: Claude Opus 5 (1M context) --- .../endpoints/class-wp-rest-revisions-controller.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php index db7a8a0502db8..b35f80e6250a2 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php @@ -750,6 +750,14 @@ public function prepare_item_for_response( $item, $request ) { * 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. @@ -764,7 +772,9 @@ private function restore_post_data( ?WP_Post $previous_post ): void { /* * There was no global post to restore, so clear the revision's post data. * This runs before unsetting the global post because wp_reset_postdata() - * repopulates it from the main query whenever that query has a post. + * 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(); unset( $GLOBALS['post'] ); From 7712697c01f1d92feb41911de8ba24b1a9694ba8 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 30 Jul 2026 22:09:08 -0700 Subject: [PATCH 15/15] REST API: Clear the global post by assignment rather than unsetting it. `global $post` binds a caller to the global symbol table entry. Unsetting that entry removes it from the symbol table, which detaches every binding made beforehand: the holder keeps a value that is no longer the global, so a later write through it is invisible to `get_post()`. That path is reachable. A caller's own `global $post` creates the entry as null, which is not a `WP_Post`, so there is no previous global post to restore and the clearing branch runs. Assigning null instead leaves the entry in place and keeps the binding intact, while `get_post()` still returns null because `isset()` is false for null. Add a regression test covering a caller that binds before the request and sets the post afterwards. Co-Authored-By: Claude Opus 5 (1M context) --- .../class-wp-rest-revisions-controller.php | 11 ++++-- .../rest-api/rest-revisions-controller.php | 35 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php index b35f80e6250a2..0121298105ebf 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php @@ -771,13 +771,20 @@ private function restore_post_data( ?WP_Post $previous_post ): void { /* * There was no global post to restore, so clear the revision's post data. - * This runs before unsetting the global post because wp_reset_postdata() + * 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(); - unset( $GLOBALS['post'] ); + + /* + * 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; } /** diff --git a/tests/phpunit/tests/rest-api/rest-revisions-controller.php b/tests/phpunit/tests/rest-api/rest-revisions-controller.php index f288fb765c5c9..71b333fa39fd4 100644 --- a/tests/phpunit/tests/rest-api/rest-revisions-controller.php +++ b/tests/phpunit/tests/rest-api/rest-revisions-controller.php @@ -493,6 +493,41 @@ static function ( $content ) use ( $decoy_post ) { $this->check_rest_prepare_revision_filter_args( $mock, array( $this->revision_id1 ), $request ); } + /** + * Clearing the global post must not detach an existing `global $post` binding. + * + * `global $post` binds a caller to the global symbol table entry. Unsetting that + * entry detaches the binding, so a caller which sets the post after the request + * would be writing somewhere get_post() can no longer see. Note that the caller's + * own `global $post` is what creates the entry as null, which is why there is no + * previous global post to restore here. + * + * @ticket 65495 + * + * @global WP_Post|null $post Global post object. + * + * @covers WP_REST_Revisions_Controller::prepare_item_for_response + */ + public function test_prepare_item_for_response_does_not_detach_an_existing_global_post_binding() { + // Bind to the global post the way a caller does before dispatching a request. + global $post; + + wp_set_current_user( self::$editor_id ); + $this->assertNull( $post, 'The global post should not be set before the request.' ); + + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . self::$post_id . '/revisions/' . $this->revision_id1 ); + $request->set_param( 'context', 'edit' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + + // The caller sets the global post, expecting template tags to pick it up. + $post = get_post( self::$post_id ); + + $global_post = get_post(); + $this->assertInstanceOf( WP_Post::class, $global_post, 'get_post() should see the post set through the binding after the request.' ); + $this->assertSame( self::$post_id, $global_post->ID, 'get_post() should return the post the caller set, not a stale or detached value.' ); + } + /** * @dataProvider data_readable_http_methods * @ticket 56481