Commit 13b8328a003 for woocommerce

commit 13b8328a003e7d9a1b9958a0ce4882a76d0d8a1b
Author: Albert Juhé Lluveras <contact@albertjuhe.com>
Date:   Mon Sep 7 12:32:09 2026 +0200

    Exclude password-protected product reviews from the Store API (#68375)

    * Add changelog

    * Exclude password-protected product reviews from the Store API

    * Make sure total headers are correct

    * Return early if product has no reviews

    * Optimize query

    * Use an unlock list instead of inaccessible list

    * Optimize loop

    * Update docs

    * Return early if the user has not password cookies

    * Add extra guards

diff --git a/docs/apis/store-api/resources-endpoints/product-reviews.md b/docs/apis/store-api/resources-endpoints/product-reviews.md
index 18068ae0bef..bd6d5e24371 100644
--- a/docs/apis/store-api/resources-endpoints/product-reviews.md
+++ b/docs/apis/store-api/resources-endpoints/product-reviews.md
@@ -58,3 +58,11 @@ curl "https://example-store.com/wp-json/wc/store/v1/products/collection-data?cal
 	}
 ]
 ```
+
+### Password-protected products
+
+Reviews of password-protected products are excluded until the visitor has submitted the correct password. They are omitted from both the collection and the pagination totals.
+
+Password verification uses WordPress's native `wp-postpass_*` cookie, set when a user submits the password form on the frontend. The Store API does not accept passwords directly.
+
+A request that targets a locked product via `product_id` returns an empty collection with status `200`.
diff --git a/plugins/woocommerce/changelog/fix-WOO6-151-exclude-password-protected-product-reviews-from-rest-api b/plugins/woocommerce/changelog/fix-WOO6-151-exclude-password-protected-product-reviews-from-rest-api
new file mode 100644
index 00000000000..6b55b31f84e
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-WOO6-151-exclude-password-protected-product-reviews-from-rest-api
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Exclude password-protected product reviews from the Store API
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/ProductReviews.php b/plugins/woocommerce/src/StoreApi/Routes/V1/ProductReviews.php
index d05c04f48df..52fd6d6e8e5 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/ProductReviews.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/ProductReviews.php
@@ -111,33 +111,117 @@ class ProductReviews extends AbstractRoute {
 			$prepared_args['offset'] = $prepared_args['number'] * ( absint( $request['page'] ) - 1 );
 		}

-		$query            = new WP_Comment_Query();
-		$query_result     = $query->query( $prepared_args );
-		$response_objects = array();
+		$unlocked_product_ids               = $this->get_unlocked_password_protected_product_ids( $prepared_args['post__in'] ?? array() );
+		$exclude_password_protected_reviews = function ( $clauses ) use ( $unlocked_product_ids ) {
+			return $this->exclude_password_protected_product_reviews( $clauses, $unlocked_product_ids );
+		};
+
+		$query_result  = array();
+		$total_reviews = 0;
+		$max_pages     = 0;
+
+		add_filter( 'comments_clauses', $exclude_password_protected_reviews );
+		try {
+			$query        = new WP_Comment_Query();
+			$query_result = $query->query( $prepared_args );
+
+			$total_reviews = (int) $query->found_comments;
+			$max_pages     = (int) $query->max_num_pages;
+
+			if ( $total_reviews < 1 ) {
+				// Out-of-bounds, run the query again without LIMIT for total count.
+				unset( $prepared_args['number'], $prepared_args['offset'] );

+				$query                  = new WP_Comment_Query();
+				$prepared_args['count'] = true;
+
+				$total_reviews = $query->query( $prepared_args );
+				$max_pages     = $request['per_page'] ? ceil( $total_reviews / $request['per_page'] ) : 1;
+			}
+		} finally {
+			remove_filter( 'comments_clauses', $exclude_password_protected_reviews );
+		}
+
+		$response_objects = array();
 		foreach ( $query_result as $review ) {
 			$data               = $this->prepare_item_for_response( $review, $request );
 			$response_objects[] = $this->prepare_response_for_collection( $data );
 		}

-		$total_reviews = (int) $query->found_comments;
-		$max_pages     = (int) $query->max_num_pages;
+		$response = rest_ensure_response( $response_objects );
+		$response = ( new Pagination() )->add_headers( $response, $request, $total_reviews, $max_pages );

-		if ( $total_reviews < 1 ) {
-			// Out-of-bounds, run the query again without LIMIT for total count.
-			unset( $prepared_args['number'], $prepared_args['offset'] );
+		return $response;
+	}

-			$query                  = new WP_Comment_Query();
-			$prepared_args['count'] = true;
+	/**
+	 * Restrict the comment query to products the visitor can access.
+	 *
+	 * WP_Comment_Query already joins posts because of post_status. Filter on that
+	 * join instead of building a post__not_in list of every protected product.
+	 *
+	 * @param array|mixed $clauses              Comment query clauses from comments_clauses.
+	 * @param int[]       $unlocked_product_ids Password-protected product IDs the visitor has unlocked.
+	 * @return array|mixed
+	 */
+	private function exclude_password_protected_product_reviews( $clauses, $unlocked_product_ids ) {
+		global $wpdb;

-			$total_reviews = $query->query( $prepared_args );
-			$max_pages     = $request['per_page'] ? ceil( $total_reviews / $request['per_page'] ) : 1;
+		if ( ! is_array( $clauses ) ) {
+			return $clauses;
 		}

-		$response = rest_ensure_response( $response_objects );
-		$response = ( new Pagination() )->add_headers( $response, $request, $total_reviews, $max_pages );
+		$where = " {$wpdb->posts}.post_password = '' ";
+		if ( ! empty( $unlocked_product_ids ) ) {
+			$ids   = implode( ',', array_map( 'absint', $unlocked_product_ids ) );
+			$where = " ( {$wpdb->posts}.post_password = '' OR {$wpdb->posts}.ID IN ({$ids}) ) ";
+		}

-		return $response;
+		$clauses['where']  = is_string( $clauses['where'] ?? null ) ? $clauses['where'] : '';
+		$clauses['where'] .= ( trim( $clauses['where'] ) ? ' AND ' : '' ) . $where;
+
+		return $clauses;
+	}
+
+	/**
+	 * Password-protected product IDs the visitor has unlocked.
+	 *
+	 * @param int[] $candidate_product_ids Product IDs already limiting the review query, if any.
+	 * @return int[]
+	 */
+	private function get_unlocked_password_protected_product_ids( $candidate_product_ids = array() ) {
+		// Return early if the visitor has not submitted the password form and there is no filter in `post_password_required`.
+		if ( ( ! defined( 'COOKIEHASH' ) || ! isset( $_COOKIE[ 'wp-postpass_' . COOKIEHASH ] ) ) && ! has_filter( 'post_password_required' ) ) {
+			return array();
+		}
+
+		$query_args = array(
+			'post_type'              => 'product',
+			'post_status'            => ProductStatus::PUBLISH,
+			'has_password'           => true,
+			'comment_count'          => array(
+				'value'   => 0,
+				'compare' => '!=',
+			),
+			'posts_per_page'         => -1,
+			'orderby'                => 'none',
+			'no_found_rows'          => true,
+			'update_post_meta_cache' => false,
+			'update_post_term_cache' => false,
+		);
+
+		if ( ! empty( $candidate_product_ids ) ) {
+			$query_args['post__in'] = $candidate_product_ids;
+		}
+
+		$unlocked_ids = array();
+		foreach ( get_posts( $query_args ) as $product ) {
+			if ( $product instanceof \WP_Post && ! post_password_required( $product ) ) {
+				$unlocked_ids[] = (int) $product->ID;
+			}
+		}
+
+		return $unlocked_ids;
 	}

 	/**
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 92b3d57e544..b566d837ca9 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductReviews.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ProductReviews.php
@@ -121,6 +121,86 @@ class ProductReviews extends ControllerTestCase {
 		$this->assertSame( 0, (int) $targeted->get_headers()['X-WP-Total'] );
 	}

+	/**
+	 * @testdox Reviews are not returned for password-protected products.
+	 */
+	public function test_reviews_are_excluded_for_password_protected_products(): void {
+		$fixtures          = new FixtureData();
+		$protected_product = $fixtures->get_simple_product(
+			array(
+				'name'          => 'Password Protected Review Product',
+				'regular_price' => 10,
+			)
+		);
+		$fixtures->add_product_review( $protected_product->get_id(), 5, 'Hidden review' );
+
+		$protected_product->set_post_password( 'secret' );
+		$protected_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() );
+		$this->assertCount( 2, $product_ids );
+		$this->assertSame( 2, (int) $response->get_headers()['X-WP-Total'] );
+		$this->assertContains( $this->products[0]->get_id(), $product_ids );
+		$this->assertContains( $this->products[1]->get_id(), $product_ids );
+		$this->assertNotContains( $protected_product->get_id(), $product_ids );
+
+		$request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/reviews' );
+		$request->set_param( 'product_id', (string) $protected_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'] );
+	}
+
+	/**
+	 * @testdox Reviews of password-protected products are returned when the visitor has the password.
+	 */
+	public function test_reviews_are_returned_for_password_protected_products_when_password_is_known(): void {
+		$fixtures          = new FixtureData();
+		$password          = 'secret';
+		$protected_product = $fixtures->get_simple_product(
+			array(
+				'name'          => 'Password Protected Review Product',
+				'regular_price' => 10,
+			)
+		);
+		$fixtures->add_product_review( $protected_product->get_id(), 3, 'Visible with password' );
+
+		$protected_product->set_post_password( $password );
+		$protected_product->save();
+
+		require_once ABSPATH . WPINC . '/class-phpass.php';
+		$hasher                                 = new \PasswordHash( 8, true );
+		$_COOKIE[ 'wp-postpass_' . COOKIEHASH ] = $hasher->HashPassword( $password );
+
+		try {
+			$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' );
+
+			$request = new \WP_REST_Request( 'GET', '/wc/store/v1/products/reviews' );
+			$request->set_param( 'product_id', (string) $protected_product->get_id() );
+			$targeted = rest_get_server()->dispatch( $request );
+			$data     = $targeted->get_data();
+		} finally {
+			unset( $_COOKIE[ 'wp-postpass_' . COOKIEHASH ] );
+		}
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertCount( 3, $product_ids );
+		$this->assertSame( 3, (int) $response->get_headers()['X-WP-Total'] );
+		$this->assertContains( $protected_product->get_id(), $product_ids );
+
+		$this->assertSame( 200, $targeted->get_status() );
+		$this->assertCount( 1, $data );
+		$this->assertSame( 1, (int) $targeted->get_headers()['X-WP-Total'] );
+		$this->assertSame( $protected_product->get_id(), $data[0]['product_id'] );
+		$this->assertSame( 3, $data[0]['rating'] );
+	}
+
 	/**
 	 * Test getting reviews with specific order and per_page parameters.
 	 */