From a4ec0999a9aa76cd2c7940ec479ff8b7fdbca50d Mon Sep 17 00:00:00 2001 From: wppoland Date: Sun, 3 May 2026 16:56:12 +0200 Subject: [PATCH] REST API: Restore global `$post` state in `WP_REST_Posts_Controller::prepare_item_for_response()`. Previously, this method overwrote `$GLOBALS['post']` and called `setup_postdata()` without restoring the previous state. Anything running after a REST request that re-used the controller (block rendering, the_content filters in subsequent calls, etc.) saw the wrong global post. This commit captures the previous global before mutation and restores it on every return path, including the early HEAD-request return. Adds tests covering: * Restoration when a global post is already set. * Restoration to `null` when no global post was set. * Restoration on the HEAD-method early-return branch. Complements GH-1165 and GH-11455. See #43502. --- .../class-wp-rest-posts-controller.php | 60 ++++++++++++++++- .../tests/rest-api/rest-posts-controller.php | 67 +++++++++++++++++++ 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php index ee3e6b4959869..eddd34a922231 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php @@ -1875,8 +1875,9 @@ protected function check_delete_permission( $post ) { * * @since 4.7.0 * @since 5.9.0 Renamed `$post` to `$item` to match parent class for PHP 8 named parameter support. + * @since 7.2.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 object. * @param WP_REST_Request $request Request object. @@ -1886,6 +1887,7 @@ public function prepare_item_for_response( $item, $request ) { // Restores the more descriptive, specific name for use within this method. $post = $item; + $previous_post = isset( $GLOBALS['post'] ) && $GLOBALS['post'] instanceof WP_Post ? $GLOBALS['post'] : null; $GLOBALS['post'] = $post; setup_postdata( $post ); @@ -1893,7 +1895,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-posts-controller.php */ - return apply_filters( "rest_prepare_{$this->post_type}", new WP_REST_Response( array() ), $post, $request ); + $response = apply_filters( "rest_prepare_{$this->post_type}", new WP_REST_Response( array() ), $post, $request ); + + $this->restore_post_data( $previous_post ); + + return $response; } $fields = $this->get_fields_for_response( $request ); @@ -2196,7 +2202,55 @@ public function prepare_item_for_response( $item, $request ) { * @param WP_Post $post Post object. * @param WP_REST_Request $request Request object. */ - return apply_filters( "rest_prepare_{$this->post_type}", $response, $post, $request ); + $response = apply_filters( "rest_prepare_{$this->post_type}", $response, $post, $request ); + + $this->restore_post_data( $previous_post ); + + return $response; + } + + /** + * Restores the global post to its previous value after preparing a post. + * + * Preparing a post 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 post. Clearing + * those would mean unsetting each one by hand, which is beyond what is + * needed to keep the global post from leaking. + * + * @since 7.2.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 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; } /** diff --git a/tests/phpunit/tests/rest-api/rest-posts-controller.php b/tests/phpunit/tests/rest-api/rest-posts-controller.php index e301a1c44a546..f9e5aef293ce8 100644 --- a/tests/phpunit/tests/rest-api/rest-posts-controller.php +++ b/tests/phpunit/tests/rest-api/rest-posts-controller.php @@ -2805,6 +2805,73 @@ function ( $classes ) { $this->assertTrue( array_is_list( $data['class_list'] ), 'Expected class_list to be a list.' ); } + /** + * @ticket 43502 + * + * @covers WP_REST_Posts_Controller::prepare_item_for_response + */ + public function test_prepare_item_for_response_restores_global_post() { + $post_1 = self::factory()->post->create_and_get(); + $post_2 = self::factory()->post->create_and_get(); + + // Set up a known global $post state. + $GLOBALS['post'] = $post_1; + setup_postdata( $post_1 ); + + $endpoint = new WP_REST_Posts_Controller( 'post' ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . $post_2->ID ); + $endpoint->prepare_item_for_response( $post_2, $request ); + + $this->assertSame( + $post_1->ID, + $GLOBALS['post']->ID, + 'Global $post should be restored after prepare_item_for_response().' + ); + } + + /** + * @ticket 43502 + * + * @covers WP_REST_Posts_Controller::prepare_item_for_response + */ + public function test_prepare_item_for_response_restores_null_global_post() { + unset( $GLOBALS['post'] ); + + $post = self::factory()->post->create_and_get(); + + $endpoint = new WP_REST_Posts_Controller( 'post' ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts/' . $post->ID ); + $endpoint->prepare_item_for_response( $post, $request ); + + $this->assertNull( + $GLOBALS['post'], + 'Global $post should be restored to null when it was not set before prepare_item_for_response().' + ); + } + + /** + * @ticket 43502 + * + * @covers WP_REST_Posts_Controller::prepare_item_for_response + */ + public function test_prepare_item_for_response_restores_global_post_on_head_request() { + $post_1 = self::factory()->post->create_and_get(); + $post_2 = self::factory()->post->create_and_get(); + + $GLOBALS['post'] = $post_1; + setup_postdata( $post_1 ); + + $endpoint = new WP_REST_Posts_Controller( 'post' ); + $request = new WP_REST_Request( 'HEAD', '/wp/v2/posts/' . $post_2->ID ); + $endpoint->prepare_item_for_response( $post_2, $request ); + + $this->assertSame( + $post_1->ID, + $GLOBALS['post']->ID, + 'Global $post should be restored after a HEAD request to prepare_item_for_response().' + ); + } + public function test_create_item() { wp_set_current_user( self::$editor_id );