Commit a4f21271238 for woocommerce

commit a4f21271238e7d12aa2e53dad26325aa38dcdfb5
Author: Jorge A. Torres <jorge.torres@automattic.com>
Date:   Wed Jun 17 15:25:21 2026 +0100

    Prevent order item meta updates to reserved keys via admin (#65471)

    Skip reserved internal meta keys when saving order items

diff --git a/plugins/woocommerce/changelog/wooplug-5971-order-item-meta b/plugins/woocommerce/changelog/wooplug-5971-order-item-meta
new file mode 100644
index 00000000000..dfa1005fde1
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-5971-order-item-meta
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Order editing: skip reserved internal meta keys when saving order items, so they cannot be added via the "Add meta" button and stored as invisible meta.
diff --git a/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-orders.php b/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-orders.php
index 0cd662bb1ac..5695d8125ee 100644
--- a/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-orders.php
+++ b/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-orders.php
@@ -8,6 +8,7 @@

 use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\Internal\Admin\Orders\ListTable;
+use Automattic\WooCommerce\Internal\Utilities\OrderItemMetaUtil;

 if ( ! defined( 'ABSPATH' ) ) {
 	exit;
@@ -235,23 +236,7 @@ class WC_Admin_List_Table_Orders extends WC_Admin_List_Table {
 	 * @return string
 	 */
 	public static function get_order_preview_item_html( $order ) {
-		$hidden_order_itemmeta = apply_filters(
-			'woocommerce_hidden_order_itemmeta',
-			array(
-				'_qty',
-				'_tax_class',
-				'_product_id',
-				'_variation_id',
-				'_line_subtotal',
-				'_line_subtotal_tax',
-				'_line_total',
-				'_line_tax',
-				'method_id',
-				'cost',
-				'_reduced_stock',
-				'_restock_refunded_items',
-			)
-		);
+		$hidden_order_itemmeta = OrderItemMetaUtil::get_hidden_keys();

 		$line_items = apply_filters( 'woocommerce_admin_order_preview_line_items', $order->get_items(), $order );
 		$columns    = apply_filters(
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-item-meta.php b/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-item-meta.php
index a1842993293..3ddd681b46c 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-item-meta.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-item-meta.php
@@ -10,23 +10,7 @@ if ( ! defined( 'ABSPATH' ) ) {
 	exit;
 }

-$hidden_order_itemmeta = apply_filters(
-	'woocommerce_hidden_order_itemmeta',
-	array(
-		'_qty',
-		'_tax_class',
-		'_product_id',
-		'_variation_id',
-		'_line_subtotal',
-		'_line_subtotal_tax',
-		'_line_total',
-		'_line_tax',
-		'method_id',
-		'cost',
-		'_reduced_stock',
-		'_restock_refunded_items',
-	)
-);
+$hidden_order_itemmeta = \Automattic\WooCommerce\Internal\Utilities\OrderItemMetaUtil::get_hidden_keys();
 ?><div class="view">
 	<?php
 	$meta_data = $item->get_all_formatted_meta_data( '' );
diff --git a/plugins/woocommerce/includes/admin/wc-admin-functions.php b/plugins/woocommerce/includes/admin/wc-admin-functions.php
index d92504a82fc..3058f360083 100644
--- a/plugins/woocommerce/includes/admin/wc-admin-functions.php
+++ b/plugins/woocommerce/includes/admin/wc-admin-functions.php
@@ -8,6 +8,7 @@

 use Automattic\WooCommerce\Enums\OrderStatus;
 use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup;
+use Automattic\WooCommerce\Internal\Utilities\OrderItemMetaUtil;
 use Automattic\WooCommerce\Utilities\OrderUtil;

 if ( ! defined( 'ABSPATH' ) ) {
@@ -362,10 +363,17 @@ function wc_save_order_items( $order_id, $items ) {
 			}

 			if ( isset( $items['meta_key'][ $item_id ], $items['meta_value'][ $item_id ] ) ) {
+				$reserved_meta_keys = OrderItemMetaUtil::get_reserved_keys( $item );
+
 				foreach ( $items['meta_key'][ $item_id ] as $meta_id => $meta_key ) {
 					$meta_key   = substr( wp_unslash( $meta_key ), 0, 255 );
 					$meta_value = isset( $items['meta_value'][ $item_id ][ $meta_id ] ) ? wp_unslash( $items['meta_value'][ $item_id ][ $meta_id ] ) : '';

+					// Skip reserved keys, which cannot be added or edited as custom meta.
+					if ( in_array( $meta_key, $reserved_meta_keys, true ) ) {
+						continue;
+					}
+
 					if ( '' === $meta_key && '' === $meta_value ) {
 						if ( ! strstr( $meta_id, 'new-' ) ) {
 							$item->delete_meta_data_by_mid( $meta_id );
@@ -428,9 +436,16 @@ function wc_save_order_items( $order_id, $items ) {
 			);

 			if ( isset( $items['meta_key'][ $item_id ], $items['meta_value'][ $item_id ] ) ) {
+				$reserved_meta_keys = OrderItemMetaUtil::get_reserved_keys( $item );
+
 				foreach ( $items['meta_key'][ $item_id ] as $meta_id => $meta_key ) {
 					$meta_value = isset( $items['meta_value'][ $item_id ][ $meta_id ] ) ? wp_unslash( $items['meta_value'][ $item_id ][ $meta_id ] ) : '';

+					// Skip reserved keys, which cannot be added or edited as custom meta.
+					if ( in_array( $meta_key, $reserved_meta_keys, true ) ) {
+						continue;
+					}
+
 					if ( '' === $meta_key && '' === $meta_value ) {
 						if ( ! strstr( $meta_id, 'new-' ) ) {
 							$item->delete_meta_data_by_mid( $meta_id );
diff --git a/plugins/woocommerce/src/Internal/Utilities/OrderItemMetaUtil.php b/plugins/woocommerce/src/Internal/Utilities/OrderItemMetaUtil.php
new file mode 100644
index 00000000000..23e8cfe333f
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Utilities/OrderItemMetaUtil.php
@@ -0,0 +1,62 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Utilities;
+
+use WC_Order_Item;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Helpers for the order item meta keys WooCommerce manages internally.
+ *
+ * @since 11.0.0
+ */
+final class OrderItemMetaUtil {
+
+	/**
+	 * Get the order item meta keys hidden from the admin order screen.
+	 *
+	 * @return string[]
+	 */
+	public static function get_hidden_keys(): array {
+		/**
+		 * Filters the order item meta keys hidden from the admin order screen.
+		 *
+		 * @since 2.2.0
+		 * @param string[] $hidden_keys Hidden order item meta keys.
+		 */
+		return apply_filters(
+			'woocommerce_hidden_order_itemmeta',
+			array(
+				'_qty',
+				'_tax_class',
+				'_product_id',
+				'_variation_id',
+				'_line_subtotal',
+				'_line_subtotal_tax',
+				'_line_total',
+				'_line_tax',
+				'method_id',
+				'cost',
+				'_reduced_stock',
+				'_restock_refunded_items',
+			)
+		);
+	}
+
+	/**
+	 * Get the meta keys that cannot be added or edited as custom meta on an order item.
+	 *
+	 * Combines the hidden keys with the item's own internal meta keys, which back core item data.
+	 *
+	 * @param WC_Order_Item $item Order item to check.
+	 * @return string[]
+	 */
+	public static function get_reserved_keys( WC_Order_Item $item ): array {
+		// @phpstan-ignore-next-line method.notFound Proxied via WC_Data_Store::__call() on the order item data store.
+		$internal_meta_keys = (array) $item->get_data_store()->get_internal_meta_keys();
+
+		return array_values( array_unique( array_merge( self::get_hidden_keys(), $internal_meta_keys ) ) );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-functions-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-functions-test.php
index 5c7d668550b..6619f308264 100644
--- a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-functions-test.php
@@ -535,4 +535,50 @@ class WC_Admin_Functions_Test extends \WC_Unit_Test_Case {
 		$order_item = new WC_Order_Item_Product( $order_item_id );
 		$this->assertEquals( 4, $order_item->get_meta( '_reduced_stock', true ), 'Reduced stock meta should be updated to new quantity' );
 	}
+
+	/**
+	 * @testdox wc_save_order_items() should skip reserved meta keys while still saving valid custom meta.
+	 *
+	 * @link https://github.com/woocommerce/woocommerce/issues/62328
+	 */
+	public function test_wc_save_order_items_skips_reserved_meta_keys() {
+		$order               = WC_Helper_Order::create_order();
+		$order_item          = current( $order->get_items() );
+		$item_id             = $order_item->get_id();
+		$original_product_id = $order_item->get_product_id();
+
+		// new-0 = internal key, new-1 = hidden key, new-2 = valid custom meta.
+		// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+		$items = array(
+			'order_item_id'        => array( $item_id ),
+			'order_item_name'      => array( $item_id => $order_item->get_name() ),
+			'order_item_qty'       => array( $item_id => $order_item->get_quantity() ),
+			'order_item_tax_class' => array( $item_id => $order_item->get_tax_class() ),
+			'line_total'           => array( $item_id => $order_item->get_total() ),
+			'line_subtotal'        => array( $item_id => $order_item->get_subtotal() ),
+			'meta_key'             => array(
+				$item_id => array(
+					'new-0' => '_product_id',
+					'new-1' => '_reduced_stock',
+					'new-2' => 'custom_meta',
+				),
+			),
+			'meta_value'           => array(
+				$item_id => array(
+					'new-0' => '99999',
+					'new-1' => '5',
+					'new-2' => 'hello world',
+				),
+			),
+		);
+		// phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+
+		wc_save_order_items( $order->get_id(), $items );
+
+		$saved_item = new WC_Order_Item_Product( $item_id );
+
+		$this->assertEquals( 'hello world', $saved_item->get_meta( 'custom_meta', true ), 'Valid custom meta should still be saved' );
+		$this->assertEquals( $original_product_id, $saved_item->get_product_id(), 'Reserved internal key should not overwrite core item data' );
+		$this->assertFalse( $saved_item->meta_exists( '_reduced_stock' ), 'Reserved hidden key should not be stored as item meta' );
+	}
 }