Commit 783a0b4cd2f for woocommerce

commit 783a0b4cd2f08a9d5f88a3393e41e7d5d8bc1f2a
Author: Alba Rincón <albarin@users.noreply.github.com>
Date:   Tue Apr 14 09:51:22 2026 +0200

    REST API: reject orders endpoint updates against non-shop_order IDs (#64050)

    * REST API: reject orders endpoint updates against non-shop_order IDs

    * Add PHPStan generic type to update_item param

    * Remove accidentally tracked local PR description

    * Add changefile(s) from automation for the following project(s): woocommerce

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/64050-fix-63936-orders-rest-type-guard b/plugins/woocommerce/changelog/64050-fix-63936-orders-rest-type-guard
new file mode 100644
index 00000000000..77f6af0c7b7
--- /dev/null
+++ b/plugins/woocommerce/changelog/64050-fix-63936-orders-rest-type-guard
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+REST API: prevent PUT /wc/v(2|3)/orders/{id} from converting non-shop_order records into orders.
\ No newline at end of file
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php
index 0f156c654f6..024b0566782 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller.php
@@ -695,6 +695,25 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
 		return empty( $schema['readonly'] );
 	}

+	/**
+	 * Update a single order.
+	 *
+	 * Rejects IDs whose underlying type isn't shop_order (e.g. shop_subscription) to avoid
+	 * silently converting them on save. Mirrors the upfront type check already performed by
+	 * WC_REST_Orders_V1_Controller::update_item().
+	 *
+	 * @param WP_REST_Request<array<string, mixed>> $request Full details about the request.
+	 * @return WP_Error|WP_REST_Response
+	 */
+	public function update_item( $request ) {
+		$id = (int) $request['id'];
+		if ( empty( $id ) || OrderUtil::get_order_type( $id ) !== $this->post_type ) {
+			return new WP_Error( "woocommerce_rest_{$this->post_type}_invalid_id", __( 'ID is invalid.', 'woocommerce' ), array( 'status' => 400 ) );
+		}
+
+		return parent::update_item( $request );
+	}
+
 	/**
 	 * Prepare a single order for create or update.
 	 *
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller-tests.php
index 4fcc546e9fa..58fea1ad66b 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-orders-controller-tests.php
@@ -317,6 +317,70 @@ class WC_REST_Orders_Controller_Tests extends WC_REST_Unit_Test_Case {
 		$this->assertEquals( 'rest-api', $order->get_created_via() );
 	}

+	/**
+	 * PUT against an ID belonging to a non 'shop_order' post type must be rejected, never
+	 * silently converted.
+	 */
+	public function test_update_rejects_non_shop_order_post_type(): void {
+		wc_register_order_type( 'shop_test' );
+		$post_id = wp_insert_post(
+			array(
+				'post_type'   => 'shop_test',
+				'post_status' => 'wc-pending',
+				'post_title'  => 'test',
+			)
+		);
+
+		$request = new \WP_REST_Request( 'PUT', '/wc/v3/orders/' . $post_id );
+		$request->set_body_params( array( 'customer_note' => 'should not apply' ) );
+
+		$response = $this->server->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertSame( 400, $response->get_status() );
+		$this->assertArrayHasKey( 'code', $data );
+		$this->assertSame( 'woocommerce_rest_shop_order_invalid_id', $data['code'] );
+
+		// The persisted record must be untouched: same post type, no added customer_note.
+		$this->assertSame( 'shop_test', get_post_type( $post_id ) );
+		$this->assertSame( '', (string) get_post_meta( $post_id, '_customer_note', true ) );
+
+		unregister_post_type( 'shop_test' );
+	}
+
+	/**
+	 * PUT against a non-existent ID returns the standard invalid-id error.
+	 */
+	public function test_update_rejects_nonexistent_id(): void {
+		$request = new \WP_REST_Request( 'PUT', '/wc/v3/orders/999999999' );
+		$request->set_body_params( array( 'customer_note' => 'irrelevant' ) );
+
+		$response = $this->server->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertSame( 400, $response->get_status() );
+		$this->assertArrayHasKey( 'code', $data );
+		$this->assertSame( 'woocommerce_rest_shop_order_invalid_id', $data['code'] );
+	}
+
+	/**
+	 * The type-mismatch override must still delegate normal shop_order updates to the
+	 * parent controller and apply the request body.
+	 */
+	public function test_update_shop_order_passes_type_guard_and_applies_changes(): void {
+		$order = new \WC_Order();
+		$order->set_customer_note( 'before' );
+		$order->save();
+
+		$request = new \WP_REST_Request( 'PUT', '/wc/v3/orders/' . $order->get_id() );
+		$request->set_body_params( array( 'customer_note' => 'after' ) );
+
+		$response = $this->server->dispatch( $request );
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( 'after', wc_get_order( $order->get_id() )->get_customer_note() );
+	}
+
 	/**
 	 * Tests that the created_via parameter cannot be updated.
 	 */