Commit 240d98b24f6 for woocommerce
commit 240d98b24f630dffd7f87001f560e4c1d5e5af6f
Author: Seghir Nadir <nadir.seghir@gmail.com>
Date: Tue Sep 1 11:53:49 2026 +0200
Harden order line meta value handling in the REST API (#68190)
* Harden order line meta value handling in the REST API
* Add changelog entry for order meta value hardening
diff --git a/plugins/woocommerce/changelog/codex-fix-woo6-103 b/plugins/woocommerce/changelog/codex-fix-woo6-103
new file mode 100644
index 00000000000..45fb8b8f43b
--- /dev/null
+++ b/plugins/woocommerce/changelog/codex-fix-woo6-103
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Harden order line meta value handling in the REST API.
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 7a9f1a63bab..0220719c95b 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
@@ -11,6 +11,7 @@
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\OrderLineMetaValidator;
use Automattic\WooCommerce\Internal\Utilities\Users;
use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Utilities\ArrayUtil;
@@ -1004,6 +1005,8 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
$product_item->set_variation_id( 0 );
}
+ OrderLineMetaValidator::assert_no_serialized_meta_value( (array) ( $posted['meta_data'] ?? array() ) );
+
$this->maybe_set_item_props( $item, array( 'name', 'quantity', 'total', 'subtotal', 'tax_class' ), $posted );
$this->maybe_set_item_meta_data( $item, $posted );
@@ -1030,6 +1033,8 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
}
}
+ OrderLineMetaValidator::assert_no_serialized_meta_value( (array) ( $posted['meta_data'] ?? array() ) );
+
$this->maybe_set_item_props( $item, array( 'method_id', 'method_title', 'total', 'instance_id' ), $posted );
$this->maybe_set_item_meta_data( $item, $posted );
@@ -1054,6 +1059,8 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
}
}
+ OrderLineMetaValidator::assert_no_serialized_meta_value( (array) ( $posted['meta_data'] ?? array() ) );
+
$this->maybe_set_item_props( $item, array( 'name', 'tax_class', 'tax_status', 'total' ), $posted );
$this->maybe_set_item_meta_data( $item, $posted );
@@ -1544,6 +1551,9 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
'description' => __( 'Line items data.', 'woocommerce' ),
'type' => 'array',
'context' => array( 'view', 'edit' ),
+ 'arg_options' => array(
+ 'validate_callback' => array( OrderLineMetaValidator::class, 'validate_request_arg' ),
+ ),
'items' => array(
'type' => 'object',
'properties' => array(
@@ -1792,6 +1802,9 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
'description' => __( 'Shipping lines data.', 'woocommerce' ),
'type' => 'array',
'context' => array( 'view', 'edit' ),
+ 'arg_options' => array(
+ 'validate_callback' => array( OrderLineMetaValidator::class, 'validate_request_arg' ),
+ ),
'items' => array(
'type' => 'object',
'properties' => array(
@@ -1883,6 +1896,9 @@ class WC_REST_Orders_V2_Controller extends WC_REST_CRUD_Controller {
'description' => __( 'Fee lines data.', 'woocommerce' ),
'type' => 'array',
'context' => array( 'view', 'edit' ),
+ 'arg_options' => array(
+ 'validate_callback' => array( OrderLineMetaValidator::class, 'validate_request_arg' ),
+ ),
'items' => array(
'type' => 'object',
'properties' => array(
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/OrderLineMetaValidator.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/OrderLineMetaValidator.php
new file mode 100644
index 00000000000..95b455deb49
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/OrderLineMetaValidator.php
@@ -0,0 +1,124 @@
+<?php
+/**
+ * OrderLineMetaValidator class file.
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\Utilities\MetaDataUtil;
+use WC_REST_Exception;
+use WP_Error;
+use WP_REST_Request;
+
+/**
+ * Validates the line payloads of the order endpoints (`line_items`, `shipping_lines`, `fee_lines`),
+ * for every REST API version.
+ *
+ * Class OrderLineMetaValidator
+ *
+ * @package Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders
+ */
+class OrderLineMetaValidator {
+
+ /**
+ * Meta key that WC_Data::update_meta_data() diverts to the order item's set_taxes(), which runs
+ * the value through maybe_unserialize(). Only serialized values are rejected.
+ *
+ * Line, fee and shipping items all carry a `taxes` data prop, so reading an existing item promotes
+ * `_taxes` to an internal meta key and both `taxes` and `_taxes` divert to the tax setter.
+ */
+ private const GUARDED_META_KEY = 'taxes';
+
+ /**
+ * Validates an order line request argument (`line_items`, `shipping_lines` or `fee_lines`).
+ *
+ * @since 11.1.0
+ *
+ * @param mixed $value Value of the argument.
+ * @param WP_REST_Request<array<string, mixed>> $request The request object.
+ * @param string $param Name of the argument.
+ * @return true|WP_Error Error when a line posts a serialized value under the guarded key.
+ */
+ public static function validate_request_arg( $value, $request, $param ) {
+ $valid = rest_validate_request_arg( $value, $request, $param );
+
+ if ( is_wp_error( $valid ) ) {
+ return $valid;
+ }
+
+ if ( ! is_array( $value ) ) {
+ return true;
+ }
+
+ foreach ( $value as $line ) {
+ if ( is_array( $line ) && self::has_serialized_meta_value( $line['meta_data'] ?? null ) ) {
+ return new WP_Error(
+ 'woocommerce_rest_invalid_order_item_meta_key',
+ self::get_serialized_meta_value_error_message(),
+ array( 'status' => 400 )
+ );
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Rejects a serialized value under the guarded meta key while preparing an order line.
+ *
+ * Covers requests that skip request argument validation, such as the ones the batch endpoint builds.
+ *
+ * @since 11.1.0
+ *
+ * @param array $meta_data `meta_data` payload from the request. Cast at the call site, which can receive a non-array.
+ * @throws WC_REST_Exception When the payload carries a serialized value under the guarded key.
+ */
+ public static function assert_no_serialized_meta_value( array $meta_data ): void {
+ if ( self::has_serialized_meta_value( $meta_data ) ) {
+ throw new WC_REST_Exception( 'woocommerce_rest_invalid_order_item_meta_key', esc_html( self::get_serialized_meta_value_error_message() ), 400 );
+ }
+ }
+
+ /**
+ * Checks whether a `meta_data` payload carries a serialized value under the guarded meta key.
+ *
+ * @param mixed $meta_data Raw `meta_data` value from the request.
+ * @return bool
+ */
+ private static function has_serialized_meta_value( $meta_data ): bool {
+ if ( ! is_array( $meta_data ) ) {
+ return false;
+ }
+
+ foreach ( MetaDataUtil::normalize( $meta_data ) as $meta ) {
+ // update_meta_data() resolves the setter from ltrim( $key, '_' ), so `_taxes` diverts too.
+ if ( ! is_string( $meta['key'] ) || self::GUARDED_META_KEY !== ltrim( $meta['key'], '_' ) ) {
+ continue;
+ }
+
+ // Keep scanning: the payload can repeat the key, and every entry reaches the setter.
+ if ( is_serialized( $meta['value'] ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Gets the error message for a rejected payload.
+ *
+ * @return string
+ */
+ private static function get_serialized_meta_value_error_message(): string {
+ return sprintf(
+ /* translators: %s: order item meta key. */
+ __( 'The "%s" order line meta key cannot hold a serialized value.', 'woocommerce' ),
+ self::GUARDED_META_KEY
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderSchema.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderSchema.php
index e196518b23f..4380a17436a 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderSchema.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/Schema/OrderSchema.php
@@ -15,6 +15,7 @@ use Automattic\WooCommerce\Internal\RestApi\Routes\V4\AbstractSchema;
use Automattic\WooCommerce\Enums\OrderItemType;
use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareTrait;
+use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Orders\OrderLineMetaValidator;
use Automattic\WooCommerce\Internal\RestApi\Routes\V4\Refunds\DataUtils;
use Automattic\WooCommerce\Utilities\NumberUtil;
use Automattic\WooCommerce\Utilities\OrderUtil;
@@ -477,6 +478,9 @@ class OrderSchema extends AbstractSchema {
'description' => __( 'A list of line items (products) within this order.', 'woocommerce' ),
'type' => 'array',
'context' => self::VIEW_EDIT_EMBED_CONTEXT,
+ 'arg_options' => array(
+ 'validate_callback' => array( OrderLineMetaValidator::class, 'validate_request_arg' ),
+ ),
'items' => array(
'type' => 'object',
'properties' => $this->order_item_schema->get_item_schema_properties(),
@@ -496,6 +500,9 @@ class OrderSchema extends AbstractSchema {
'description' => __( 'Shipping lines data.', 'woocommerce' ),
'type' => 'array',
'context' => self::VIEW_EDIT_EMBED_CONTEXT,
+ 'arg_options' => array(
+ 'validate_callback' => array( OrderLineMetaValidator::class, 'validate_request_arg' ),
+ ),
'items' => array(
'type' => 'object',
'properties' => $this->order_shipping_schema->get_item_schema_properties(),
@@ -505,6 +512,9 @@ class OrderSchema extends AbstractSchema {
'description' => __( 'Fee lines data.', 'woocommerce' ),
'type' => 'array',
'context' => self::VIEW_EDIT_EMBED_CONTEXT,
+ 'arg_options' => array(
+ 'validate_callback' => array( OrderLineMetaValidator::class, 'validate_request_arg' ),
+ ),
'items' => array(
'type' => 'object',
'properties' => $this->order_fee_schema->get_item_schema_properties(),
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/UpdateUtils.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/UpdateUtils.php
index 69eb0a49a50..bca49b753a7 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/UpdateUtils.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Orders/UpdateUtils.php
@@ -371,6 +371,8 @@ class UpdateUtils {
}
}
+ OrderLineMetaValidator::assert_no_serialized_meta_value( (array) ( $request_data['meta_data'] ?? array() ) );
+
$this->maybe_set_item_props( $item, array( 'name', 'quantity', 'total', 'subtotal', 'tax_class' ), $request_data );
$this->maybe_set_item_meta_data( $item, $request_data );
@@ -402,6 +404,8 @@ class UpdateUtils {
throw new WC_REST_Exception( 'woocommerce_rest_invalid_shipping_item', esc_html__( 'Shipping method ID is required.', 'woocommerce' ), 400 );
}
+ OrderLineMetaValidator::assert_no_serialized_meta_value( (array) ( $request_data['meta_data'] ?? array() ) );
+
$this->maybe_set_item_props( $item, array( 'method_id', 'method_title', 'total', 'instance_id' ), $request_data );
$this->maybe_set_item_meta_data( $item, $request_data );
@@ -424,6 +428,8 @@ class UpdateUtils {
throw new WC_REST_Exception( 'woocommerce_rest_invalid_fee_item', esc_html__( 'Fee name is required.', 'woocommerce' ), 400 );
}
+ OrderLineMetaValidator::assert_no_serialized_meta_value( (array) ( $request_data['meta_data'] ?? array() ) );
+
$this->maybe_set_item_props( $item, array( 'name', 'tax_class', 'tax_status', 'total' ), $request_data );
$this->maybe_set_item_meta_data( $item, $request_data );
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller-test.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller-test.php
index afa2b7cc4eb..f7baed2ce3e 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller-test.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version2/class-wc-rest-orders-v2-controller-test.php
@@ -324,4 +324,98 @@ class WC_REST_Order_V2_Controller_Test extends WC_REST_Unit_Test_case {
$this->assertEquals( 400, $response->get_status(), 'The order was not updated, as the specified customer does not belong to the blog.' );
$this->assertEquals( 'woocommerce_rest_invalid_customer_id', $response->get_data()['code'], 'The returned error indicates the customer ID was invalid.' );
}
+
+ /**
+ * The /wc/v2/orders route registers its own schema args, so this confirms the reserved-meta-key
+ * guard is wired onto the live v2 endpoint, not just the shared prepare methods.
+ *
+ * @testdox PUT /wc/v2/orders/<id> rejects a serialized value under the reserved meta key of every line type.
+ * @dataProvider provide_reserved_meta_key_line_types
+ *
+ * @param string $line_type Request key: `line_items`, `fee_lines` or `shipping_lines`.
+ * @param callable $create_item Builds the order item that $line_type maps to.
+ */
+ public function test_v2_update_rejects_serialized_taxes_line_meta_key( string $line_type, callable $create_item ): void {
+ $order = new WC_Order();
+ $item = $create_item();
+ $order->add_item( $item );
+ $order->save();
+
+ $response = $this->dispatch_serialized_taxes_update( $order->get_id(), $line_type, $item->get_id() );
+ $data = $response->get_data();
+
+ $this->assertEquals( 400, $response->get_status() );
+ $this->assertEquals( 'woocommerce_rest_invalid_order_item_meta_key', $data['data']['details'][ $line_type ]['code'] );
+ }
+
+ /**
+ * Every request key that maps to an order item type accepting meta data.
+ *
+ * @return array
+ */
+ public function provide_reserved_meta_key_line_types(): array {
+ return array(
+ 'line items' => array(
+ 'line_items',
+ function () {
+ $item = new WC_Order_Item_Product();
+ $item->set_product( WC_Helper_Product::create_simple_product() );
+ $item->set_quantity( 1 );
+ $item->set_total( '10.00' );
+ return $item;
+ },
+ ),
+ 'fee lines' => array(
+ 'fee_lines',
+ function () {
+ $item = new WC_Order_Item_Fee();
+ $item->set_name( 'Test fee' );
+ $item->set_total( '5.00' );
+ return $item;
+ },
+ ),
+ 'shipping lines' => array(
+ 'shipping_lines',
+ function () {
+ $item = new WC_Order_Item_Shipping();
+ $item->set_method_id( 'flat_rate' );
+ $item->set_method_title( 'Flat rate' );
+ $item->set_total( '10.00' );
+ return $item;
+ },
+ ),
+ );
+ }
+
+ /**
+ * Dispatch a PUT that posts a serialized value under the reserved `_taxes` meta key of one order item.
+ *
+ * @param int $order_id Order to update.
+ * @param string $line_type Request key: `line_items`, `fee_lines` or `shipping_lines`.
+ * @param int $item_id Order item ID to target.
+ * @return WP_REST_Response
+ */
+ private function dispatch_serialized_taxes_update( int $order_id, string $line_type, int $item_id ) {
+ $request = new WP_REST_Request( 'PUT', '/wc/v2/orders/' . $order_id );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ $line_type => array(
+ array(
+ 'id' => $item_id,
+ 'meta_data' => array(
+ array(
+ 'key' => '_taxes',
+ 'value' => 'O:8:"stdClass":0:{}',
+ ),
+ ),
+ ),
+ ),
+ )
+ )
+ );
+
+ return $this->server->dispatch( $request );
+ }
}
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 d72ed3b4f06..d9868061246 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
@@ -1750,4 +1750,231 @@ class WC_REST_Orders_Controller_Tests extends WC_REST_Unit_Test_Case {
$this->assertEquals( 90, $data['line_items'][0]['total'], 'The total should be recalculated from the posted subtotal, not from or on top of the posted total' );
$this->assertEquals( 10, $data['discount_total'] );
}
+
+ /**
+ * Create an order with a single shipping line that carries known taxes.
+ *
+ * @return array{0: WC_Order, 1: int} The order and the shipping item ID.
+ */
+ private function create_order_with_shipping_line(): array {
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Shipping();
+ $item->set_method_id( 'flat_rate' );
+ $item->set_method_title( 'Flat rate' );
+ $item->set_total( '10.00' );
+ $item->set_taxes( array( 'total' => array( 1 => '2.00' ) ) );
+ $order->add_item( $item );
+ $order->save();
+
+ return array( $order, $item->get_id() );
+ }
+
+ /**
+ * Every request key whose order item carries a `taxes` data prop, against each meta key that
+ * update_meta_data() diverts to that item's set_taxes().
+ *
+ * Reading an item adds `_taxes` to its internal meta keys, so the setter resolves from the
+ * underscore-prefixed key as well as the bare one.
+ *
+ * @return array
+ */
+ public function provide_reserved_meta_key_line_types(): array {
+ $line_types = array(
+ 'line items' => array(
+ 'line_items',
+ 'create_order_with_line_item',
+ array(
+ 'total' => array( 1 => '2.00' ),
+ 'subtotal' => array( 1 => '2.00' ),
+ ),
+ ),
+ 'fee lines' => array( 'fee_lines', 'create_order_with_fee_line', array( 'total' => array( 1 => '1.00' ) ) ),
+ 'shipping lines' => array( 'shipping_lines', 'create_order_with_shipping_line', array( 'total' => array( 1 => '2.00' ) ) ),
+ );
+
+ $cases = array();
+ foreach ( $line_types as $label => $line_type ) {
+ foreach ( array( 'taxes', '_taxes' ) as $meta_key ) {
+ $cases[ "$label, $meta_key" ] = array_merge( $line_type, array( $meta_key ) );
+ }
+ }
+
+ return $cases;
+ }
+
+ /**
+ * @testdox PUT /orders/<id> rejects a serialized value under a reserved line meta key and leaves the taxes untouched.
+ *
+ * @dataProvider provide_reserved_meta_key_line_types
+ *
+ * @param string $line_type Request key: `line_items`, `fee_lines` or `shipping_lines`.
+ * @param string $create_order Factory method building an order with one line of that type.
+ * @param array $expected_taxes Taxes the item should still carry after the update is rejected.
+ * @param string $meta_key Reserved meta key to post the serialized value under.
+ */
+ public function test_update_order_rejects_serialized_taxes_line_meta_key( string $line_type, string $create_order, array $expected_taxes, string $meta_key ): void {
+ list( $order, $item_id ) = $this->$create_order();
+
+ $request = new WP_REST_Request( 'PUT', '/wc/v3/orders/' . $order->get_id() );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body( wp_json_encode( array( $line_type => array( $this->get_order_line_with_serialized_taxes( $item_id, $meta_key ) ) ) ) );
+
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ // Request argument validation wraps the failure in `rest_invalid_param`, under `details`.
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'rest_invalid_param', $data['code'] );
+ $this->assertSame( 'woocommerce_rest_invalid_order_item_meta_key', $data['data']['details'][ $line_type ]['code'] );
+
+ $item = WC_Order_Factory::get_order_item( $item_id );
+ $this->assertSame( $expected_taxes, $item->get_taxes(), 'The line taxes should be unchanged.' );
+ }
+
+ /**
+ * The batch endpoint skips request argument validation, so the prepare-time check has to catch this.
+ *
+ * @testdox POST /orders/batch rejects a serialized value under a reserved line meta key and leaves the taxes untouched.
+ *
+ * @dataProvider provide_reserved_meta_key_line_types
+ *
+ * @param string $line_type Request key: `line_items`, `fee_lines` or `shipping_lines`.
+ * @param string $create_order Factory method building an order with one line of that type.
+ * @param array $expected_taxes Taxes the item should still carry after the update is rejected.
+ * @param string $meta_key Reserved meta key to post the serialized value under.
+ */
+ public function test_batch_update_rejects_serialized_taxes_line_meta_key( string $line_type, string $create_order, array $expected_taxes, string $meta_key ): void {
+ list( $order, $item_id ) = $this->$create_order();
+
+ $request = new WP_REST_Request( 'POST', '/wc/v3/orders/batch' );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'update' => array(
+ array(
+ 'id' => $order->get_id(),
+ $line_type => array( $this->get_order_line_with_serialized_taxes( $item_id, $meta_key ) ),
+ ),
+ ),
+ )
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( 'woocommerce_rest_invalid_order_item_meta_key', $data['update'][0]['error']['code'] );
+
+ $item = WC_Order_Factory::get_order_item( $item_id );
+ $this->assertSame( $expected_taxes, $item->get_taxes(), 'The line taxes should be unchanged.' );
+ }
+
+ /**
+ * The reserved key still diverts to set_taxes(), hence the expected _doing_it_wrong notice.
+ *
+ * @testdox POST /orders only rejects serialized values, and only under the reserved meta key.
+ */
+ public function test_create_order_accepts_unguarded_shipping_line_meta(): void {
+ $this->setExpectedIncorrectUsage( 'is_internal_meta_key' );
+
+ $request = new WP_REST_Request( 'POST', '/wc/v3/orders' );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'shipping_lines' => array(
+ array(
+ 'method_id' => 'flat_rate',
+ 'total' => '10.00',
+ 'meta_data' => array(
+ array(
+ 'key' => 'taxes',
+ 'value' => 'not-serialized',
+ ),
+ array(
+ 'key' => 'delivery_window',
+ 'value' => 'morning',
+ ),
+ ),
+ ),
+ ),
+ )
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $this->assertSame( 201, $response->get_status(), 'Only serialized values should be rejected.' );
+
+ $shipping_lines = wc_get_order( $response->get_data()['id'] )->get_items( 'shipping' );
+ $shipping_line = reset( $shipping_lines );
+ $this->assertSame( 'morning', $shipping_line->get_meta( 'delivery_window' ), 'A meta key that is not reserved should be stored.' );
+ }
+
+ /**
+ * Create an order with a single line item that carries known taxes.
+ *
+ * @return array{0: WC_Order, 1: int} The order and the line item ID.
+ */
+ private function create_order_with_line_item(): array {
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Product();
+ $item->set_product( ProductHelper::create_simple_product() );
+ $item->set_quantity( 1 );
+ $item->set_total( '10.00' );
+ $item->set_taxes(
+ array(
+ 'total' => array( 1 => '2.00' ),
+ 'subtotal' => array( 1 => '2.00' ),
+ )
+ );
+ $order->add_item( $item );
+ $order->save();
+
+ return array( $order, $item->get_id() );
+ }
+
+ /**
+ * Create an order with a single fee line that carries known taxes.
+ *
+ * @return array{0: WC_Order, 1: int} The order and the fee item ID.
+ */
+ private function create_order_with_fee_line(): array {
+ $order = wc_create_order();
+ $item = new WC_Order_Item_Fee();
+ $item->set_name( 'Test fee' );
+ $item->set_total( '5.00' );
+ $item->set_taxes( array( 'total' => array( 1 => '1.00' ) ) );
+ $order->add_item( $item );
+ $order->save();
+
+ return array( $order, $item->get_id() );
+ }
+
+ /**
+ * Get an order line payload carrying a serialized object under a reserved meta key.
+ *
+ * The non-serialized decoy shares the key: `meta_data` is a list, so a key can repeat and every
+ * entry reaches the setter.
+ *
+ * @param int $item_id Order item ID to target.
+ * @param string $meta_key Meta key to post the serialized value under.
+ * @return array
+ */
+ private function get_order_line_with_serialized_taxes( int $item_id, string $meta_key = '_taxes' ): array {
+ return array(
+ 'id' => $item_id,
+ 'meta_data' => array(
+ array(
+ 'key' => $meta_key,
+ 'value' => 'not-serialized',
+ ),
+ array(
+ 'key' => $meta_key,
+ 'value' => 'O:8:"stdClass":0:{}',
+ ),
+ ),
+ );
+ }
}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Orders/class-wc-rest-orders-v4-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Orders/class-wc-rest-orders-v4-controller-tests.php
index c1d0da7d501..7a7494031a1 100644
--- a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Orders/class-wc-rest-orders-v4-controller-tests.php
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version4/Orders/class-wc-rest-orders-v4-controller-tests.php
@@ -1870,4 +1870,110 @@ class WC_REST_Orders_V4_Controller_Tests extends WC_REST_Unit_Test_Case {
$order->delete( true );
}
}
+
+ /**
+ * Test that a serialized value under a reserved line meta key is rejected for every line type.
+ *
+ * The create and update routes both build their args from the same OrderSchema property, so one
+ * route covers the registration. Reading an item adds `_taxes` to its internal meta keys, so the
+ * underscore-prefixed key diverts to the item's set_taxes() as well.
+ *
+ * @dataProvider provide_reserved_meta_key_line_types
+ *
+ * @param string $line_type Request key: `line_items`, `fee_lines` or `shipping_lines`.
+ * @param callable $create_item Builds the order item that $line_type maps to, taxes included.
+ * @param array $expected_taxes Taxes the item should still carry after the update is rejected.
+ */
+ public function test_orders_update_rejects_serialized_taxes_line_meta_key( string $line_type, callable $create_item, array $expected_taxes ): void {
+ $order = new WC_Order();
+ $item = $create_item();
+ $order->add_item( $item );
+ $order->save();
+
+ $request = new WP_REST_Request( 'POST', '/wc/v4/orders/' . $order->get_id() );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ $line_type => array(
+ array(
+ 'id' => $item->get_id(),
+ 'meta_data' => array(
+ array(
+ 'key' => '_taxes',
+ 'value' => 'O:8:"stdClass":0:{}',
+ ),
+ ),
+ ),
+ ),
+ )
+ )
+ );
+
+ $response = $this->server->dispatch( $request );
+ $data = $response->get_data();
+
+ $this->assertEquals( 400, $response->get_status(), 'The underscore-prefixed reserved key should be rejected.' );
+ $this->assertEquals(
+ 'woocommerce_rest_invalid_order_item_meta_key',
+ $data['data']['details'][ $line_type ]['code'],
+ 'The rejection should report the reserved meta key.'
+ );
+
+ $item_class = get_class( $item );
+ $reread = new $item_class( $item->get_id() );
+ $this->assertEquals( $expected_taxes, $reread->get_taxes(), 'The line taxes should be unchanged.' );
+ }
+
+ /**
+ * Every request key that maps to an order item type accepting meta data.
+ *
+ * @return array
+ */
+ public function provide_reserved_meta_key_line_types(): array {
+ $line_item_taxes = array(
+ 'total' => array( 1 => '2.00' ),
+ 'subtotal' => array( 1 => '2.00' ),
+ );
+ $fee_taxes = array( 'total' => array( 1 => '1.00' ) );
+ $shipping_taxes = array( 'total' => array( 1 => '2.00' ) );
+
+ return array(
+ 'line items' => array(
+ 'line_items',
+ function () use ( $line_item_taxes ) {
+ $item = new WC_Order_Item_Product();
+ $item->set_product( WC_Helper_Product::create_simple_product() );
+ $item->set_quantity( 1 );
+ $item->set_total( '10.00' );
+ $item->set_taxes( $line_item_taxes );
+ return $item;
+ },
+ $line_item_taxes,
+ ),
+ 'fee lines' => array(
+ 'fee_lines',
+ function () use ( $fee_taxes ) {
+ $item = new WC_Order_Item_Fee();
+ $item->set_name( 'Test fee' );
+ $item->set_total( '5.00' );
+ $item->set_taxes( $fee_taxes );
+ return $item;
+ },
+ $fee_taxes,
+ ),
+ 'shipping lines' => array(
+ 'shipping_lines',
+ function () use ( $shipping_taxes ) {
+ $item = new WC_Order_Item_Shipping();
+ $item->set_method_id( 'flat_rate' );
+ $item->set_method_title( 'Flat rate' );
+ $item->set_total( '10.00' );
+ $item->set_taxes( $shipping_taxes );
+ return $item;
+ },
+ $shipping_taxes,
+ ),
+ );
+ }
}