Commit a3b6396e111 for woocommerce
commit a3b6396e111f75d0fa928aba92aaee62bdb2d6f0
Author: Michal Iwanow <4765119+mcliwanow@users.noreply.github.com>
Date: Wed Aug 5 13:14:39 2026 +0200
Fix fatal errors from malformed subscription data (#67152)
* Fix fatal errors from malformed subscription data
* Add changelog entry for malformed subscription fix
* Clarify subscription product ID validation
* Improve malformed subscription validation
* Log malformed subscription API responses
* Simplify subscription product ID validation
---------
Co-authored-by: Akeda Bagus <akeda.bagus@automattic.com>
diff --git a/plugins/woocommerce/changelog/fix-wccom-2747-malformed-subscriptions b/plugins/woocommerce/changelog/fix-wccom-2747-malformed-subscriptions
new file mode 100644
index 00000000000..e8f6a43ec98
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wccom-2747-malformed-subscriptions
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent fatal errors on plugins.php, update-core.php and the Extensions page when cached WooCommerce.com subscription data contains malformed entries.
diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php
index a26e1c8cd92..0ea1afd85f9 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php
@@ -420,8 +420,10 @@ class WC_Helper_Updater {
$subscriptions = WC_Helper::get_subscriptions();
foreach ( $subscriptions as $subscription ) {
- $payload[ $subscription['product_id'] ] = array(
- 'product_id' => $subscription['product_id'],
+ $product_id = (int) $subscription['product_id'];
+
+ $payload[ $product_id ] = array(
+ 'product_id' => $product_id,
'file_id' => '',
);
}
@@ -456,8 +458,10 @@ class WC_Helper_Updater {
$subscriptions = WC_Helper::get_subscriptions();
foreach ( $subscriptions as $subscription ) {
- $payload[ $subscription['product_id'] ] = array(
- 'product_id' => $subscription['product_id'],
+ $product_id = (int) $subscription['product_id'];
+
+ $payload[ $product_id ] = array(
+ 'product_id' => $product_id,
'file_id' => '',
);
}
diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
index 68ddf082126..572054474a8 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
@@ -1895,6 +1895,30 @@ class WC_Helper {
return $connection_data;
}
+ /**
+ * Filter malformed entries from subscription data.
+ *
+ * @param array $subscriptions Subscription entries.
+ * @return array
+ */
+ private static function filter_valid_subscriptions( $subscriptions ) {
+ return array_filter(
+ $subscriptions,
+ static function ( $subscription ) {
+ if ( ! is_array( $subscription ) ) {
+ return false;
+ }
+
+ $product_id = $subscription['product_id'] ?? null;
+ return (
+ is_int( $product_id )
+ || ( is_string( $product_id ) && ctype_digit( $product_id ) )
+ ) && 0 < (int) $product_id
+ && is_array( $subscription['connections'] ?? null );
+ }
+ );
+ }
+
/**
* Get the connected user's subscriptions.
*
@@ -1907,7 +1931,7 @@ class WC_Helper {
$data = get_transient( $cache_key );
if ( false !== $data ) {
if ( is_array( $data ) ) {
- return $data;
+ return self::filter_valid_subscriptions( $data );
}
// Cached data is corrupted, delete and fetch fresh.
delete_transient( $cache_key );
@@ -1961,6 +1985,18 @@ class WC_Helper {
throw new Exception( __( 'WooCommerce.com API returned an invalid response.', 'woocommerce' ), 422 );
}
+ $subscription_count = count( $data );
+ $data = self::filter_valid_subscriptions( $data );
+ $invalid_count = $subscription_count - count( $data );
+ if ( 0 < $invalid_count ) {
+ self::log(
+ sprintf(
+ 'Filtered %d malformed subscription entries from the WooCommerce.com API response.',
+ $invalid_count
+ ),
+ 'warning'
+ );
+ }
set_transient( $cache_key, $data, 3 * HOUR_IN_SECONDS );
// Remove notice after successful API call as it's no longer applicable.
diff --git a/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-test.php b/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-test.php
index faa88bad370..c539e851264 100644
--- a/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-test.php
@@ -1,6 +1,8 @@
<?php
declare( strict_types = 1 );
+use Automattic\Jetpack\Constants;
+
/**
* Class WC_Tests_WC_Helper.
*/
@@ -33,6 +35,57 @@ class WC_Helper_Test extends \WC_Unit_Test_Case {
delete_transient( '_woocommerce_helper_connection_data' );
}
+ /**
+ * Get subscription data containing valid and malformed entries.
+ *
+ * @return array
+ */
+ private function get_mixed_subscription_data(): array {
+ return array(
+ 'scalar' => 'corrupted',
+ 'missing ID' => array( 'product_key' => 'missing-id' ),
+ 'array ID' => array( 'product_id' => array( 456 ) ),
+ 'zero ID' => array( 'product_id' => 0 ),
+ 'negative ID' => array( 'product_id' => -10 ),
+ 'float ID' => array( 'product_id' => 900001.9 ),
+ 'decimal string ID' => array( 'product_id' => '900002.9' ),
+ 'scientific ID' => array( 'product_id' => '9e5' ),
+ 'signed ID' => array( 'product_id' => '+900003' ),
+ 'whitespace ID' => array( 'product_id' => ' 900004 ' ),
+ 'boolean ID' => array( 'product_id' => true ),
+ 'missing connections' => array( 'product_id' => 900005 ),
+ 'invalid connections' => array(
+ 'product_id' => 900006,
+ 'connections' => 'corrupted',
+ ),
+ 'valid integer ID' => array(
+ 'product_id' => 123,
+ 'product_key' => 'integer-key',
+ 'connections' => array( 789 ),
+ 'metadata' => array( 'preserved' => true ),
+ ),
+ 'valid string ID' => array(
+ 'product_id' => '456',
+ 'product_key' => 'string-key',
+ 'connections' => array(),
+ ),
+ );
+ }
+
+ /**
+ * Get the valid entries from the mixed subscription fixture.
+ *
+ * @return array
+ */
+ private function get_valid_subscription_data(): array {
+ $data = $this->get_mixed_subscription_data();
+
+ return array(
+ 'valid integer ID' => $data['valid integer ID'],
+ 'valid string ID' => $data['valid string ID'],
+ );
+ }
+
/**
* @testdox get_subscriptions should delete corrupted string transient and return empty array.
*/
@@ -66,6 +119,7 @@ class WC_Helper_Test extends \WC_Unit_Test_Case {
array(
'product_id' => 123,
'product_key' => 'test_key',
+ 'connections' => array(),
),
);
set_transient( '_woocommerce_helper_subscriptions', $valid_data, HOUR_IN_SECONDS );
@@ -75,6 +129,76 @@ class WC_Helper_Test extends \WC_Unit_Test_Case {
$this->assertEquals( $valid_data, $result, 'Valid cached data should be returned as-is' );
}
+ /**
+ * @testdox get_subscriptions should filter malformed cached entries without modifying valid subscriptions.
+ */
+ public function test_get_subscriptions_filters_malformed_cached_entries(): void {
+ set_transient( '_woocommerce_helper_subscriptions', $this->get_mixed_subscription_data(), HOUR_IN_SECONDS );
+
+ $result = WC_Helper::get_subscriptions();
+
+ $this->assertSame( $this->get_valid_subscription_data(), $result, 'Only valid cached subscriptions should be returned unchanged' );
+ }
+
+ /**
+ * @testdox get_subscriptions should filter malformed API entries before caching them.
+ */
+ public function test_get_subscriptions_filters_malformed_api_entries(): void {
+ $response_data = $this->get_mixed_subscription_data();
+ $previous_auth = WC_Helper_Options::get( 'auth', array() );
+ $previous_log = WC_Helper::$log;
+ $had_wp_debug_override = array_key_exists( 'WP_DEBUG', Constants::$set_constants );
+ $previous_wp_debug = Constants::$set_constants['WP_DEBUG'] ?? null;
+ $filtered_count = count( $response_data ) - count( $this->get_valid_subscription_data() );
+ $http_mock = static function () use ( $response_data ) {
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => wp_json_encode( $response_data ),
+ );
+ };
+ $logger = $this->createMock( WC_Logger_Interface::class );
+ $logger->expects( $this->once() )
+ ->method( 'log' )
+ ->with(
+ 'warning',
+ sprintf(
+ 'Filtered %d malformed subscription entries from the WooCommerce.com API response.',
+ $filtered_count
+ ),
+ array( 'source' => 'helper' )
+ );
+ WC_Helper::$log = $logger;
+ Constants::set_constant( 'WP_DEBUG', true );
+ WC_Helper_Options::update(
+ 'auth',
+ array(
+ 'access_token' => 'test-token',
+ 'access_token_secret' => 'test-secret',
+ )
+ );
+ add_filter( 'pre_http_request', $http_mock );
+
+ try {
+ $result = WC_Helper::get_subscriptions();
+ } finally {
+ remove_filter( 'pre_http_request', $http_mock );
+ WC_Helper_Options::update( 'auth', $previous_auth );
+ WC_Helper::$log = $previous_log;
+ if ( $had_wp_debug_override ) {
+ Constants::set_constant( 'WP_DEBUG', $previous_wp_debug );
+ } else {
+ Constants::clear_single_constant( 'WP_DEBUG' );
+ }
+ }
+
+ $this->assertSame( $this->get_valid_subscription_data(), $result, 'Only valid API subscriptions should be returned unchanged' );
+ $this->assertSame(
+ $this->get_valid_subscription_data(),
+ get_transient( '_woocommerce_helper_subscriptions' ),
+ 'Only valid API subscriptions should be cached'
+ );
+ }
+
/**
* @testdox get_cached_connection_data should return false for corrupted string transient.
*/
@@ -171,6 +295,39 @@ class WC_Helper_Test extends \WC_Unit_Test_Case {
$this->assertIsArray( $result, 'Result should be an array even with corrupted subscriptions transient' );
}
+ /**
+ * @testdox get_subscription_list_data should handle malformed subscription entries.
+ */
+ public function test_get_subscription_list_data_handles_malformed_entries(): void {
+ set_transient(
+ '_woocommerce_helper_subscriptions',
+ array(
+ 'corrupted',
+ array( 'product_key' => 'missing-id' ),
+ array( 'product_id' => array( 456 ) ),
+ array( 'product_id' => 900005 ),
+ array(
+ 'product_id' => 900006,
+ 'connections' => 'corrupted',
+ ),
+ ),
+ HOUR_IN_SECONDS
+ );
+
+ $http_mock = static function () {
+ return new WP_Error( 'test', 'Mocked error' );
+ };
+ add_filter( 'pre_http_request', $http_mock );
+
+ try {
+ $result = WC_Helper::get_subscription_list_data();
+ } finally {
+ remove_filter( 'pre_http_request', $http_mock );
+ }
+
+ $this->assertIsArray( $result, 'Malformed subscription entries should not interrupt the Extensions list' );
+ }
+
/**
* @testdox get_installed_subscriptions should return empty array when subscriptions are corrupted.
*/
@@ -240,6 +397,7 @@ class WC_Helper_Test extends \WC_Unit_Test_Case {
$subscriptions = array(
array(
'product_id' => 123,
+ 'connections' => array(),
'included_in_host_plan' => true,
),
);
@@ -257,6 +415,7 @@ class WC_Helper_Test extends \WC_Unit_Test_Case {
$subscriptions = array(
array(
'product_id' => 123,
+ 'connections' => array(),
'included_in_host_plan' => false,
),
);
diff --git a/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-updater-test.php b/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-updater-test.php
index 9b2fe57928d..c60751186d3 100644
--- a/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-updater-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-updater-test.php
@@ -26,6 +26,13 @@ class WC_Helper_Updater_Test extends WC_Unit_Test_Case {
),
);
+ /**
+ * Products sent in the mocked update-check request.
+ *
+ * @var array|null
+ */
+ private $mocked_request_products;
+
/**
* Set up before each test.
*/
@@ -50,6 +57,7 @@ class WC_Helper_Updater_Test extends WC_Unit_Test_Case {
private function cleanup_transients() {
delete_transient( '_woocommerce_helper_updates' );
delete_transient( '_woocommerce_helper_updates_count' );
+ delete_transient( '_woocommerce_helper_subscriptions' );
}
/**
@@ -66,6 +74,83 @@ class WC_Helper_Updater_Test extends WC_Unit_Test_Case {
return $method->invoke( null, $payload );
}
+ /**
+ * @testdox Update-data entry points skip malformed subscription records.
+ *
+ * @dataProvider malformed_subscription_entry_points
+ *
+ * @param string $entry_point Updater method to test.
+ */
+ public function test_update_data_entry_points_skip_malformed_subscriptions( string $entry_point ): void {
+ set_transient(
+ '_woocommerce_helper_subscriptions',
+ array(
+ 'corrupted',
+ array( 'product_key' => 'missing-id' ),
+ array( 'product_id' => array( 456 ) ),
+ array( 'product_id' => 0 ),
+ array( 'product_id' => -10 ),
+ array( 'product_id' => 900001.9 ),
+ array( 'product_id' => '900002.9' ),
+ array( 'product_id' => '9e5' ),
+ array( 'product_id' => '+900003' ),
+ array( 'product_id' => ' 900004 ' ),
+ array( 'product_id' => true ),
+ array( 'product_id' => 900005 ),
+ array(
+ 'product_id' => 900006,
+ 'connections' => 'corrupted',
+ ),
+ array(
+ 'product_id' => 123,
+ 'connections' => array(),
+ ),
+ array(
+ 'product_id' => '456',
+ 'connections' => array(),
+ ),
+ ),
+ HOUR_IN_SECONDS
+ );
+ add_filter( 'pre_http_request', array( $this, 'mock_helper_api_response' ), 10, 3 );
+
+ try {
+ $result = call_user_func( array( WC_Helper_Updater::class, $entry_point ) );
+ } finally {
+ remove_filter( 'pre_http_request', array( $this, 'mock_helper_api_response' ) );
+ }
+
+ $this->assertSame( $this->mocked_updates, $result, 'Malformed subscriptions should not interrupt the update check' );
+ $this->assertIsArray( $this->mocked_request_products, 'The valid subscription should trigger an update-check request' );
+ $this->assertSame(
+ array( 123, 456 ),
+ array_values(
+ array_intersect(
+ array( 123, 456, 900000, 900001, 900002, 900003, 900004, 900005, 900006 ),
+ array_keys( $this->mocked_request_products )
+ )
+ ),
+ 'Only valid test subscription IDs should be included in the request'
+ );
+ $this->assertSame(
+ 456,
+ $this->mocked_request_products[456]['product_id'],
+ 'String subscription IDs should be normalized to integers in the update request'
+ );
+ }
+
+ /**
+ * Data provider for subscription update entry points.
+ *
+ * @return array
+ */
+ public function malformed_subscription_entry_points() {
+ return array(
+ 'available extension downloads' => array( 'get_available_extensions_downloads_data' ),
+ 'all extension updates' => array( 'get_update_data' ),
+ );
+ }
+
/**
* Helper method to call private should_use_cached_update_data method via reflection.
*
@@ -448,6 +533,9 @@ class WC_Helper_Updater_Test extends WC_Unit_Test_Case {
return $preempt;
}
+ $request_body = json_decode( $args['body'] ?? '', true );
+ $this->mocked_request_products = $request_body['products'] ?? null;
+
return array(
'response' => array(
'code' => 200,