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