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