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.
*/