Commit 9e3811be8fe for woocommerce
commit 9e3811be8fe985c2f812ac46d83a300d70f1aa4d
Author: Lucio Giannotta <lucio.giannotta@a8c.com>
Date: Thu Nov 5 14:04:07 2026 -0500
Fix verified-owner review check for REST and XML-RPC comments (#70592)
diff --git a/plugins/woocommerce/changelog/fix-verified-owner-review-routes b/plugins/woocommerce/changelog/fix-verified-owner-review-routes
new file mode 100644
index 00000000000..fd4985f7f90
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-verified-owner-review-routes
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Apply the "verified owners" review restriction to product comments sent through the REST API and XML-RPC.
diff --git a/plugins/woocommerce/includes/class-wc-comments.php b/plugins/woocommerce/includes/class-wc-comments.php
index 78153d14b02..574e89d6ca0 100644
--- a/plugins/woocommerce/includes/class-wc-comments.php
+++ b/plugins/woocommerce/includes/class-wc-comments.php
@@ -88,6 +88,7 @@ class WC_Comments {
// Validate product reviews if requires verified owners.
add_action( 'pre_comment_on_post', array( __CLASS__, 'validate_product_review_verified_owners' ) );
+ add_filter( 'pre_comment_approved', array( __CLASS__, 'handle_pre_comment_approved' ), 99, 2 );
}
/**
@@ -667,18 +668,7 @@ class WC_Comments {
* @param int $comment_post_id Post ID.
*/
public static function validate_product_review_verified_owners( $comment_post_id ) {
- // Only validate if option is enabled.
- if ( 'yes' !== get_option( 'woocommerce_review_rating_verification_required' ) ) {
- return;
- }
-
- // Validate only products.
- if ( 'product' !== get_post_type( $comment_post_id ) ) {
- return;
- }
-
- // Skip if is a verified owner.
- if ( wc_customer_bought_product( '', get_current_user_id(), $comment_post_id ) ) {
+ if ( ! self::is_review_blocked_for_current_user( absint( $comment_post_id ) ) ) {
return;
}
@@ -686,11 +676,54 @@ class WC_Comments {
esc_html__( 'Only logged in customers who have purchased this product may leave a review.', 'woocommerce' ),
esc_html__( 'Reviews can only be left by "verified owners"', 'woocommerce' ),
array(
- 'code' => 403,
+ 'response' => 403,
)
);
}
+ /**
+ * Apply the verified-owner rule to product comments that skip the review form, such as REST and XML-RPC.
+ *
+ * Review-type comments are skipped: only the review form, checked above, and WooCommerce's own review
+ * endpoints create them. REST refuses other comment types and XML-RPC sends none.
+ *
+ * @internal
+ *
+ * @param int|string|WP_Error $approved Approval status, or an error from an earlier check.
+ * @param array $comment_data Comment data.
+ * @return int|string|WP_Error
+ */
+ public static function handle_pre_comment_approved( $approved, $comment_data ) {
+ if ( is_wp_error( $approved ) || ! is_array( $comment_data ) || ! self::is_default_comment_type( $comment_data['comment_type'] ?? '' ) ) {
+ return $approved;
+ }
+
+ $comment_post_id = absint( $comment_data['comment_post_ID'] ?? 0 );
+ if ( ! self::is_review_blocked_for_current_user( $comment_post_id ) || current_user_can( 'edit_post', $comment_post_id ) ) {
+ return $approved;
+ }
+
+ return new WP_Error(
+ 'woocommerce_review_verified_owner_required',
+ __( 'Only logged in customers who have purchased this product may leave a review.', 'woocommerce' ),
+ array( 'status' => 403 )
+ );
+ }
+
+ /**
+ * Whether the "verified owners" review setting stops the current user from commenting on a product.
+ *
+ * Checks the logged-in user, never the author name or email sent with the comment.
+ *
+ * @param int $post_id Post ID.
+ * @return bool
+ */
+ private static function is_review_blocked_for_current_user( int $post_id ): bool {
+ return 'yes' === get_option( 'woocommerce_review_rating_verification_required' )
+ && 'product' === get_post_type( $post_id )
+ && ! wc_customer_bought_product( '', get_current_user_id(), $post_id );
+ }
+
/**
* Determines if a comment is of the default type.
*
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index cb7eeeca08b..45179d683ef 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -10512,12 +10512,6 @@ parameters:
count: 1
path: includes/class-wc-comments.php
- -
- message: '#^Parameter \#3 \$args of function wp_die expects array\{response\?\: int, link_url\?\: string, link_text\?\: string, back_link\?\: bool, text_direction\?\: string, charset\?\: string, code\?\: string, exit\?\: bool\}\|int, array\{code\: 403\} given\.$#'
- identifier: argument.type
- count: 1
- path: includes/class-wc-comments.php
-
-
message: '#^Parameter \#3 \$product_id of function wc_customer_bought_product expects int, string given\.$#'
identifier: argument.type
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-comments-test.php b/plugins/woocommerce/tests/php/includes/class-wc-comments-test.php
index fa667b846c6..bfc8e393d40 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-comments-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-comments-test.php
@@ -1,6 +1,7 @@
<?php
declare( strict_types=1 );
+use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\RestApi\UnitTests\Helpers\ProductHelper;
/**
@@ -295,4 +296,208 @@ class WC_Comments_Tests extends \WC_Unit_Test_Case {
$product->delete( true );
}
}
+
+ /**
+ * @testdox handle_pre_comment_approved() refuses default-type product comments from non-buyers only while the setting is on.
+ *
+ * @testWith ["yes", "non_buyer", "product", "comment", true]
+ * ["yes", "non_buyer", "product", "", true]
+ * ["yes", "guest", "product", "comment", true]
+ * ["yes", "buyer", "product", "comment", false]
+ * ["yes", "product_editor", "product", "comment", false]
+ * ["yes", "non_buyer", "product", "review", false]
+ * ["yes", "non_buyer", "post", "comment", false]
+ * ["no", "non_buyer", "product", "comment", false]
+ *
+ * @param string $setting Value of the verified-owner setting.
+ * @param string $reviewer Kind of user submitting the comment.
+ * @param string $post_type Post type receiving the comment.
+ * @param string $comment_type Comment type being submitted.
+ * @param bool $expect_refusal Whether the comment should be refused.
+ */
+ public function test_pre_comment_approved_applies_verified_owner_setting( string $setting, string $reviewer, string $post_type, string $comment_type, bool $expect_refusal ): void {
+ update_option( 'woocommerce_review_rating_verification_required', $setting );
+ $product = WC_Helper_Product::create_simple_product();
+ $post_id = 'product' === $post_type ? $product->get_id() : self::factory()->post->create();
+ wp_set_current_user( $this->create_reviewer( $reviewer, $product ) );
+
+ $result = WC_Comments::handle_pre_comment_approved(
+ 1,
+ array(
+ 'comment_post_ID' => $post_id,
+ 'comment_type' => $comment_type,
+ )
+ );
+
+ if ( $expect_refusal ) {
+ $this->assertWPError( $result, 'The comment should have been refused.' );
+ $this->assertSame( 'woocommerce_review_verified_owner_required', $result->get_error_code() );
+ $this->assertSame( array( 'status' => 403 ), $result->get_error_data() );
+ } else {
+ $this->assertSame( 1, $result, 'The approval status should pass through unchanged.' );
+ }
+ }
+
+ /**
+ * @testdox The WordPress REST comments route refuses product comments from non-buyers, even under a buyer's name and email.
+ *
+ * @testWith ["non_buyer", false, 403]
+ * ["non_buyer", true, 403]
+ * ["buyer", false, 201]
+ *
+ * @param string $reviewer Kind of user sending the request.
+ * @param bool $use_buyer_email Whether the request names another customer, who bought the product, as the author.
+ * @param int $expected_status Expected HTTP status.
+ */
+ public function test_rest_comments_route_applies_verified_owner_setting( string $reviewer, bool $use_buyer_email, int $expected_status ): void {
+ update_option( 'woocommerce_review_rating_verification_required', 'yes' );
+ $product = WC_Helper_Product::create_simple_product();
+ wp_set_current_user( $this->create_reviewer( $reviewer, $product ) );
+
+ $request = new WP_REST_Request( 'POST', '/wp/v2/comments' );
+ $request->set_param( 'post', $product->get_id() );
+ $request->set_param( 'content', 'A product review sent through the REST API.' );
+ if ( $use_buyer_email ) {
+ $buyer = get_userdata( $this->create_reviewer( 'buyer', $product ) );
+ $request->set_param( 'author_name', $buyer->display_name );
+ $request->set_param( 'author_email', $buyer->user_email );
+ }
+
+ $server = $this->create_rest_server_with_routes(
+ array(
+ static function () {
+ ( new WP_REST_Comments_Controller() )->register_routes();
+ },
+ )
+ );
+ try {
+ $response = $server->dispatch( $request );
+ } finally {
+ $this->clear_rest_server();
+ }
+
+ $this->assertSame( $expected_status, $response->get_status() );
+ if ( 403 === $expected_status ) {
+ $this->assertSame( 'woocommerce_review_verified_owner_required', $response->get_data()['code'] );
+ }
+ $this->assertSame( 201 === $expected_status ? 1 : 0, $this->count_comments( $product->get_id() ), 'Only an accepted comment should be stored.' );
+ }
+
+ /**
+ * @testdox The XML-RPC wp.newComment method refuses product comments from non-buyers.
+ *
+ * @testWith ["non_buyer", false]
+ * ["buyer", true]
+ *
+ * @param string $reviewer Kind of user calling the method.
+ * @param bool $expect_comment Whether the comment should be stored.
+ */
+ public function test_xmlrpc_new_comment_applies_verified_owner_setting( string $reviewer, bool $expect_comment ): void {
+ require_once ABSPATH . WPINC . '/class-IXR.php';
+ require_once ABSPATH . WPINC . '/class-wp-xmlrpc-server.php';
+
+ update_option( 'woocommerce_review_rating_verification_required', 'yes' );
+ $product = WC_Helper_Product::create_simple_product();
+ $user = get_userdata( $this->create_reviewer( $reviewer, $product ) );
+
+ $result = ( new wp_xmlrpc_server() )->wp_newComment(
+ array( 1, $user->user_login, 'password', $product->get_id(), array( 'content' => 'A product review sent through XML-RPC.' ) )
+ );
+
+ if ( $expect_comment ) {
+ $this->assertIsInt( $result, 'A buyer should be able to comment through XML-RPC.' );
+ } else {
+ $this->assertInstanceOf( IXR_Error::class, $result );
+ $this->assertSame( 403, $result->code );
+ $this->assertSame( 'Only logged in customers who have purchased this product may leave a review.', $result->message );
+ }
+ $this->assertSame( $expect_comment ? 1 : 0, $this->count_comments( $product->get_id() ), 'Only an accepted comment should be stored.' );
+ }
+
+ /**
+ * @testdox The review form stores a buyer's review and refuses a non-buyer with a 403.
+ *
+ * @testWith ["buyer"]
+ * ["non_buyer"]
+ *
+ * @param string $reviewer Kind of user submitting the form.
+ */
+ public function test_review_form_applies_verified_owner_setting( string $reviewer ): void {
+ update_option( 'woocommerce_review_rating_verification_required', 'yes' );
+ $product = WC_Helper_Product::create_simple_product();
+ wp_set_current_user( $this->create_reviewer( $reviewer, $product ) );
+ $_POST['comment_post_ID'] = (string) $product->get_id();
+ $_POST['rating'] = '5';
+
+ if ( 'non_buyer' === $reviewer ) {
+ $this->expectException( WPDieException::class );
+ $this->expectExceptionMessage( 'Only logged in customers who have purchased this product may leave a review.' );
+ $this->expectExceptionCode( 403 );
+ }
+
+ try {
+ $comment = wp_handle_comment_submission(
+ array(
+ 'comment_post_ID' => $product->get_id(),
+ 'comment' => 'A product review sent through the review form.',
+ )
+ );
+ } finally {
+ unset( $_POST['comment_post_ID'], $_POST['rating'] );
+ }
+
+ $this->assertInstanceOf( WP_Comment::class, $comment, 'A buyer should be able to use the review form.' );
+ $this->assertSame( 'review', $comment->comment_type );
+ }
+
+ /**
+ * Create a user to submit product comments, or return 0 for a guest.
+ *
+ * @param string $kind One of "buyer", "non_buyer", "product_editor" or "guest".
+ * @param WC_Product $product Product that a buyer has a completed order for.
+ * @return int User ID.
+ */
+ private function create_reviewer( string $kind, WC_Product $product ): int {
+ if ( 'guest' === $kind ) {
+ return 0;
+ }
+
+ $user = self::factory()->user->create_and_get(
+ array(
+ 'role' => 'customer',
+ 'user_pass' => 'password',
+ )
+ );
+
+ if ( 'product_editor' === $kind ) {
+ $user->add_cap( 'edit_products' );
+ $user->add_cap( 'edit_others_products' );
+ $user->add_cap( 'edit_published_products' );
+ }
+
+ if ( 'buyer' === $kind ) {
+ $order = wc_create_order( array( 'customer_id' => $user->ID ) );
+ $order->add_product( $product );
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+ }
+
+ return $user->ID;
+ }
+
+ /**
+ * Count the comments on a post, approved or held for moderation.
+ *
+ * @param int $post_id Post ID.
+ * @return int
+ */
+ private function count_comments( int $post_id ): int {
+ return (int) get_comments(
+ array(
+ 'post_id' => $post_id,
+ 'status' => 'all',
+ 'count' => true,
+ )
+ );
+ }
}