From 306a3ad1276fe89ee3f015e50a7cb0943106e76f Mon Sep 17 00:00:00 2001 From: Kamran Abdul Aziz Date: Sun, 1 Nov 2026 15:41:34 +0530 Subject: [PATCH] Widgets: Reject REST widget IDs that end in a newline or match no widget. wp_parse_widget_id() anchored its pattern with $, which also matches before a final newline, so "text-1\n" was parsed as text-1. Creating a widget through the REST API with that ID saved settings to text-1 and added "text-1\n" to the sidebar before failing with a 500 error. WP_REST_Widgets_Controller::update_item() also let an ID that matches no registered widget through when the widget is in no sidebar, so a request with only a sidebar parameter added that ID to the sidebar before failing. Fixing only the parser would send "text-1\n" down that path. The pattern now uses \z, and update_item() returns a 404 error when the widget is in no sidebar and its ID is not registered. Fixes #66434. --- .../class-wp-rest-widgets-controller.php | 7 +- src/wp-includes/widgets.php | 2 +- .../rest-api/rest-widgets-controller.php | 108 ++++++++++++++++++ .../phpunit/tests/widgets/wpParseWidgetId.php | 10 ++ 4 files changed, 123 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-widgets-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-widgets-controller.php index 9b25298c347ba..2ac1400503d48 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-widgets-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-widgets-controller.php @@ -289,12 +289,13 @@ public function update_item_permissions_check( $request ) { * @since 5.8.0 * * @global WP_Widget_Factory $wp_widget_factory + * @global array $wp_registered_widgets The registered widgets. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function update_item( $request ) { - global $wp_widget_factory; + global $wp_widget_factory, $wp_registered_widgets; /* * retrieve_widgets() contains logic to move "hidden" or "lost" widgets to the @@ -312,10 +313,10 @@ public function update_item( $request ) { $widget_id = $request['id']; $sidebar_id = wp_find_widgets_sidebar( $widget_id ); - // Allow sidebar to be unset or missing when widget is not a WP_Widget. + // Allow sidebar to be unset or missing for a registered widget that is not a WP_Widget. $parsed_id = wp_parse_widget_id( $widget_id ); $widget_object = $wp_widget_factory->get_widget_object( $parsed_id['id_base'] ); - if ( is_null( $sidebar_id ) && $widget_object ) { + if ( is_null( $sidebar_id ) && ( $widget_object || ! isset( $wp_registered_widgets[ $widget_id ] ) ) ) { return new WP_Error( 'rest_widget_not_found', __( 'No widget was found with that id.' ), diff --git a/src/wp-includes/widgets.php b/src/wp-includes/widgets.php index 31fce41b65954..6ca14be455ec2 100644 --- a/src/wp-includes/widgets.php +++ b/src/wp-includes/widgets.php @@ -1941,7 +1941,7 @@ function wp_use_widgets_block_editor() { function wp_parse_widget_id( $id ) { $parsed = array(); - if ( preg_match( '/^(.+)-(\d+)$/', $id, $matches ) ) { + if ( preg_match( '/^(.+)-(\d+)\z/', $id, $matches ) ) { $parsed['id_base'] = $matches[1]; $parsed['number'] = (int) $matches[2]; } else { diff --git a/tests/phpunit/tests/rest-api/rest-widgets-controller.php b/tests/phpunit/tests/rest-api/rest-widgets-controller.php index a091847bab211..0fc57d8289480 100644 --- a/tests/phpunit/tests/rest-api/rest-widgets-controller.php +++ b/tests/phpunit/tests/rest-api/rest-widgets-controller.php @@ -1137,6 +1137,51 @@ public function test_create_item_second_instance() { ); } + /** + * @ticket 66434 + */ + public function test_create_item_rejects_id_with_trailing_newline() { + $this->setup_widget( + 'text', + 1, + array( + 'text' => 'Custom text test', + ) + ); + $this->setup_sidebar( + 'sidebar-1', + array( + 'name' => 'Test sidebar', + ), + array( 'text-1' ) + ); + $this->setup_sidebar( + 'sidebar-2', + array( + 'name' => 'Test sidebar 2', + ) + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/widgets' ); + $request->set_body_params( + array( + 'id' => "text-1\n", + 'sidebar' => 'sidebar-2', + 'instance' => array( + 'raw' => array( + 'text' => 'Updated text test', + ), + ), + ) + ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_widget', $response, 400 ); + $this->assertSame( 'Custom text test', get_option( 'widget_text' )[1]['text'] ); + $this->assertSame( array( 'text-1' ), get_option( 'sidebars_widgets' )['sidebar-1'] ); + $this->assertSame( array(), get_option( 'sidebars_widgets' )['sidebar-2'] ); + } + /** * @ticket 41683 */ @@ -1415,6 +1460,69 @@ public function test_create_item_legacy_widget() { ); } + /** + * @ticket 66434 + */ + public function test_update_item_unknown_id_returns_404() { + $this->setup_sidebar( + 'sidebar-1', + array( + 'name' => 'Test sidebar', + ) + ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/widgets/unknown-3' ); + $request->set_body_params( + array( + 'sidebar' => 'sidebar-1', + ) + ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_widget_not_found', $response, 404 ); + $this->assertSame( array(), get_option( 'sidebars_widgets' )['sidebar-1'] ); + } + + /** + * @ticket 66434 + */ + public function test_update_item_rejects_id_with_trailing_newline() { + $this->setup_widget( + 'text', + 1, + array( + 'text' => 'Custom text test', + ) + ); + $this->setup_sidebar( + 'sidebar-1', + array( + 'name' => 'Test sidebar', + ), + array( 'text-1' ) + ); + $this->setup_sidebar( + 'sidebar-2', + array( + 'name' => 'Test sidebar 2', + ) + ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/widgets/text-1' ); + $request->set_body_params( + array( + 'id' => "text-1\n", + 'sidebar' => 'sidebar-2', + ) + ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_widget_not_found', $response, 404 ); + $this->assertSame( 'Custom text test', get_option( 'widget_text' )[1]['text'] ); + $this->assertSame( array( 'text-1' ), get_option( 'sidebars_widgets' )['sidebar-1'] ); + $this->assertSame( array(), get_option( 'sidebars_widgets' )['sidebar-2'] ); + } + /** * @ticket 41683 */ diff --git a/tests/phpunit/tests/widgets/wpParseWidgetId.php b/tests/phpunit/tests/widgets/wpParseWidgetId.php index 4b751d7b41482..c02c7548d1f1e 100644 --- a/tests/phpunit/tests/widgets/wpParseWidgetId.php +++ b/tests/phpunit/tests/widgets/wpParseWidgetId.php @@ -44,4 +44,14 @@ public function test_returns_only_id_base_without_number() { wp_parse_widget_id( 'archives' ) ); } + + /** + * @ticket 66434 + */ + public function test_does_not_parse_number_before_trailing_newline() { + $this->assertSame( + array( 'id_base' => "text-2\n" ), + wp_parse_widget_id( "text-2\n" ) + ); + } }