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