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.'
+ );
+ }
+}