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