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,
+			),
+		);
+	}
 }