Commit 0648cce78fa for woocommerce

commit 0648cce78fa438fa79c9cffd728b6222ab86b667
Author: Liam Sarsfield <43409125+LiamSarsfield@users.noreply.github.com>
Date:   Mon Aug 10 15:28:12 2026 +0100

    Check permissions before activating a Marketplace subscription (#67540)

    Co-authored-by: Jorge Torres <jorge.torres@automattic.com>

diff --git a/plugins/woocommerce/changelog/fix-marketplace-subscription-activate-permission b/plugins/woocommerce/changelog/fix-marketplace-subscription-activate-permission
new file mode 100644
index 00000000000..642bc61b932
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-marketplace-subscription-activate-permission
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Tighten authorization checks on the Marketplace subscription activate endpoint.
diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper-subscriptions-api.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper-subscriptions-api.php
index 186ad4bab9a..077e968b917 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper-subscriptions-api.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper-subscriptions-api.php
@@ -294,7 +294,7 @@ class WC_Helper_Subscriptions_API {
 		$product_key  = $request->get_param( 'product_key' );
 		$subscription = WC_Helper::get_subscription( $product_key );

-		if ( ! $subscription ) {
+		if ( ! is_array( $subscription ) ) {
 			wp_send_json_error(
 				array(
 					'message' => __( 'We couldn\'t find a subscription for this product.', 'woocommerce' ),
@@ -303,6 +303,15 @@ class WC_Helper_Subscriptions_API {
 			);
 		}

+		if ( ! in_array( $subscription['product_type'], array( 'plugin', 'theme' ), true ) ) {
+			wp_send_json_error(
+				array(
+					'message' => __( 'This product type is not supported.', 'woocommerce' ),
+				),
+				400
+			);
+		}
+
 		if ( true !== $subscription['local']['installed'] || ! isset( $subscription['local']['active'] ) ) {
 			wp_send_json_error(
 				array(
@@ -321,6 +330,15 @@ class WC_Helper_Subscriptions_API {
 		}

 		if ( 'plugin' === $subscription['product_type'] ) {
+			// manage_woocommerce (checked by get_permission() above) doesn't imply activate_plugins.
+			if ( ! current_user_can( 'activate_plugins' ) ) {
+				wp_send_json_error(
+					array(
+						'message' => __( 'You do not have permission to activate plugins.', 'woocommerce' ),
+					),
+					403
+				);
+			}
 			$success = activate_plugin( $subscription['local']['path'] );
 			if ( is_wp_error( $success ) ) {
 				wp_send_json_error(
@@ -331,6 +349,14 @@ class WC_Helper_Subscriptions_API {
 				);
 			}
 		} elseif ( 'theme' === $subscription['product_type'] ) {
+			if ( ! current_user_can( 'switch_themes' ) ) {
+				wp_send_json_error(
+					array(
+						'message' => __( 'You do not have permission to switch themes.', 'woocommerce' ),
+					),
+					403
+				);
+			}
 			switch_theme( $subscription['local']['slug'] );
 			$theme = wp_get_theme();
 			if ( $subscription['local']['slug'] !== $theme->get_stylesheet() ) {
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 7041e2d218b..612401aae71 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -3513,7 +3513,7 @@ parameters:
 		-
 			message: '#^Cannot access offset ''local'' on non\-empty\-array\|true\.$#'
 			identifier: offsetAccess.nonOffsetAccessible
-			count: 2
+			count: 1
 			path: includes/admin/helper/class-wc-helper-subscriptions-api.php

 		-
diff --git a/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-subscriptions-api-test.php b/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-subscriptions-api-test.php
new file mode 100644
index 00000000000..674a46bed24
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-subscriptions-api-test.php
@@ -0,0 +1,243 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * Tests for WC_Helper_Subscriptions_API::activate().
+ */
+class WC_Helper_Subscriptions_API_Test extends \WC_Unit_Test_Case {
+
+	/**
+	 * Slug of a plugin that is always present but inactive in the test
+	 * environment, used to simulate an "installed but not active" Woo
+	 * subscription without depending on WooCommerce's own plugin state.
+	 */
+	private const INACTIVE_PLUGIN_SLUG = 'akismet';
+
+	/**
+	 * Stylesheet of a theme that is always present in the test environment,
+	 * used to simulate an "installed but not active" Woo theme subscription.
+	 * Must differ from whatever theme is active when the test starts.
+	 */
+	private const INACTIVE_THEME_SLUG = 'storefront';
+
+	/**
+	 * Stylesheet of the theme active before each test, restored afterwards.
+	 *
+	 * @var string
+	 */
+	private $original_stylesheet;
+
+	/**
+	 * Set up before each test.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		delete_transient( '_woocommerce_helper_subscriptions' );
+		deactivate_plugins( self::INACTIVE_PLUGIN_SLUG . '/' . self::INACTIVE_PLUGIN_SLUG . '.php' );
+		$this->original_stylesheet = get_stylesheet();
+	}
+
+	/**
+	 * Tear down after each test.
+	 */
+	public function tearDown(): void {
+		delete_transient( '_woocommerce_helper_subscriptions' );
+		deactivate_plugins( self::INACTIVE_PLUGIN_SLUG . '/' . self::INACTIVE_PLUGIN_SLUG . '.php' );
+		switch_theme( $this->original_stylesheet );
+		wp_set_current_user( 0 );
+		remove_all_filters( 'wp_die_ajax_handler' );
+		remove_all_filters( 'wp_doing_ajax' );
+		parent::tearDown();
+	}
+
+	/**
+	 * Stage a subscription for an installed-but-inactive theme. Switches to
+	 * a different theme first so INACTIVE_THEME_SLUG is guaranteed inactive
+	 * regardless of whatever theme the environment starts on.
+	 *
+	 * @return string The subscription's product_key.
+	 */
+	private function stage_inactive_theme_subscription(): string {
+		switch_theme( 'twentytwentyfive' );
+
+		$product_key = 'test-activate-key';
+		set_transient(
+			'_woocommerce_helper_subscriptions',
+			array(
+				array(
+					'product_id'   => 999003,
+					'product_key'  => $product_key,
+					'product_type' => 'theme',
+					'zip_slug'     => self::INACTIVE_THEME_SLUG,
+					'connections'  => array(),
+				),
+			),
+			HOUR_IN_SECONDS
+		);
+		return $product_key;
+	}
+
+	/**
+	 * Stage a subscription for an installed-but-inactive plugin.
+	 *
+	 * @return string The subscription's product_key.
+	 */
+	private function stage_inactive_plugin_subscription(): string {
+		$product_key = 'test-activate-key';
+		set_transient(
+			'_woocommerce_helper_subscriptions',
+			array(
+				array(
+					'product_id'   => 999001,
+					'product_key'  => $product_key,
+					'product_type' => 'plugin',
+					'zip_slug'     => self::INACTIVE_PLUGIN_SLUG,
+					'connections'  => array(),
+				),
+			),
+			HOUR_IN_SECONDS
+		);
+		return $product_key;
+	}
+
+	/**
+	 * Invoke WC_Helper_Subscriptions_API::activate() and capture the JSON it
+	 * would have sent, the same interception pattern SubmissionHandlerTest
+	 * uses for wp_send_json_* callbacks that would otherwise call wp_die().
+	 *
+	 * @param string $product_key Product key to activate.
+	 * @return array{success:bool, data:mixed, status:int}
+	 */
+	private function dispatch_activate( string $product_key ): array {
+		$response = array(
+			'success' => false,
+			'data'    => null,
+			'status'  => 200,
+		);
+
+		// wp_send_json_*() always terminates the request via wp_die(). Throwing
+		// from the die handler stops execution at that exact point, the same
+		// as a real request would — a no-op handler would instead let
+		// activate() keep running past what should be a terminal response.
+		add_filter(
+			'wp_die_ajax_handler',
+			static fn() => static function () {
+				throw new RuntimeException( 'wp_die intercepted' );
+			}
+		);
+		add_filter( 'wp_doing_ajax', static fn() => true );
+
+		$request = new WP_REST_Request( 'POST', '/wc/v3/marketplace/subscriptions/activate' );
+		$request->set_param( 'product_key', $product_key );
+
+		ob_start();
+		try {
+			WC_Helper_Subscriptions_API::activate( $request );
+		} catch ( RuntimeException $e ) {
+			unset( $e );
+		}
+		$body = (string) ob_get_clean();
+
+		$decoded = json_decode( $body, true );
+		if ( is_array( $decoded ) ) {
+			$response['success'] = ! empty( $decoded['success'] );
+			$response['data']    = $decoded['data'] ?? null;
+		}
+		return $response;
+	}
+
+	/**
+	 * @testdox A Shop Manager (manage_woocommerce but not activate_plugins) cannot activate a subscribed plugin.
+	 */
+	public function test_shop_manager_cannot_activate_plugin(): void {
+		$product_key = $this->stage_inactive_plugin_subscription();
+
+		$shop_manager_id = self::factory()->user->create( array( 'role' => 'shop_manager' ) );
+		$this->assertFalse( user_can( $shop_manager_id, 'activate_plugins' ), 'Test precondition: shop_manager must not have activate_plugins.' );
+		wp_set_current_user( $shop_manager_id );
+
+		$response = $this->dispatch_activate( $product_key );
+
+		$this->assertFalse( $response['success'] );
+		$this->assertIsArray( $response['data'] );
+		$this->assertStringContainsString( 'permission', $response['data']['message'] );
+		$this->assertFalse( is_plugin_active( self::INACTIVE_PLUGIN_SLUG . '/' . self::INACTIVE_PLUGIN_SLUG . '.php' ) );
+	}
+
+	/**
+	 * @testdox An Administrator (has activate_plugins) can still activate a subscribed plugin.
+	 */
+	public function test_administrator_can_activate_plugin(): void {
+		$product_key = $this->stage_inactive_plugin_subscription();
+
+		$admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $admin_id );
+
+		$response = $this->dispatch_activate( $product_key );
+
+		$this->assertTrue( $response['success'] );
+		$this->assertTrue( is_plugin_active( self::INACTIVE_PLUGIN_SLUG . '/' . self::INACTIVE_PLUGIN_SLUG . '.php' ) );
+	}
+
+	/**
+	 * @testdox A Shop Manager (manage_woocommerce but not switch_themes) cannot activate a subscribed theme.
+	 */
+	public function test_shop_manager_cannot_activate_theme(): void {
+		$product_key = $this->stage_inactive_theme_subscription();
+
+		$shop_manager_id = self::factory()->user->create( array( 'role' => 'shop_manager' ) );
+		$this->assertFalse( user_can( $shop_manager_id, 'switch_themes' ), 'Test precondition: shop_manager must not have switch_themes.' );
+		wp_set_current_user( $shop_manager_id );
+
+		$response = $this->dispatch_activate( $product_key );
+
+		$this->assertFalse( $response['success'] );
+		$this->assertIsArray( $response['data'] );
+		$this->assertStringContainsString( 'permission', $response['data']['message'] );
+		$this->assertNotSame( self::INACTIVE_THEME_SLUG, get_stylesheet() );
+	}
+
+	/**
+	 * @testdox An Administrator (has switch_themes) can still activate a subscribed theme.
+	 */
+	public function test_administrator_can_activate_theme(): void {
+		$product_key = $this->stage_inactive_theme_subscription();
+
+		$admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $admin_id );
+
+		$response = $this->dispatch_activate( $product_key );
+
+		$this->assertTrue( $response['success'] );
+		$this->assertSame( self::INACTIVE_THEME_SLUG, get_stylesheet() );
+	}
+
+	/**
+	 * @testdox A subscription with an unsupported product type cannot be activated, even by an Administrator.
+	 */
+	public function test_unsupported_product_type_is_rejected(): void {
+		$product_key = 'test-activate-key';
+		set_transient(
+			'_woocommerce_helper_subscriptions',
+			array(
+				array(
+					'product_id'   => 999002,
+					'product_key'  => $product_key,
+					'product_type' => 'unknown',
+					'zip_slug'     => self::INACTIVE_PLUGIN_SLUG,
+					'connections'  => array(),
+				),
+			),
+			HOUR_IN_SECONDS
+		);
+
+		$admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $admin_id );
+
+		$response = $this->dispatch_activate( $product_key );
+
+		$this->assertFalse( $response['success'] );
+		$this->assertIsArray( $response['data'] );
+		$this->assertStringContainsString( 'not supported', $response['data']['message'] );
+	}
+}