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`.
*/