Commit 27aeffd5521 for woocommerce

commit 27aeffd5521881f0b31cf60bf74828930b599cea
Author: Sergey Mitroshin <sergeymitr@gmail.com>
Date:   Wed Nov 4 19:51:16 2026 -0500

    UCP: Read input from the JSON body only, refuse other content types (#70678)

    Read UCP input from the JSON only.

diff --git a/plugins/woocommerce/changelog/fix-ucp-json-only-requests b/plugins/woocommerce/changelog/fix-ucp-json-only-requests
new file mode 100644
index 00000000000..9e1819bd71f
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-ucp-json-only-requests
@@ -0,0 +1,3 @@
+Significance: patch
+Type: fix
+Comment: Read UCP input from the JSON only and refuse other content types.
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCartRoute.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCartRoute.php
index 605a7a7718a..344710b91f3 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCartRoute.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCartRoute.php
@@ -67,6 +67,11 @@ abstract class AbstractCartRoute extends StoreApiCartRoute {
 	 * @return \WP_REST_Response
 	 */
 	public function get_response( \WP_REST_Request $request ) {
+		$refusal = UcpUtils::refuse_non_json_payload( $request, UcpUtils::CAPABILITY_CART );
+		if ( null !== $refusal ) {
+			return $this->add_response_headers( $refusal );
+		}
+
 		if ( '' !== $this->route_cart_id( $request ) && '' === $this->validated_cart_id( $request ) ) {
 			return $this->add_response_headers( $this->ucp_not_found() );
 		}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCatalogRoute.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCatalogRoute.php
index 7f6769625b0..f4313823ae2 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCatalogRoute.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCatalogRoute.php
@@ -60,6 +60,19 @@ abstract class AbstractCatalogRoute extends AbstractRoute {
 		);
 	}

+	/**
+	 * Refuse a non-JSON payload before the handler reads anything.
+	 *
+	 * @param \WP_REST_Request $request Request object.
+	 *
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @return \WP_REST_Response
+	 */
+	public function get_response( \WP_REST_Request $request ) {
+		return UcpUtils::refuse_non_json_payload( $request, $this->get_capability() ) ?? parent::get_response( $request );
+	}
+
 	/**
 	 * Request body arguments accepted by this route.
 	 *
@@ -112,7 +125,7 @@ abstract class AbstractCatalogRoute extends AbstractRoute {
 	 * @return array
 	 */
 	protected function get_context( \WP_REST_Request $request ): array {
-		$raw = $request->get_param( 'context' );
+		$raw = UcpUtils::json_param( $request, 'context' );

 		if ( ! is_array( $raw ) ) {
 			return array();
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/Carts.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/Carts.php
index 9756888432f..7ef51f3d15e 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/Carts.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/Carts.php
@@ -3,6 +3,8 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\StoreApi\Routes\V1\Ucp;

+use Automattic\WooCommerce\StoreApi\Utilities\UcpUtils;
+
 /**
  * UCP `POST /carts` route.
  */
@@ -67,7 +69,7 @@ class Carts extends AbstractCartRoute {
 	 * @return \WP_REST_Response
 	 */
 	protected function get_route_post_response( \WP_REST_Request $request ) {
-		$line_items = $request->get_param( 'line_items' );
+		$line_items = UcpUtils::json_param( $request, 'line_items' );

 		if ( null !== $line_items ) {
 			$desired = $this->parse_line_items( $line_items );
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CartsById.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CartsById.php
index 14e44025271..31eade8ffa1 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CartsById.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CartsById.php
@@ -3,6 +3,8 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\StoreApi\Routes\V1\Ucp;

+use Automattic\WooCommerce\StoreApi\Utilities\UcpUtils;
+
 /**
  * UCP `GET /carts/{id}` and `PUT /carts/{id}` route.
  */
@@ -86,7 +88,7 @@ class CartsById extends AbstractCartRoute {
 	 * @return \WP_REST_Response
 	 */
 	protected function get_route_update_response( \WP_REST_Request $request ) {
-		$line_items = $request->get_param( 'line_items' );
+		$line_items = UcpUtils::json_param( $request, 'line_items' );

 		// `line_items` is the whole desired state, so omitting it is not the same as
 		// sending an empty list: it leaves the cart alone.
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CatalogLookup.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CatalogLookup.php
index 6fe919932bb..5bf69f88559 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CatalogLookup.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CatalogLookup.php
@@ -84,7 +84,7 @@ class CatalogLookup extends AbstractCatalogRoute {
 	 * @return \WP_REST_Response
 	 */
 	protected function get_route_post_response( \WP_REST_Request $request ) {
-		$raw_ids = $request->get_param( 'ids' );
+		$raw_ids = UcpUtils::json_param( $request, 'ids' );

 		if ( ! is_array( $raw_ids ) || empty( $raw_ids ) ) {
 			return $this->ucp_error( 'VALIDATION_ERROR', __( 'ids is required.', 'woocommerce' ), 'recoverable', 400 );
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CatalogProduct.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CatalogProduct.php
index 5e34b8aa43b..dddeb507e41 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CatalogProduct.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CatalogProduct.php
@@ -84,13 +84,13 @@ class CatalogProduct extends AbstractCatalogRoute {
 	 * @return \WP_REST_Response
 	 */
 	protected function get_route_post_response( \WP_REST_Request $request ) {
-		$id = trim( strval( $request->get_param( 'id' ) ) );
+		$id = trim( strval( UcpUtils::json_param( $request, 'id' ) ) );

 		if ( '' === $id ) {
 			return $this->ucp_error( 'VALIDATION_ERROR', __( 'id is required.', 'woocommerce' ), 'recoverable', 400 );
 		}

-		$selected = (array) ( $request->get_param( 'selected' ) ?? array() );
+		$selected = (array) ( UcpUtils::json_param( $request, 'selected' ) ?? array() );

 		// Checked before the product is loaded: an over-long selection is refused
 		// without paying for the lookup it would have been applied to.
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CatalogSearch.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CatalogSearch.php
index 32dabbbbf02..22764fd3274 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CatalogSearch.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CatalogSearch.php
@@ -99,9 +99,9 @@ class CatalogSearch extends AbstractCatalogRoute {
 	 * @return \WP_REST_Response
 	 */
 	protected function get_route_post_response( \WP_REST_Request $request ) {
-		$query      = trim( (string) $request->get_param( 'query' ) );
-		$filters    = (array) ( $request->get_param( 'filters' ) ?? array() );
-		$pagination = (array) ( $request->get_param( 'pagination' ) ?? array() );
+		$query      = trim( (string) UcpUtils::json_param( $request, 'query' ) );
+		$filters    = (array) ( UcpUtils::json_param( $request, 'filters' ) ?? array() );
+		$pagination = (array) ( UcpUtils::json_param( $request, 'pagination' ) ?? array() );
 		$context    = $this->get_context( $request );

 		$invalid = $this->validate_search_input( $query, $filters );
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/UcpUtils.php b/plugins/woocommerce/src/StoreApi/Utilities/UcpUtils.php
index 9f77520eaf7..70b142d40d9 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/UcpUtils.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/UcpUtils.php
@@ -132,6 +132,50 @@ class UcpUtils {
 		);
 	}

+	/**
+	 * Refuse a request that brought a body or form fields under anything other than a JSON content type.
+	 *
+	 * A UCP request is a JSON document, so a form or query payload is never read in its
+	 * place. A bare request, such as a `cancel`, carries nothing and is not refused.
+	 *
+	 * @param \WP_REST_Request $request Request object.
+	 * @param string           $capability One of the CAPABILITY_* constants.
+	 *
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @return \WP_REST_Response|null The 415 refusal, or null when the request may be read.
+	 */
+	public static function refuse_non_json_payload( \WP_REST_Request $request, string $capability ): ?\WP_REST_Response {
+		if ( $request->is_json_content_type() ) {
+			return null;
+		}
+
+		if ( '' === trim( (string) $request->get_body() ) && array() === $request->get_body_params() ) {
+			return null;
+		}
+
+		return self::error_response( $capability, 'VALIDATION_ERROR', __( 'UCP requests must be sent as application/json.', 'woocommerce' ), 'recoverable', 415 );
+	}
+
+	/**
+	 * Read a parameter from the JSON document alone.
+	 *
+	 * `get_param()` would also resolve it from the form body or the query string. UCP defines
+	 * the request as the document, so nothing carried elsewhere is allowed to act.
+	 *
+	 * @param \WP_REST_Request $request Request object.
+	 * @param string           $name Parameter name.
+	 *
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @return mixed|null The value, or null when the document has none.
+	 */
+	public static function json_param( \WP_REST_Request $request, string $name ) {
+		$params = $request->get_json_params();
+
+		return is_array( $params ) && array_key_exists( $name, $params ) ? $params[ $name ] : null;
+	}
+
 	/**
 	 * Translate a cart error code into the UCP code closest to it.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCart.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCart.php
index 24400f0755e..8e8adb657f9 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCart.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCart.php
@@ -472,6 +472,37 @@ class UcpCart extends ControllerTestCase {
 		$this->assertNull( $this->total( $data['totals'], 'discount' ) );
 	}

+	/**
+	 * Line items outside the JSON document do not act.
+	 */
+	public function test_line_items_in_the_query_string_are_ignored(): void {
+		$request = new \WP_REST_Request( 'POST', '/wc/ucp/v1/carts' );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_query_params( array( 'line_items' => array( $this->line_item( 0, 2 ) ) ) );
+		$request->set_body( '{}' );
+
+		$response = rest_get_server()->dispatch( $request );
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( 'success', $response->get_data()['ucp']['status'] );
+		$this->assertSame( array(), $response->get_data()['line_items'] );
+	}
+
+	/**
+	 * A payload under any other content type is refused rather than read.
+	 */
+	public function test_a_non_json_payload_is_refused(): void {
+		$request = new \WP_REST_Request( 'POST', '/wc/ucp/v1/carts' );
+		$request->set_header( 'content-type', 'application/x-www-form-urlencoded' );
+		$request->set_body_params( array( 'line_items' => array( $this->line_item( 0, 2 ) ) ) );
+
+		$response = rest_get_server()->dispatch( $request );
+
+		$this->assertSame( 415, $response->get_status() );
+		$this->assertSame( 'error', $response->get_data()['ucp']['status'] );
+		$this->assertSame( 'VALIDATION_ERROR', $response->get_data()['messages'][0]['code'] );
+	}
+
 	/**
 	 * A cart reads back with the contents it was created with.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCatalog.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCatalog.php
index 4072a7c373f..e81901c532a 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCatalog.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCatalog.php
@@ -234,6 +234,37 @@ class UcpCatalog extends ControllerTestCase {
 		$this->assertSame( 'VALIDATION_ERROR', $data['messages'][0]['code'] );
 	}

+	/**
+	 * Input outside the JSON document does not act.
+	 */
+	public function test_lookup_ids_in_the_query_string_are_ignored(): void {
+		$request = new \WP_REST_Request( 'POST', '/wc/ucp/v1/catalog/lookup' );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_query_params( array( 'ids' => array( (string) self::$product_ids[0] ) ) );
+		$request->set_body( '{}' );
+
+		$response = rest_get_server()->dispatch( $request );
+
+		$this->assertSame( 400, $response->get_status() );
+		$this->assertSame( 'VALIDATION_ERROR', $response->get_data()['messages'][0]['code'] );
+	}
+
+	/**
+	 * A payload under any other content type is refused rather than read.
+	 */
+	public function test_a_non_json_payload_is_refused(): void {
+		$request = new \WP_REST_Request( 'POST', '/wc/ucp/v1/catalog/lookup' );
+		$request->set_header( 'content-type', 'application/x-www-form-urlencoded' );
+		$request->set_body_params( array( 'ids' => array( (string) self::$product_ids[0] ) ) );
+
+		$response = rest_get_server()->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertSame( 415, $response->get_status() );
+		$this->assertSame( 'error', $data['ucp']['status'] );
+		$this->assertSame( 'VALIDATION_ERROR', $data['messages'][0]['code'] );
+	}
+
 	/**
 	 * Product detail answers with `ucp.status`, and reports a miss as 200 + `not_found`.
 	 */