Commit e1b60077444 for woocommerce

commit e1b600774448a90255bc220553c6c68f799c7f8c
Author: Oleksandr Aratovskyi <79862886+oaratovskyi@users.noreply.github.com>
Date:   Wed Sep 2 14:05:43 2026 +0300

    Fix cross-resource post-action webhook deliveries (#68208)

    * fix: Validate post webhook actions by affected post ID

    Generic post hooks do not reliably populate global post type state.

    Webhook post-action validation fails open without that global, which can queue cross-resource deliveries. Resolve the affected post type from the hook argument and cover the complete default-resource decision table.

    Refs WOOPLUG-2191

    * fix: Align post-action webhook resource validation

    Post-action validation now resolves resources from the affected post ID.

    The fixed map omitted product variations plus registered order types. Falsy IDs could also resolve the global post, reintroducing request-state validation.

    Reject falsy IDs before post lookup. Map variations to products. Populate order entries from the order-webhook registry. Cover variable-product deliveries, global-post fallback, and extension order types with regression tests.

    Refs WOOPLUG-2191

    * test: Cover excluded webhook order types

    Extension order types can opt out of order webhooks, but the registry regression only covered an included type. That allowed an unfiltered order-type lookup to pass unnoticed. Register both included and excluded types so the test pins the filtered registry contract and cleans up both registrations.

    Refs WOOPLUG-2191

    * fix: Reject malformed post webhook IDs

    Post-action hooks accept mixed arguments, and absint() can coerce booleans, arrays, floats, and negative values to an unrelated positive post ID. Validate positive integer IDs and decimal ID strings before coercion so malformed hook data cannot queue a webhook for another resource.

    Refs WOOPLUG-2191

    * refactor: Remove redundant post webhook ID guard

    Post-action webhook IDs are validated before coercion.

    The later zero-ID check became unreachable after the positive-ID validation was added.

    Remove the dead branch so the implementation matches its mutation coverage.

    Refs WOOPLUG-2191

diff --git a/plugins/woocommerce/changelog/wooplug-2191-post-action-webhook-validation b/plugins/woocommerce/changelog/wooplug-2191-post-action-webhook-validation
new file mode 100644
index 00000000000..b064391f682
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-2191-post-action-webhook-validation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Validate post-action webhooks using the affected post ID instead of request-global post type state.
diff --git a/plugins/woocommerce/includes/class-wc-webhook.php b/plugins/woocommerce/includes/class-wc-webhook.php
index f8f26b50050..3015fc868a9 100644
--- a/plugins/woocommerce/includes/class-wc-webhook.php
+++ b/plugins/woocommerce/includes/class-wc-webhook.php
@@ -210,16 +210,22 @@ class WC_Webhook extends WC_Legacy_Webhook {
 	 * @return bool       True if validation passes.
 	 */
 	private function is_valid_post_action( $arg ) {
-		// Only deliver deleted/restored event for coupons, orders, and products.
-		if ( isset( $GLOBALS['post_type'] ) && ! in_array( $GLOBALS['post_type'], array( 'shop_coupon', 'shop_order', 'product' ), true ) ) {
+		if ( ( ! is_int( $arg ) && ! ( is_string( $arg ) && ctype_digit( $arg ) ) ) || 0 >= (int) $arg ) {
 			return false;
 		}

-		// Check if is delivering for the correct resource.
-		if ( isset( $GLOBALS['post_type'] ) && str_replace( 'shop_', '', $GLOBALS['post_type'] ) !== $this->get_resource() ) {
-			return false;
-		}
-		return true;
+		$post_id               = absint( $arg );
+		$post_type_to_resource = array_merge(
+			array(
+				'product'           => 'product',
+				'product_variation' => 'product',
+				'shop_coupon'       => 'coupon',
+			),
+			array_fill_keys( wc_get_order_types( 'order-webhooks' ), 'order' )
+		);
+		$post_type             = get_post_type( $post_id );
+
+		return isset( $post_type_to_resource[ $post_type ] ) && $post_type_to_resource[ $post_type ] === $this->get_resource();
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-webhook-tests.php b/plugins/woocommerce/tests/php/includes/class-wc-webhook-tests.php
index 6955c52fcec..6808ef3c49d 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-webhook-tests.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-webhook-tests.php
@@ -8,6 +8,277 @@
  */
 class WC_Webhook_Test extends WC_Unit_Test_Case {

+	/**
+	 * @testdox Check that post-action validation uses the affected post ID.
+	 *
+	 * @dataProvider post_action_validation_provider
+	 *
+	 * @param string|null $post_type       Post type to create, or null for ID 0.
+	 * @param string      $topic           Webhook topic.
+	 * @param string|null $global_post_type Existing global post type, or null to unset it.
+	 * @param bool        $expected        Expected validation result.
+	 */
+	public function test_is_valid_post_action_uses_post_id( $post_type, $topic, $global_post_type, $expected ): void {
+		$had_global_post_type = array_key_exists( 'post_type', $GLOBALS );
+		$original_post_type   = $GLOBALS['post_type'] ?? null;
+		if ( null === $global_post_type ) {
+			unset( $GLOBALS['post_type'] );
+		} else {
+			// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Test isolates arbitrary global request state.
+			$GLOBALS['post_type'] = $global_post_type;
+		}
+
+		try {
+			$post_id = null === $post_type ? 0 : $this->factory->post->create(
+				array(
+					'post_type'   => $post_type,
+					'post_status' => 'publish',
+				)
+			);
+			$webhook = new WC_Webhook();
+			$webhook->set_topic( $topic );
+			$this->assertSame( $expected, $this->call_is_valid_post_action( $webhook, $post_id ) );
+		} finally {
+			if ( $had_global_post_type ) {
+				// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the global request state changed for this test.
+				$GLOBALS['post_type'] = $original_post_type;
+			} else {
+				unset( $GLOBALS['post_type'] );
+			}
+		}
+	}
+
+	/**
+	 * Call the private post-action validator.
+	 *
+	 * @param WC_Webhook $webhook Webhook to validate.
+	 * @param mixed      $arg     Hook argument.
+	 * @return bool Validation result.
+	 */
+	private function call_is_valid_post_action( WC_Webhook $webhook, $arg ): bool {
+		$call_is_valid_function = function ( $arg ) {
+			return $this->is_valid_post_action( $arg );
+		};
+
+		return $call_is_valid_function->call( $webhook, $arg );
+	}
+
+	/**
+	 * Data provider for test_is_valid_post_action_uses_post_id().
+	 *
+	 * @return array<string, array{string|null, string, string|null, bool}> Test cases.
+	 */
+	public function post_action_validation_provider() {
+		return array(
+			'matching product without global'         => array( 'product', 'product.deleted', null, true ),
+			'matching coupon without global'          => array( 'shop_coupon', 'coupon.deleted', null, true ),
+			'matching order without global'           => array( 'shop_order', 'order.deleted', null, true ),
+			'product resource with coupon ID'         => array( 'shop_coupon', 'product.deleted', null, false ),
+			'coupon resource with product ID'         => array( 'product', 'coupon.deleted', null, false ),
+			'product resource with unrelated post ID' => array( 'post', 'product.deleted', null, false ),
+			'product resource with missing ID'        => array( null, 'product.deleted', null, false ),
+			'matching product with stale global'      => array( 'product', 'product.deleted', 'page', true ),
+			'product resource with stale global'      => array( 'shop_coupon', 'product.deleted', 'product', false ),
+		);
+	}
+
+	/**
+	 * @testdox Post-action validation rejects a falsy ID even when a global product exists.
+	 */
+	public function test_is_valid_post_action_rejects_falsy_id_with_global_post(): void {
+		$had_global_post = array_key_exists( 'post', $GLOBALS );
+		$original_post   = $GLOBALS['post'] ?? null;
+		$product_id      = $this->factory->post->create(
+			array(
+				'post_type'   => 'product',
+				'post_status' => 'publish',
+			)
+		);
+		$webhook         = new WC_Webhook();
+		$webhook->set_topic( 'product.deleted' );
+
+		try {
+			// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Test isolates arbitrary global request state.
+			$GLOBALS['post'] = get_post( $product_id );
+			$this->assertFalse( $this->call_is_valid_post_action( $webhook, 0 ) );
+		} finally {
+			if ( $had_global_post ) {
+				// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the global request state changed for this test.
+				$GLOBALS['post'] = $original_post;
+			} else {
+				unset( $GLOBALS['post'] );
+			}
+		}
+	}
+
+	/**
+	 * @testdox Post-action validation rejects non-ID values before coercion.
+	 */
+	public function test_is_valid_post_action_rejects_non_id_values_before_coercion(): void {
+		$post_id = $this->factory->post->create(
+			array(
+				'post_type'   => 'product',
+				'post_status' => 'publish',
+			)
+		);
+		$webhook = new WC_Webhook();
+		$webhook->set_topic( 'product.deleted' );
+
+		$cached_post_at_one = wp_cache_get( 1, 'posts', false, $had_cached_post_at_one );
+		$post_at_one        = clone get_post( $post_id );
+		$post_at_one->ID    = 1;
+		wp_cache_set( 1, $post_at_one, 'posts' );
+
+		try {
+			$this->assertTrue( $this->call_is_valid_post_action( $webhook, (string) $post_id ) );
+
+			$invalid_ids = array(
+				'boolean'             => true,
+				'array'               => array( $post_id ),
+				'float'               => (float) $post_id,
+				'negative integer'    => -$post_id,
+				'decimal-like string' => $post_id . '.5',
+			);
+
+			foreach ( $invalid_ids as $description => $invalid_id ) {
+				$this->assertFalse(
+					$this->call_is_valid_post_action( $webhook, $invalid_id ),
+					"A {$description} should not be treated as a post ID."
+				);
+			}
+		} finally {
+			if ( $had_cached_post_at_one ) {
+				wp_cache_set( 1, $cached_post_at_one, 'posts' );
+			} else {
+				wp_cache_delete( 1, 'posts' );
+			}
+		}
+	}
+
+	/**
+	 * @testdox Post-action validation honors the order-webhooks registry.
+	 */
+	public function test_is_valid_post_action_honors_order_webhook_registry(): void {
+		global $wc_order_types;
+
+		$order_type          = 'shop_webhook_test';
+		$excluded_order_type = 'shop_no_webhook';
+		$registered          = wc_register_order_type(
+			$order_type,
+			array(
+				'exclude_from_order_webhooks' => false,
+			)
+		);
+		$excluded_registered = wc_register_order_type(
+			$excluded_order_type,
+			array(
+				'exclude_from_order_webhooks' => true,
+			)
+		);
+
+		try {
+			$this->assertSame( array( true, true ), array( $registered, $excluded_registered ), 'The test order types should be registered.' );
+			$post_id          = $this->factory->post->create(
+				array(
+					'post_type'   => $order_type,
+					'post_status' => 'publish',
+				)
+			);
+			$excluded_post_id = $this->factory->post->create(
+				array(
+					'post_type'   => $excluded_order_type,
+					'post_status' => 'publish',
+				)
+			);
+			$webhook          = new WC_Webhook();
+			$webhook->set_topic( 'order.deleted' );
+
+			$this->assertTrue( $this->call_is_valid_post_action( $webhook, $post_id ) );
+			$this->assertFalse( $this->call_is_valid_post_action( $webhook, $excluded_post_id ) );
+		} finally {
+			foreach ( array( $order_type, $excluded_order_type ) as $test_order_type ) {
+				if ( post_type_exists( $test_order_type ) ) {
+					unregister_post_type( $test_order_type );
+				}
+				unset( $wc_order_types[ $test_order_type ] );
+			}
+		}
+	}
+
+	/**
+	 * @testdox Product deletion webhooks are delivered for a variable product and all of its variations.
+	 */
+	public function test_product_deletion_webhook_delivers_for_variable_product_variations(): void {
+		$product       = WC_Helper_Product::create_variation_product();
+		$expected_ids  = array_merge( array( $product->get_id() ), $product->get_children() );
+		$delivered_ids = array();
+		$webhook       = $this->create_active_webhook( 'product.deleted' );
+
+		remove_action( 'woocommerce_webhook_process_delivery', 'wc_webhook_process_delivery', 10 );
+		add_action(
+			'woocommerce_webhook_process_delivery',
+			function ( $delivering_webhook, $arg ) use ( $webhook, &$delivered_ids ) {
+				if ( $webhook === $delivering_webhook ) {
+					$delivered_ids[] = $arg;
+				}
+			},
+			10,
+			2
+		);
+		$webhook->enqueue();
+
+		wp_trash_post( $product->get_id() );
+
+		sort( $expected_ids );
+		sort( $delivered_ids );
+		$this->assertSame( $expected_ids, $delivered_ids );
+	}
+
+	/**
+	 * @testdox Product restoration webhooks are delivered for a variable product and all of its variations.
+	 */
+	public function test_product_restoration_webhook_delivers_for_variable_product_variations(): void {
+		$product       = WC_Helper_Product::create_variation_product();
+		$expected_ids  = array_merge( array( $product->get_id() ), $product->get_children() );
+		$delivered_ids = array();
+		wp_trash_post( $product->get_id() );
+
+		$webhook = $this->create_active_webhook( 'product.restored' );
+		remove_action( 'woocommerce_webhook_process_delivery', 'wc_webhook_process_delivery', 10 );
+		add_action(
+			'woocommerce_webhook_process_delivery',
+			function ( $delivering_webhook, $arg ) use ( $webhook, &$delivered_ids ) {
+				if ( $webhook === $delivering_webhook ) {
+					$delivered_ids[] = $arg;
+				}
+			},
+			10,
+			2
+		);
+		$webhook->enqueue();
+
+		wp_untrash_post( $product->get_id() );
+
+		sort( $expected_ids );
+		sort( $delivered_ids );
+		$this->assertSame( $expected_ids, $delivered_ids );
+	}
+
+	/**
+	 * Create an active webhook for integration tests.
+	 *
+	 * @param string $topic Webhook topic.
+	 * @return WC_Webhook
+	 */
+	private function create_active_webhook( string $topic ): WC_Webhook {
+		$webhook = new WC_Webhook();
+		$webhook->set_status( 'active' );
+		$webhook->set_topic( $topic );
+		$webhook->set_delivery_url( 'https://example.com/webhook' );
+
+		return $webhook;
+	}
+
 	/**
 	 * @testDox Check if valid resource is true when both arg and topic are valid.
 	 */