Commit b316aa0d711 for woocommerce
commit b316aa0d711adcf4910615e5a234d075a11977eb
Author: Jorge A. Torres <jorge.torres@automattic.com>
Date: Wed Jul 22 15:41:35 2026 +0100
Cherry pick 406 (#66881)
Apply WordPress's link-count and moderation-keyword checks to order reviews
diff --git a/plugins/woocommerce/changelog/fix-order-review-moderation-bypass b/plugins/woocommerce/changelog/fix-order-review-moderation-bypass
new file mode 100644
index 00000000000..da381485ad9
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-order-review-moderation-bypass
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Tighten moderation checks on order reviews.
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php b/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php
index 16da25f6e05..5cc78a253fd 100644
--- a/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php
+++ b/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php
@@ -127,7 +127,6 @@ class SubmissionHandler {
$author_email = $order->get_billing_email();
$author_ip = $order->get_customer_ip_address();
$author_agent = $order->get_customer_user_agent();
- $require_mod = (bool) get_option( 'comment_moderation' );
// Drop any per-request memoisation a prior caller may have populated,
// then preload the eligibility cache so the per-row decide() calls
@@ -222,12 +221,20 @@ class SubmissionHandler {
$existing = $decision['comment'] instanceof \WP_Comment ? $decision['comment'] : null;
if ( $existing instanceof \WP_Comment ) {
+ // A moderator's spam/trash decision is final.
+ if ( in_array( wp_get_comment_status( $existing ), array( 'spam', 'trash' ), true ) ) {
+ $result['error'] = 'update_failed';
+ $results[ $row_index ] = $result;
+ continue;
+ }
+
+ $approved = self::comment_approval_status( $author_name, $author_email, $text, $author_ip, $author_agent );
$update_ok = wp_update_comment(
wp_slash(
array(
'comment_ID' => (int) $existing->comment_ID,
'comment_content' => $text,
- 'comment_approved' => $require_mod ? 0 : 1,
+ 'comment_approved' => $approved,
)
)
);
@@ -240,11 +247,19 @@ class SubmissionHandler {
update_comment_meta( (int) $existing->comment_ID, 'rating', $rating );
$result['comment_id'] = (int) $existing->comment_ID;
- $result['status'] = $require_mod ? 'pending_moderation' : 'ok';
+ $result['status'] = 1 === $approved ? 'ok' : 'pending_moderation';
+ $results[ $row_index ] = $result;
+ continue;
+ }
+
+ if ( self::has_rejected_review( $order, $line_product_id, $line_variation_id ) ) {
+ $result['error'] = 'update_failed';
$results[ $row_index ] = $result;
continue;
}
+ $approved = self::comment_approval_status( $author_name, $author_email, $text, $author_ip, $author_agent );
+
$comment_data = array(
'comment_post_ID' => $review_post_id,
'comment_author' => '' !== $author_name ? $author_name : __( 'Anonymous', 'woocommerce' ),
@@ -253,7 +268,7 @@ class SubmissionHandler {
'comment_agent' => $author_agent,
'comment_content' => $text,
'comment_type' => 'review',
- 'comment_approved' => $require_mod ? 0 : 1,
+ 'comment_approved' => $approved,
'user_id' => $comment_user_id,
);
@@ -275,13 +290,70 @@ class SubmissionHandler {
}
$result['comment_id'] = (int) $comment_id;
- $result['status'] = $require_mod ? 'pending_moderation' : 'ok';
+ $result['status'] = 1 === $approved ? 'ok' : 'pending_moderation';
$results[ $row_index ] = $result;
}//end foreach
return $results;
}
+ /**
+ * Decide whether a review should be auto-approved, via WordPress's own `check_comment()`.
+ *
+ * @param string $author Comment author name.
+ * @param string $email Comment author email.
+ * @param string $content Comment content.
+ * @param string $ip Comment author IP.
+ * @param string $agent Comment author user agent.
+ * @return int 1 to auto-approve, 0 to hold for moderation.
+ */
+ private static function comment_approval_status( string $author, string $email, string $content, string $ip, string $agent ): int {
+ add_filter( 'pre_option_comment_previously_approved', '__return_zero' );
+ $approved = check_comment( $author, $email, '', $content, $ip, $agent, 'review' );
+ remove_filter( 'pre_option_comment_previously_approved', '__return_zero' );
+
+ return $approved ? 1 : 0;
+ }
+
+ /**
+ * Whether a moderator already marked this exact order/product/variation
+ * review as spam or trash.
+ *
+ * @param WC_Order $order Order being reviewed.
+ * @param int $product_id Parent product id.
+ * @param int $variation_id Variation id (0 for simple products).
+ * @return bool
+ */
+ private static function has_rejected_review( WC_Order $order, int $product_id, int $variation_id ): bool {
+ $email = $order->get_billing_email();
+ if ( '' === $email ) {
+ return false;
+ }
+
+ $comments = get_comments(
+ array(
+ 'post_id' => $product_id,
+ 'author_email' => $email,
+ 'type' => 'review',
+ 'status' => array( 'spam', 'trash' ),
+ 'number' => 1,
+ 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded by post_id + author_email.
+ 'relation' => 'AND',
+ array(
+ 'key' => ItemEligibility::ORDER_META_KEY,
+ 'value' => (string) $order->get_id(),
+ ),
+ array(
+ 'key' => ItemEligibility::VARIATION_META_KEY,
+ 'value' => (string) $variation_id,
+ ),
+ ),
+ )
+ );
+
+ return is_array( $comments ) && ! empty( $comments );
+ }
+
/**
* Set the completed-at meta when every eligible item has a review by this
* customer (approved or pending moderation), whether posted in this
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SubmissionHandlerTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SubmissionHandlerTest.php
index 0201095fcaa..098decab80d 100644
--- a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SubmissionHandlerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/SubmissionHandlerTest.php
@@ -30,6 +30,8 @@ class SubmissionHandlerTest extends WC_Unit_Test_Case {
public function tearDown(): void {
$_POST = array();
update_option( 'comment_moderation', '0' );
+ update_option( 'comment_max_links', 2 );
+ update_option( 'moderation_keys', '' );
remove_all_filters( 'woocommerce_review_order_submitted' );
remove_all_filters( 'woocommerce_review_order_eligible_statuses' );
remove_all_filters( 'woocommerce_review_order_eligible_items' );
@@ -376,6 +378,122 @@ class SubmissionHandlerTest extends WC_Unit_Test_Case {
$this->assertSame( '0', $comment->comment_approved );
}
+ /**
+ * @testdox A review with more links than comment_max_links is held for moderation, like an ordinary comment.
+ */
+ public function test_link_count_over_limit_is_held(): void {
+ update_option( 'comment_max_links', 2 );
+
+ $built = $this->make_order( 1 );
+ $order = $built['order'];
+ $product_id = $built['product_ids'][0];
+ $item_id = $built['item_ids'][0];
+
+ $_POST = array(
+ 'order_id' => $order->get_id(),
+ 'key' => $order->get_order_key(),
+ '_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+ 'reviews' => array(
+ array(
+ 'product_id' => $product_id,
+ 'order_item_id' => $item_id,
+ 'rating' => 5,
+ 'text' => 'Great deal, see <a href="https://spam.test/a">here</a> and <a href="https://spam.test/b">here</a>.',
+ ),
+ ),
+ );
+
+ $response = $this->dispatch();
+ $row = reset( $response['data']['results'] );
+ $this->assertSame( 'pending_moderation', $row['status'] );
+
+ $comment = get_comment( $row['comment_id'] );
+ $this->assertSame( '0', $comment->comment_approved );
+ }
+
+ /**
+ * @testdox A review matching a moderation keyword is held for moderation, like an ordinary comment.
+ */
+ public function test_moderation_keyword_match_is_held(): void {
+ update_option( 'moderation_keys', "casino\nviagra" );
+
+ $built = $this->make_order( 1 );
+ $order = $built['order'];
+ $product_id = $built['product_ids'][0];
+ $item_id = $built['item_ids'][0];
+
+ $_POST = array(
+ 'order_id' => $order->get_id(),
+ 'key' => $order->get_order_key(),
+ '_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+ 'reviews' => array(
+ array(
+ 'product_id' => $product_id,
+ 'order_item_id' => $item_id,
+ 'rating' => 5,
+ 'text' => 'Won big at the casino thanks to this product!',
+ ),
+ ),
+ );
+
+ $response = $this->dispatch();
+ $row = reset( $response['data']['results'] );
+ $this->assertSame( 'pending_moderation', $row['status'] );
+
+ $comment = get_comment( $row['comment_id'] );
+ $this->assertSame( '0', $comment->comment_approved );
+ }
+
+ /**
+ * @testdox A second submission after a moderator's spam/trash verdict cannot auto-approve.
+ */
+ public function test_resubmit_after_spam_verdict_cannot_autoapprove(): void {
+ $built = $this->make_order( 1 );
+ $order = $built['order'];
+ $product_id = $built['product_ids'][0];
+ $item_id = $built['item_ids'][0];
+
+ $_POST = array(
+ 'order_id' => $order->get_id(),
+ 'key' => $order->get_order_key(),
+ '_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+ 'reviews' => array(
+ array(
+ 'product_id' => $product_id,
+ 'order_item_id' => $item_id,
+ 'rating' => 5,
+ 'text' => 'Original review text.',
+ ),
+ ),
+ );
+ $first = $this->dispatch();
+ $comment_id = (int) reset( $first['data']['results'] )['comment_id'];
+
+ // A moderator marks the review as spam.
+ wp_spam_comment( $comment_id );
+ $this->assertSame( 'spam', wp_get_comment_status( $comment_id ) );
+
+ // The customer resubmits the same row.
+ $_POST = array(
+ 'order_id' => $order->get_id(),
+ 'key' => $order->get_order_key(),
+ '_wcnonce' => wp_create_nonce( SubmissionHandler::ACTION ),
+ 'reviews' => array(
+ array(
+ 'product_id' => $product_id,
+ 'order_item_id' => $item_id,
+ 'rating' => 5,
+ 'text' => 'Trying again with clean text.',
+ ),
+ ),
+ );
+ $second = $this->dispatch();
+ $row = reset( $second['data']['results'] );
+
+ $this->assertSame( 'error', $row['status'] );
+ $this->assertSame( 'spam', wp_get_comment_status( $comment_id ), 'Moderator decision must stick; a second submission must not auto-approve.' );
+ }
+
/**
* @testdox Rows referencing a product not on the order fail per-row, others succeed.
*/