Commit 7f8786047f4 for woocommerce

commit 7f8786047f4a6553bbfb81e420bc78d5caab33f0
Author: Jorge A. Torres <jorge.torres@automattic.com>
Date:   Sat Jun 27 10:01:49 2026 +0100

    Cherry pick 448 (#66065)

diff --git a/plugins/woocommerce/changelog/fix-store-api-non-public-product-visibility b/plugins/woocommerce/changelog/fix-store-api-non-public-product-visibility
new file mode 100644
index 00000000000..143bf0d0031
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-store-api-non-public-product-visibility
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Hardens Store API product/review endpoints checks.
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/ProductReviews.php b/plugins/woocommerce/src/StoreApi/Routes/V1/ProductReviews.php
index a54b1f7357b..d05c04f48df 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/ProductReviews.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/ProductReviews.php
@@ -2,6 +2,7 @@
 namespace Automattic\WooCommerce\StoreApi\Routes\V1;

 use WP_Comment_Query;
+use Automattic\WooCommerce\Enums\ProductStatus;
 use Automattic\WooCommerce\StoreApi\Utilities\Pagination;

 /**
@@ -73,6 +74,8 @@ class ProductReviews extends AbstractRoute {
 			'order'         => $request['order'],
 			'number'        => $request['per_page'],
 			'post__in'      => $request['product_id'],
+			// Exclude reviews of non-published products.
+			'post_status'   => ProductStatus::PUBLISH,
 		);

 		/**
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsById.php b/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsById.php
index 6191c3c13eb..c1793638d8a 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsById.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsById.php
@@ -87,6 +87,15 @@ class ProductsById extends AbstractRoute {
 			throw new RouteException( 'woocommerce_rest_product_invalid_id', __( 'Invalid product ID.', 'woocommerce' ), 404 );
 		}

+		// A variation's visibility follows its parent product.
+		if ( $object->get_parent_id() ) {
+			$parent = wc_get_product( $object->get_parent_id() );
+
+			if ( ! $parent || ProductStatus::PUBLISH !== $parent->get_status() ) {
+				throw new RouteException( 'woocommerce_rest_product_invalid_id', __( 'Invalid product ID.', 'woocommerce' ), 404 ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- REST API JSON response, not HTML.
+			}
+		}
+
 		return $this->prepare_item_for_response( $object, $request );
 	}
 }
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsBySlug.php b/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsBySlug.php
index 544ae09855a..0296263c315 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsBySlug.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsBySlug.php
@@ -92,6 +92,15 @@ class ProductsBySlug extends AbstractRoute {
 			throw new RouteException( 'woocommerce_rest_product_invalid_slug', __( 'Invalid product slug.', 'woocommerce' ), 404 );
 		}

+		// A variation's visibility follows its parent product.
+		if ( $object->get_parent_id() ) {
+			$parent = wc_get_product( $object->get_parent_id() );
+
+			if ( ! $parent || ProductStatus::PUBLISH !== $parent->get_status() ) {
+				throw new RouteException( 'woocommerce_rest_product_invalid_slug', __( 'Invalid product slug.', 'woocommerce' ), 404 ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- REST API JSON response, not HTML.
+			}
+		}
+
 		return $this->prepare_item_for_response( $object, $request );
 	}

diff --git a/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php b/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php
index 6f7ea3ba335..8e446e6247b 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php
@@ -419,6 +419,17 @@ class ProductQuery implements QueryClausesGenerator {
 	public function add_query_clauses( array $args, \WP_Query $wp_query ): array {
 		global $wpdb;

+		// SKU and slug lookups can return variations, so exclude any whose parent product is not published.
+		if ( in_array( 'product_variation', (array) $wp_query->get( 'post_type' ), true ) ) {
+			$args['where'] .= $wpdb->prepare(
+				" AND ( {$wpdb->posts}.post_type != 'product_variation' OR EXISTS (
+					SELECT 1 FROM {$wpdb->posts} AS parent
+					WHERE parent.ID = {$wpdb->posts}.post_parent AND parent.post_status = %s
+				) ) ",
+				ProductStatus::PUBLISH
+			);
+		}
+
 		if ( $wp_query->get( 'search' ) ) {
 			$search         = '%' . $wpdb->esc_like( $wp_query->get( 'search' ) ) . '%';
 			$search_query   = wc_product_sku_enabled()
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductReviews.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductReviews.php
index e11497c5cbb..92b3d57e544 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductReviews.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductReviews.php
@@ -9,6 +9,7 @@ namespace Automattic\WooCommerce\Tests\Blocks\StoreApi\Routes;

 use Automattic\WooCommerce\Tests\Blocks\StoreApi\Routes\ControllerTestCase;
 use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
+use Automattic\WooCommerce\Enums\ProductStatus;

 /**
  * Product Reviews Controller Tests.
@@ -83,6 +84,43 @@ class ProductReviews extends ControllerTestCase {
 		$this->assertSame( 5, $data[1]['rating'] );
 	}

+	/**
+	 * @testdox Reviews are only returned for published products.
+	 */
+	public function test_reviews_are_limited_to_published_products() {
+		$fixtures      = new FixtureData();
+		$draft_product = $fixtures->get_simple_product(
+			array(
+				'name'          => 'Draft Review Product',
+				'regular_price' => 10,
+			)
+		);
+		$fixtures->add_product_review( $draft_product->get_id(), 5, 'Hidden review' );
+
+		// Product becomes non-public after the review exists.
+		$draft_product->set_status( ProductStatus::DRAFT );
+		$draft_product->save();
+
+		$response    = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/products/reviews' ) );
+		$product_ids = wp_list_pluck( $response->get_data(), 'product_id' );
+
+		$this->assertSame( 200, $response->get_status() );
+		// Reviews for the published products from setUp are still returned (regression)...
+		$this->assertContains( $this->products[0]->get_id(), $product_ids );
+		$this->assertContains( $this->products[1]->get_id(), $product_ids );
+		// ...but the non-public product's review is excluded.
+		$this->assertNotContains( $draft_product->get_id(), $product_ids );
+
+		// A targeted query for the non-public product returns nothing.
+		$request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/reviews' );
+		$request->set_param( 'product_id', (string) $draft_product->get_id() );
+		$targeted = rest_get_server()->dispatch( $request );
+
+		$this->assertSame( 200, $targeted->get_status() );
+		$this->assertCount( 0, $targeted->get_data() );
+		$this->assertSame( 0, (int) $targeted->get_headers()['X-WP-Total'] );
+	}
+
 	/**
 	 * Test getting reviews with specific order and per_page parameters.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Products.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Products.php
index fa952ecd8f2..b6305546af5 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Products.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Products.php
@@ -465,6 +465,62 @@ class Products extends ControllerTestCase {
 		$this->assertStringContainsString( '/wc/store/v1/products/' . $variable_product->get_id(), $links['up'][0]['href'] );
 	}

+	/**
+	 * @testdox A variation's visibility follows its parent product across ID, slug and SKU lookups.
+	 */
+	public function test_variation_visibility_follows_parent_product() {
+		$fixtures  = new FixtureData();
+		$attribute = FixtureData::get_product_attribute( 'color', array( 'red', 'blue' ) );
+
+		// Variation under a non-public (draft) parent. The variation itself stays published.
+		$hidden_parent    = $fixtures->get_variable_product( array( 'name' => 'Hidden Parent' ), array( $attribute ) );
+		$hidden_variation = $fixtures->get_variation_product(
+			$hidden_parent->get_id(),
+			array( 'pa_color' => 'red' ),
+			array(
+				'regular_price' => 10,
+				'sku'           => 'hidden-parent-variation',
+			)
+		);
+		$hidden_slug      = get_post_field( 'post_name', $hidden_variation->get_id() );
+		$hidden_parent->set_status( ProductStatus::DRAFT );
+		$hidden_parent->save();
+
+		// Variation under a published parent (regression).
+		$public_parent    = $fixtures->get_variable_product( array( 'name' => 'Public Parent' ), array( $attribute ) );
+		$public_variation = $fixtures->get_variation_product(
+			$public_parent->get_id(),
+			array( 'pa_color' => 'blue' ),
+			array(
+				'regular_price' => 10,
+				'sku'           => 'public-parent-variation',
+			)
+		);
+
+		// Hidden parent: the variation must not be exposed by ID, slug, or SKU/slug collection lookups.
+		$by_id   = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/products/' . $hidden_variation->get_id() ) );
+		$by_slug = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/products/' . $hidden_slug ) );
+		$this->assertEquals( 404, $by_id->get_status() );
+		$this->assertEquals( 404, $by_slug->get_status() );
+
+		$sku_request = new \WP_REST_Request( 'GET', '/wc/store/v1/products' );
+		$sku_request->set_param( 'sku', 'hidden-parent-variation' );
+		$this->assertCount( 0, rest_get_server()->dispatch( $sku_request )->get_data() );
+
+		$slug_request = new \WP_REST_Request( 'GET', '/wc/store/v1/products' );
+		$slug_request->set_param( 'slug', $hidden_slug );
+		$this->assertCount( 0, rest_get_server()->dispatch( $slug_request )->get_data() );
+
+		// Published parent: the variation is still returned.
+		$public_by_id = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/products/' . $public_variation->get_id() ) );
+		$this->assertEquals( 200, $public_by_id->get_status() );
+		$this->assertSame( $public_variation->get_id(), $public_by_id->get_data()['id'] );
+
+		$public_sku_request = new \WP_REST_Request( 'GET', '/wc/store/v1/products' );
+		$public_sku_request->set_param( 'sku', 'public-parent-variation' );
+		$this->assertCount( 1, rest_get_server()->dispatch( $public_sku_request )->get_data() );
+	}
+
 	/**
 	 * @testdox Product should always include embeddable related link using related parameter format.
 	 */