Commit 786dacd4e96 for woocommerce

commit 786dacd4e966f3d4ee25a6fbb350fbafaf86350d
Author: Mahangu Weerasinghe <mahangu@users.noreply.github.com>
Date:   Mon May 11 19:42:16 2026 +0530

    Fix: reject webhook topics that have no registered hook (#64505)

    * Fix: reject webhook topics that have no registered hook

    `wc_is_webhook_valid_topic()` validated topics by checking the resource
    and event against two independent lists, accepting the full cartesian
    product (4 resources x 5 events = 20 topics). The actual topic-to-hook
    map only registers 16 of those pairs. The four un-mapped pairs
    (`order.published`, `customer.published`, `coupon.published`,
    `customer.restored`) saved as Active webhooks but registered zero
    `add_action` calls and never delivered.

    This adds an explicit pair-existence check for default-resource +
    default-event topics, scoped so that plugins extending the marginal
    sets via `woocommerce_valid_webhook_resources` /
    `woocommerce_valid_webhook_events` continue to work as before.

    Closes #64502.

    * Rename changelog to use PR number (#64505)

    * Add woocommerce_valid_webhook_default_topics filter for extension support

    * Revert "Add woocommerce_valid_webhook_default_topics filter for extension support"

    This reverts commit e5c7098916102afe306d7290197868818177cd87.

    * Fix lint: add resources filter docblock, expand topic array per WP coding standards

    * Address PR review: derive allowlist from new get_default_topic_hooks() static

    Per @prettyboymp's review on #64505, the previous fix tightened the
    topic validator without preserving the woocommerce_webhook_topic_hooks
    extension contract on the delivery path (WC_Webhook::should_deliver()
    re-validates on every fire, so existing default+default webhooks wired
    via that filter would silently stop delivering).

    This commit switches to Option A from the review: a new
    public static WC_Webhook::get_default_topic_hooks() holds the topic-to-
    hook-names map and applies the existing woocommerce_webhook_topic_hooks
    filter. The validator derives the default-pair allowlist from the keys
    of that filtered map, so the validator and the topic-hooks map share a
    single source of truth and cannot drift. Extensions that register hooks
    for previously-rejected default pairs (e.g. customer.restored) are now
    honored automatically.

    The private instance get_topic_hooks( \$topic ) is restored to its
    original signature and delegates to the static.

    Tests added:
    - Drift check across the cartesian product of default resources/events.
    - Filter-extends-default-pair (customer.restored via untrashed_post).
    - Filter-empties-default-pair (order.created emptied via the filter).

    Inline comment at lines 146-148 rewritten to reflect the derived list.
    Changelog body names all four affected topics for release-notes search.

    * Address CodeRabbit: drop cached bare webhook from get_default_topic_hooks()

    Per https://github.com/woocommerce/woocommerce/pull/64505#discussion_r3216153081 —
    construct a fresh WC_Webhook per call so a misbehaving filter callback that
    mutates the passed instance can't leak state into later validations.

    * Fix lint: add docblock for woocommerce_webhook_topic_hooks filter

    Per PHPCS WooCommerce.Commenting.CommentHooks.MissingHookComment. Also
    notes the bare-instance caveat for the static call path.

    ---------

    Co-authored-by: Mahangu Weerasinghe <mahangu.weerasinghe@automattic.com>
    Co-authored-by: Michael Pretty <prettyboymp@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/64505-fix-64502-webhook-topic-pair-validation b/plugins/woocommerce/changelog/64505-fix-64502-webhook-topic-pair-validation
new file mode 100644
index 00000000000..390ba62acce
--- /dev/null
+++ b/plugins/woocommerce/changelog/64505-fix-64502-webhook-topic-pair-validation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix webhook topic validator accepting `coupon.published`, `customer.published`, `customer.restored`, and `order.published` topics that have no registered hook, causing webhooks to save as Active but never deliver. The validator now derives the default-pair allowlist from the new `WC_Webhook::get_default_topic_hooks()` static, so extensions that register hooks for those pairs via `woocommerce_webhook_topic_hooks` are honored automatically.
diff --git a/plugins/woocommerce/includes/class-wc-webhook.php b/plugins/woocommerce/includes/class-wc-webhook.php
index cef7bf36d7f..edacb650477 100644
--- a/plugins/woocommerce/includes/class-wc-webhook.php
+++ b/plugins/woocommerce/includes/class-wc-webhook.php
@@ -933,13 +933,31 @@ class WC_Webhook extends WC_Legacy_Webhook {
 	*/

 	/**
-	 * Get the associated hook names for a topic.
+	 * Get the default topic-hooks map (topic => array of hook names).
 	 *
-	 * @since  2.2.0
-	 * @param  string $topic Topic name.
+	 * Source of truth for which default-resource/default-event webhook topics
+	 * can deliver. Used by `wc_is_webhook_valid_topic()` to derive the
+	 * default-pair allowlist so the validator and the map cannot drift.
+	 *
+	 * The `woocommerce_webhook_topic_hooks` filter is applied here. When called
+	 * statically (without a webhook context, e.g. from the topic validator),
+	 * a fresh, unsaved `WC_Webhook` instance is passed as the filter's second
+	 * argument so callbacks registered with `accepted_args = 2` keep working.
+	 * Those callbacks should not rely on the second argument exposing
+	 * per-webhook state in that call path (`get_id()` returns 0,
+	 * `get_topic()` is empty).
+	 *
+	 * @since 10.9.0
+	 * @param WC_Webhook|null $webhook Optional webhook instance to pass as the
+	 *                                 filter's context argument. A fresh
+	 *                                 unsaved instance is created when omitted.
 	 * @return array
 	 */
-	private function get_topic_hooks( $topic ) {
+	public static function get_default_topic_hooks( $webhook = null ) {
+		if ( ! $webhook instanceof WC_Webhook ) {
+			$webhook = new self();
+		}
+
 		$topic_hooks = array(
 			'coupon.created'    => array(
 				'woocommerce_process_shop_coupon_meta',
@@ -1003,7 +1021,28 @@ class WC_Webhook extends WC_Legacy_Webhook {
 			),
 		);

-		$topic_hooks = apply_filters( 'woocommerce_webhook_topic_hooks', $topic_hooks, $this );
+		/**
+		 * Filters the map of webhook topics to their registered hook names.
+		 *
+		 * @since 2.2.0
+		 * @param array      $topic_hooks Map of topic name to array of hook names.
+		 * @param WC_Webhook $webhook     The webhook instance. May be a fresh,
+		 *                                unsaved instance when called from
+		 *                                `WC_Webhook::get_default_topic_hooks()`
+		 *                                without a webhook context.
+		 */
+		return apply_filters( 'woocommerce_webhook_topic_hooks', $topic_hooks, $webhook );
+	}
+
+	/**
+	 * Get the associated hook names for a topic.
+	 *
+	 * @since  2.2.0
+	 * @param  string $topic Topic name.
+	 * @return array
+	 */
+	private function get_topic_hooks( $topic ) {
+		$topic_hooks = self::get_default_topic_hooks( $this );

 		return isset( $topic_hooks[ $topic ] ) ? $topic_hooks[ $topic ] : array();
 	}
diff --git a/plugins/woocommerce/includes/wc-webhook-functions.php b/plugins/woocommerce/includes/wc-webhook-functions.php
index d16e16dd658..32d4915e758 100644
--- a/plugins/woocommerce/includes/wc-webhook-functions.php
+++ b/plugins/woocommerce/includes/wc-webhook-functions.php
@@ -87,8 +87,12 @@ add_action( 'woocommerce_deliver_webhook_async', 'wc_deliver_webhook_async', 10,
 /**
  * Check if the given topic is a valid webhook topic, a topic is valid if:
  *
- * + starts with `action.woocommerce_` or `action.wc_`.
- * + it has a valid resource & event.
+ * + starts with `action.woocommerce_` or `action.wc_` (unless explicitly blocked by
+ *   the `$invalid_topics` list below).
+ * + it has a valid resource & event AND (when both are part of the default sets) the
+ *   `<resource>.<event>` pair has a registered hook. Default/default pairs with no
+ *   registered hook (e.g. `order.published`, `customer.restored`) are rejected to
+ *   prevent ghost webhooks that save as Active but never deliver.
  *
  * @since  2.2.0
  * @param  string $topic Webhook topic.
@@ -116,20 +120,48 @@ function wc_is_webhook_valid_topic( $topic ) {
 		return false;
 	}

-	$valid_resources = apply_filters( 'woocommerce_valid_webhook_resources', array( 'coupon', 'customer', 'order', 'product' ) );
+	$default_resources = array( 'coupon', 'customer', 'order', 'product' );
+	$default_events    = array( 'created', 'updated', 'deleted', 'restored', 'published' );
+
+	/**
+	 * Filters the list of valid webhook resources.
+	 *
+	 * @since 2.2.0
+	 * @param array $valid_resources Array of valid webhook resources.
+	 */
+	$valid_resources = apply_filters( 'woocommerce_valid_webhook_resources', $default_resources );
+
 	/**
 	 * Filters the list of valid webhook events.
 	 *
 	 * @since 2.2.0
 	 * @param array $valid_events Array of valid webhook events.
 	 */
-	$valid_events = apply_filters( 'woocommerce_valid_webhook_events', array( 'created', 'updated', 'deleted', 'restored', 'published' ) );
+	$valid_events = apply_filters( 'woocommerce_valid_webhook_events', $default_events );
+
+	if ( ! in_array( $data[0], $valid_resources, true ) || ! in_array( $data[1], $valid_events, true ) ) {
+		return false;
+	}

-	if ( in_array( $data[0], $valid_resources, true ) && in_array( $data[1], $valid_events, true ) ) {
+	// Topics that include a filter-added resource or event are accepted; plugins
+	// extending the resource/event lists are expected to wire delivery via the
+	// `woocommerce_webhook_topic_hooks` or `woocommerce_webhook_hooks` filters.
+	if ( ! in_array( $data[0], $default_resources, true ) || ! in_array( $data[1], $default_events, true ) ) {
 		return true;
 	}

-	return false;
+	// Default-resource + default-event topics must correspond to a registered
+	// hook. Pairs without one (e.g. `order.published`, `customer.restored`) save
+	// as Active webhooks but never deliver, so reject them.
+	//
+	// The allowlist is derived from `WC_Webhook::get_default_topic_hooks()`,
+	// which runs through the `woocommerce_webhook_topic_hooks` filter.
+	// Extensions that register hooks for an otherwise-empty default pair (via
+	// that filter) are therefore honored automatically, with no separate
+	// allowlist to keep in sync.
+	$default_topics = array_keys( array_filter( WC_Webhook::get_default_topic_hooks() ) );
+
+	return in_array( $topic, $default_topics, true );
 }

 /**
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/webhooks/functions.php b/plugins/woocommerce/tests/legacy/unit-tests/webhooks/functions.php
index f51e754d32b..26a832b9e0e 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/webhooks/functions.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/webhooks/functions.php
@@ -62,6 +62,113 @@ class WC_Tests_Webhook_Functions extends WC_Unit_Test_Case {
 		$this->assertEquals( $assert, $values );
 	}

+	/**
+	 * Default-resource + default-event topics with no registered hook are rejected,
+	 * to prevent ghost webhooks that save as Active and never deliver (issue #64502).
+	 */
+	public function test_wc_is_webhook_valid_topic_rejects_unregistered_pairs() {
+		$this->assertFalse( wc_is_webhook_valid_topic( 'order.published' ) );
+		$this->assertFalse( wc_is_webhook_valid_topic( 'coupon.published' ) );
+		$this->assertFalse( wc_is_webhook_valid_topic( 'customer.published' ) );
+		$this->assertFalse( wc_is_webhook_valid_topic( 'customer.restored' ) );
+	}
+
+	/**
+	 * The default-pair allowlist is derived from `WC_Webhook::get_default_topic_hooks()`,
+	 * so the validator and the topic-hooks map cannot drift. Any default-pair
+	 * topic the map registers a hook for must validate; any that the map leaves
+	 * empty must be rejected.
+	 */
+	public function test_wc_is_webhook_valid_topic_matches_topic_hooks_map() {
+		$topic_hooks       = WC_Webhook::get_default_topic_hooks();
+		$default_resources = array( 'coupon', 'customer', 'order', 'product' );
+		$default_events    = array( 'created', 'updated', 'deleted', 'restored', 'published' );
+
+		foreach ( $default_resources as $resource ) {
+			foreach ( $default_events as $event ) {
+				$topic    = "{$resource}.{$event}";
+				$expected = ! empty( $topic_hooks[ $topic ] );
+				$this->assertSame(
+					$expected,
+					wc_is_webhook_valid_topic( $topic ),
+					"Validator out of sync with WC_Webhook::get_default_topic_hooks() for `{$topic}`."
+				);
+			}
+		}
+	}
+
+	/**
+	 * Plugins extending the default-pair allowlist via `woocommerce_webhook_topic_hooks`
+	 * (e.g. wiring `untrashed_post` to fire `customer.restored`) are honored: the
+	 * validator accepts the topic so the webhook can save and deliver.
+	 */
+	public function test_wc_is_webhook_valid_topic_passes_filter_extended_default_pair() {
+		$this->assertFalse( wc_is_webhook_valid_topic( 'customer.restored' ) );
+
+		$add_default_pair_hook = function ( $hooks ) {
+			$hooks['customer.restored'] = array( 'untrashed_post' );
+			return $hooks;
+		};
+
+		try {
+			add_filter( 'woocommerce_webhook_topic_hooks', $add_default_pair_hook );
+			$this->assertTrue( wc_is_webhook_valid_topic( 'customer.restored' ) );
+		} finally {
+			remove_filter( 'woocommerce_webhook_topic_hooks', $add_default_pair_hook );
+		}
+
+		$this->assertFalse( wc_is_webhook_valid_topic( 'customer.restored' ) );
+	}
+
+	/**
+	 * Inverse of the filter-extended case: a plugin that empties a previously-valid
+	 * default pair via `woocommerce_webhook_topic_hooks` causes the validator to
+	 * reject that topic, since there are no hooks left to deliver it.
+	 */
+	public function test_wc_is_webhook_valid_topic_rejects_filter_emptied_default_pair() {
+		$this->assertTrue( wc_is_webhook_valid_topic( 'order.created' ) );
+
+		$empty_default_pair_hooks = function ( $hooks ) {
+			$hooks['order.created'] = array();
+			return $hooks;
+		};
+
+		try {
+			add_filter( 'woocommerce_webhook_topic_hooks', $empty_default_pair_hooks );
+			$this->assertFalse( wc_is_webhook_valid_topic( 'order.created' ) );
+		} finally {
+			remove_filter( 'woocommerce_webhook_topic_hooks', $empty_default_pair_hooks );
+		}
+
+		$this->assertTrue( wc_is_webhook_valid_topic( 'order.created' ) );
+	}
+
+	/**
+	 * Plugins extending the resource/event lists via the marginal-set filters keep
+	 * working: their topics include a custom resource or event, which skips the
+	 * default-pair check.
+	 */
+	public function test_wc_is_webhook_valid_topic_passes_filter_extended_resource_or_event() {
+		$add_event    = function ( $events ) {
+			$events[] = 'refunded';
+			return $events;
+		};
+		$add_resource = function ( $resources ) {
+			$resources[] = 'subscription';
+			return $resources;
+		};
+		add_filter( 'woocommerce_valid_webhook_events', $add_event );
+		add_filter( 'woocommerce_valid_webhook_resources', $add_resource );
+
+		try {
+			$this->assertTrue( wc_is_webhook_valid_topic( 'order.refunded' ) );
+			$this->assertTrue( wc_is_webhook_valid_topic( 'subscription.created' ) );
+		} finally {
+			remove_filter( 'woocommerce_valid_webhook_events', $add_event );
+			remove_filter( 'woocommerce_valid_webhook_resources', $add_resource );
+		}
+	}
+
 	/**
 	 * Data provider for test_wc_is_webhook_valid_status.
 	 *