Commit 1e044ff258d for woocommerce

commit 1e044ff258d68aaad47eb428a159deda2180feec
Author: Darren Ethier <darren@roughsmootheng.in>
Date:   Fri Sep 4 16:17:40 2026 -0400

    Document public hooks and replace legacy output suppressions (#68301)

diff --git a/plugins/woocommerce/changelog/fix-woo6-121-hook-docs-and-output-suppressions b/plugins/woocommerce/changelog/fix-woo6-121-hook-docs-and-output-suppressions
new file mode 100644
index 00000000000..9f49c800667
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-woo6-121-hook-docs-and-output-suppressions
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Run the product title in the single product reviews heading through wp_kses_post and replace legacy WPCS output suppressions with documented hooks and exact PHPCS annotations.
diff --git a/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php b/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php
index e46cfed6a20..8b386efddd9 100644
--- a/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php
+++ b/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php
@@ -356,7 +356,18 @@ class WC_Admin_List_Table_Products extends WC_Admin_List_Table {
 				$termlist[] = '<a href="' . esc_url( admin_url( 'edit.php?product_cat=' . $term->slug . '&post_type=product' ) ) . '">' . esc_html( $term->name ) . '</a>';
 			}

-			echo apply_filters( 'woocommerce_admin_product_term_list', implode( ', ', $termlist ), 'product_cat', $this->object->get_id(), $termlist, $terms ); // WPCS: XSS ok.
+			/**
+			 * Filters the term list rendered in the products list table taxonomy column.
+			 *
+			 * @since 3.3.0
+			 *
+			 * @param string             $term_list  Comma separated list of escaped term links.
+			 * @param string             $taxonomy   Taxonomy the terms belong to.
+			 * @param int                $product_id ID of the product the terms are rendered for.
+			 * @param string[]           $termlist   Individual escaped term links.
+			 * @param WP_Term[]|WP_Error $terms      Term objects assigned to the product, or the WP_Error returned by get_the_terms().
+			 */
+			echo apply_filters( 'woocommerce_admin_product_term_list', implode( ', ', $termlist ), 'product_cat', $this->object->get_id(), $termlist, $terms ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Term names and links are escaped above; the filter returns extension-controlled markup by design.
 		}
 	}

@@ -373,7 +384,18 @@ class WC_Admin_List_Table_Products extends WC_Admin_List_Table {
 				$termlist[] = '<a href="' . esc_url( admin_url( 'edit.php?product_tag=' . $term->slug . '&post_type=product' ) ) . '">' . esc_html( $term->name ) . '</a>';
 			}

-			echo apply_filters( 'woocommerce_admin_product_term_list', implode( ', ', $termlist ), 'product_tag', $this->object->get_id(), $termlist, $terms ); // WPCS: XSS ok.
+			/**
+			 * Filters the term list rendered in the products list table taxonomy column.
+			 *
+			 * @since 3.3.0
+			 *
+			 * @param string             $term_list  Comma separated list of escaped term links.
+			 * @param string             $taxonomy   Taxonomy the terms belong to.
+			 * @param int                $product_id ID of the product the terms are rendered for.
+			 * @param string[]           $termlist   Individual escaped term links.
+			 * @param WP_Term[]|WP_Error $terms      Term objects assigned to the product, or the WP_Error returned by get_the_terms().
+			 */
+			echo apply_filters( 'woocommerce_admin_product_term_list', implode( ', ', $termlist ), 'product_tag', $this->object->get_id(), $termlist, $terms ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Term names and links are escaped above; the filter returns extension-controlled markup by design.
 		}
 	}

@@ -440,7 +462,14 @@ class WC_Admin_List_Table_Products extends WC_Admin_List_Table {
 		}
 		$output = ob_get_clean();

-		echo apply_filters( 'woocommerce_product_filters', $output ); // WPCS: XSS ok.
+		/**
+		 * Filters the rendered markup for the products list table filter controls.
+		 *
+		 * @since 2.1.0
+		 *
+		 * @param string|false $output Buffered markup produced by the registered filter renderers, or false when no output buffer was active.
+		 */
+		echo apply_filters( 'woocommerce_product_filters', $output ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $output is buffered admin markup and the filter returns extension-controlled markup by design.
 	}

 	/**
diff --git a/plugins/woocommerce/includes/admin/plugin-updates/class-wc-plugins-screen-updates.php b/plugins/woocommerce/includes/admin/plugin-updates/class-wc-plugins-screen-updates.php
index edc091946dc..b3f3e770052 100644
--- a/plugins/woocommerce/includes/admin/plugin-updates/class-wc-plugins-screen-updates.php
+++ b/plugins/woocommerce/includes/admin/plugin-updates/class-wc-plugins-screen-updates.php
@@ -68,7 +68,14 @@ class WC_Plugins_Screen_Updates extends WC_Plugin_Updates {
 			add_action( 'admin_print_footer_scripts', array( $this, 'plugin_screen_modal_js' ) );
 		}

-		echo apply_filters( 'woocommerce_in_plugin_update_message', $this->upgrade_notice ? '</p>' . wp_kses_post( $this->upgrade_notice ) . '<p class="dummy">' : '' ); // phpcs:ignore WordPress.XSS.EscapeOutput.OutputNotEscaped
+		/**
+		 * Filters the inline update message shown for WooCommerce on the Plugins screen.
+		 *
+		 * @since 3.1.0
+		 *
+		 * @param string $message Upgrade notice markup, already run through wp_kses_post(), or an empty string.
+		 */
+		echo apply_filters( 'woocommerce_in_plugin_update_message', $this->upgrade_notice ? '</p>' . wp_kses_post( $this->upgrade_notice ) . '<p class="dummy">' : '' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- The base value is run through wp_kses_post() and the filter returns extension-controlled markup by design.
 	}

 	/**
diff --git a/plugins/woocommerce/includes/wc-conditional-functions.php b/plugins/woocommerce/includes/wc-conditional-functions.php
index 964aa9947ab..6d8133b521a 100644
--- a/plugins/woocommerce/includes/wc-conditional-functions.php
+++ b/plugins/woocommerce/includes/wc-conditional-functions.php
@@ -26,6 +26,13 @@ if ( ! defined( 'ABSPATH' ) ) {
  * @return bool
  */
 function is_woocommerce() {
+	/**
+	 * Filters whether the current request is being handled by a WooCommerce template.
+	 *
+	 * @since 2.1.0
+	 *
+	 * @param bool $is_woocommerce True on the shop archive, a product taxonomy archive or a single product.
+	 */
 	return apply_filters( 'is_woocommerce', is_shop() || is_product_taxonomy() || is_product() );
 }

diff --git a/plugins/woocommerce/templates/single-product-reviews.php b/plugins/woocommerce/templates/single-product-reviews.php
index 55e9c88bc6c..9ada2a1cc85 100644
--- a/plugins/woocommerce/templates/single-product-reviews.php
+++ b/plugins/woocommerce/templates/single-product-reviews.php
@@ -12,7 +12,7 @@
  *
  * @see     https://woocommerce.com/document/template-structure/
  * @package WooCommerce\Templates
- * @version 9.7.0
+ * @version 11.2.0
  */

 defined( 'ABSPATH' ) || exit;
@@ -31,8 +31,17 @@ if ( ! comments_open() ) {
 			$count = $product->get_review_count();
 			if ( $count && wc_review_ratings_enabled() ) {
 				/* translators: 1: reviews count 2: product name */
-				$reviews_title = sprintf( esc_html( _n( '%1$s review for %2$s', '%1$s reviews for %2$s', $count, 'woocommerce' ) ), esc_html( $count ), '<span>' . get_the_title() . '</span>' );
-				echo apply_filters( 'woocommerce_reviews_title', $reviews_title, $count, $product ); // WPCS: XSS ok.
+				$reviews_title = sprintf( esc_html( _n( '%1$s review for %2$s', '%1$s reviews for %2$s', $count, 'woocommerce' ) ), esc_html( $count ), '<span>' . wp_kses_post( get_the_title() ) . '</span>' );
+				/**
+				 * Filters the heading shown above the product review list.
+				 *
+				 * @since 3.6.0
+				 *
+				 * @param string     $reviews_title Review count heading. The count is escaped, the product title is run through wp_kses_post() and wrapped in a span.
+				 * @param int        $count         Number of reviews for the product.
+				 * @param WC_Product $product       Product the reviews belong to.
+				 */
+				echo apply_filters( 'woocommerce_reviews_title', $reviews_title, $count, $product ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- The base heading is sanitised above and the filter returns extension-controlled markup by design.
 			} else {
 				esc_html_e( 'Reviews', 'woocommerce' );
 			}
diff --git a/plugins/woocommerce/templates/single-product/add-to-cart/variable.php b/plugins/woocommerce/templates/single-product/add-to-cart/variable.php
index 4b90434d29b..fbb77196546 100644
--- a/plugins/woocommerce/templates/single-product/add-to-cart/variable.php
+++ b/plugins/woocommerce/templates/single-product/add-to-cart/variable.php
@@ -23,10 +23,22 @@ $attribute_keys  = array_keys( $attributes );
 $variations_json = wp_json_encode( $available_variations );
 $variations_attr = function_exists( 'wc_esc_json' ) ? wc_esc_json( $variations_json ) : _wp_specialchars( $variations_json, ENT_QUOTES, 'UTF-8', true );

+/**
+ * Fires before the add to cart form of a variable product.
+ *
+ * @since 1.2.1
+ */
 do_action( 'woocommerce_before_add_to_cart_form' ); ?>

-<form class="variations_form cart" action="<?php echo esc_url( apply_filters( 'woocommerce_add_to_cart_form_action', $product->get_permalink() ) ); ?>" method="post" enctype='multipart/form-data' data-product_id="<?php echo absint( $product->get_id() ); ?>" data-product_variations="<?php echo $variations_attr; // WPCS: XSS ok. ?>">
-	<?php do_action( 'woocommerce_before_variations_form' ); ?>
+<form class="variations_form cart" action="<?php echo esc_url( apply_filters( 'woocommerce_add_to_cart_form_action', $product->get_permalink() ) ); ?>" method="post" enctype='multipart/form-data' data-product_id="<?php echo absint( $product->get_id() ); ?>" data-product_variations="<?php echo $variations_attr; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped, WooCommerce.Commenting.CommentHooks.MissingHookComment -- $variations_attr is already escaped for an HTML attribute by wc_esc_json() or _wp_specialchars(). A hook docblock for woocommerce_add_to_cart_form_action cannot end on the line above this inline call without leaving content before a closing PHP tag. ?>">
+	<?php
+	/**
+	 * Fires at the top of the variations form, inside the form element.
+	 *
+	 * @since 2.4.0
+	 */
+	do_action( 'woocommerce_before_variations_form' );
+	?>

 	<?php if ( empty( $available_variations ) && false !== $available_variations ) : ?>
 		<p class="stock out-of-stock"><?php echo esc_html( apply_filters( 'woocommerce_out_of_stock_message', __( 'This product is currently out of stock and unavailable.', 'woocommerce' ) ) ); ?></p>
@@ -70,6 +82,8 @@ do_action( 'woocommerce_before_add_to_cart_form' ); ?>
 			<?php
 				/**
 				 * Hook: woocommerce_before_single_variation.
+				 *
+				 * @since 2.1.0
 				 */
 				do_action( 'woocommerce_before_single_variation' );

@@ -84,14 +98,28 @@ do_action( 'woocommerce_before_add_to_cart_form' ); ?>

 				/**
 				 * Hook: woocommerce_after_single_variation.
+				 *
+				 * @since 2.1.0
 				 */
 				do_action( 'woocommerce_after_single_variation' );
 			?>
 		</div>
 	<?php endif; ?>

-	<?php do_action( 'woocommerce_after_variations_form' ); ?>
+	<?php
+	/**
+	 * Fires at the bottom of the variations form, inside the form element.
+	 *
+	 * @since 2.4.0
+	 */
+	do_action( 'woocommerce_after_variations_form' );
+	?>
 </form>

 <?php
+/**
+ * Fires after the add to cart form of a variable product.
+ *
+ * @since 1.2.1
+ */
 do_action( 'woocommerce_after_add_to_cart_form' );
diff --git a/plugins/woocommerce/tests/php/templates/single-product/SingleProductReviewsTemplateTest.php b/plugins/woocommerce/tests/php/templates/single-product/SingleProductReviewsTemplateTest.php
new file mode 100644
index 00000000000..73e65e38a64
--- /dev/null
+++ b/plugins/woocommerce/tests/php/templates/single-product/SingleProductReviewsTemplateTest.php
@@ -0,0 +1,78 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Templates\SingleProduct;
+
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the single product reviews template.
+ */
+class SingleProductReviewsTemplateTest extends WC_Unit_Test_Case {
+
+	/**
+	 * Product title mixing allowed markup with a disallowed event handler attribute.
+	 */
+	private const PRODUCT_TITLE = 'Widget <strong onclick="alert(1)">Deluxe</strong>';
+
+	/**
+	 * @testdox The reviews heading keeps allowed markup from the product title, drops disallowed attributes and hands the built heading to the filter.
+	 */
+	public function test_reviews_title_sanitises_product_title_and_passes_built_heading_to_filter(): void {
+		// An author with unfiltered_html can store the raw title, so the template has to sanitise it.
+		wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_name( self::PRODUCT_TITLE );
+		$product->set_review_count( 2 );
+		$product->save();
+
+		wp_update_post(
+			array(
+				'ID'             => $product->get_id(),
+				'comment_status' => 'open',
+			)
+		);
+
+		$this->go_to( get_permalink( $product->get_id() ) );
+
+		$previous_product   = $GLOBALS['product'] ?? null;
+		$GLOBALS['product'] = $product;
+
+		$received_title = null;
+		$title_filter   = static function ( $reviews_title ) use ( &$received_title ) {
+			$received_title = $reviews_title;
+			return $reviews_title;
+		};
+		add_filter( 'woocommerce_reviews_title', $title_filter );
+
+		try {
+			$html = wc_get_template_html( 'single-product-reviews.php' );
+		} finally {
+			remove_filter( 'woocommerce_reviews_title', $title_filter );
+			$GLOBALS['product'] = $previous_product;
+			WC_Helper_Product::delete_product( $product->get_id() );
+			wp_set_current_user( 0 );
+		}
+
+		preg_match( '#<h2 class="woocommerce-Reviews-title">(.*?)</h2>#s', $html, $heading_match );
+		$heading = $heading_match[1] ?? '';
+
+		$this->assertStringContainsString(
+			'<span>Widget <strong>Deluxe</strong></span>',
+			$heading,
+			'The reviews heading should keep markup that wp_kses_post() allows in the product title.'
+		);
+		$this->assertStringNotContainsString(
+			'onclick',
+			$heading,
+			'The reviews heading should drop attributes that wp_kses_post() disallows in the product title.'
+		);
+		$this->assertSame(
+			'2 reviews for <span>Widget <strong>Deluxe</strong></span>',
+			$received_title,
+			'The woocommerce_reviews_title filter should receive the complete heading string.'
+		);
+	}
+}