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,