Commit 90b7fe755e0 for woocommerce
commit 90b7fe755e0d9244f79b818c26a3b1aa36ab1e7b
Author: Michal Iwanow <4765119+mcliwanow@users.noreply.github.com>
Date: Wed Oct 7 12:59:25 2026 +0200
Reject product download links with invalid order or email values (#69524)
Co-authored-by: Darren Ethier <1429108+nerrad@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/fix-download-authorization-params b/plugins/woocommerce/changelog/fix-download-authorization-params
new file mode 100644
index 00000000000..1098fb9cf6f
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-download-authorization-params
@@ -0,0 +1,3 @@
+Significance: patch
+Type: fix
+Comment: Reject product download links with invalid order or email values.
diff --git a/plugins/woocommerce/includes/class-wc-download-handler.php b/plugins/woocommerce/includes/class-wc-download-handler.php
index 932c8c6200d..9c316fd75a9 100644
--- a/plugins/woocommerce/includes/class-wc-download-handler.php
+++ b/plugins/woocommerce/includes/class-wc-download-handler.php
@@ -61,12 +61,13 @@ class WC_Download_Handler {
$downloads = $product ? $product->get_downloads() : array();
$data_store = WC_Data_Store::load( 'customer-download' );
- $key = empty( $_GET['key'] ) ? '' : sanitize_text_field( wp_unslash( $_GET['key'] ) );
+ $key = empty( $_GET['key'] ) ? '' : sanitize_text_field( wp_unslash( $_GET['key'] ) );
+ $order_key = empty( $_GET['order'] ) ? '' : wc_clean( wp_unslash( $_GET['order'] ) );
if (
! $product
|| empty( $key )
- || empty( $_GET['order'] )
+ || empty( $order_key )
|| ! isset( $downloads[ $key ] )
|| ! $downloads[ $key ]->get_enabled()
) {
@@ -78,7 +79,7 @@ class WC_Download_Handler {
self::download_error( __( 'Invalid download link.', 'woocommerce' ) );
}
- $order_id = wc_get_order_id_by_order_key( wc_clean( wp_unslash( $_GET['order'] ) ) );
+ $order_id = wc_get_order_id_by_order_key( $order_key );
$order = wc_get_order( $order_id );
if ( isset( $_GET['email'] ) ) {
@@ -95,10 +96,16 @@ class WC_Download_Handler {
}
}
+ $user_email = sanitize_email( str_replace( ' ', '+', $email_address ) );
+
+ if ( empty( $user_email ) ) {
+ self::download_error( __( 'Invalid download link.', 'woocommerce' ) );
+ }
+
$download_ids = $data_store->get_downloads(
array(
- 'user_email' => sanitize_email( str_replace( ' ', '+', $email_address ) ),
- 'order_key' => wc_clean( wp_unslash( $_GET['order'] ) ),
+ 'user_email' => $user_email,
+ 'order_key' => $order_key,
'product_id' => $product_id,
'download_id' => wc_clean( preg_replace( '/\s+/', ' ', wp_unslash( $_GET['key'] ) ) ), // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- The key is matched against the product's download list above and wc_clean() normalizes it before the lookup.
'orderby' => 'downloads_remaining',
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-download-handler-tests.php b/plugins/woocommerce/tests/php/includes/class-wc-download-handler-tests.php
index e9bcb2142da..c6b8b1f358b 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-download-handler-tests.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-download-handler-tests.php
@@ -729,6 +729,169 @@ class WC_Download_Handler_Tests extends \WC_Unit_Test_Case {
}
}
+ /**
+ * @testdox download_product() should reject authorization values that sanitize to empty.
+ *
+ * @dataProvider provider_authorization_values_that_sanitize_to_empty
+ *
+ * @param string $argument Query argument under test.
+ * @param string $value Query argument value.
+ */
+ public function test_download_product_rejects_authorization_values_that_sanitize_to_empty( string $argument, string $value ): void {
+ self::remove_download_handlers();
+
+ try {
+ list( $product, $order ) = $this->build_downloadable_product_and_order_one(
+ array(
+ array(
+ 'name' => 'Protected download',
+ 'file' => content_url( 'uploads/woocommerce_uploads/protected-download.pdf' ),
+ ),
+ )
+ );
+
+ $download_key = current( array_keys( $product->get_downloads() ) );
+ $download = current( WC_Data_Store::load( 'customer-download' )->get_downloads( array( 'product_id' => $product->get_id() ) ) );
+ $download->set_downloads_remaining( 5 );
+ $download->save();
+
+ $_GET = array(
+ 'download_file' => $product->get_id(),
+ 'order' => $order->get_order_key(),
+ 'email' => $order->get_billing_email(),
+ 'key' => $download_key,
+ );
+
+ $_GET[ $argument ] = $value;
+
+ $wp_die_message = '';
+
+ try {
+ WC_Download_Handler::download_product();
+ } catch ( WPDieException $e ) {
+ $wp_die_message = $e->getMessage();
+ }
+
+ $this->assertStringContainsString( 'Invalid download link', $wp_die_message, 'The malformed authorization value should render the invalid download link error.' );
+
+ $download = new WC_Customer_Download( $download->get_id() );
+ $this->assertSame( 5, $download->get_downloads_remaining(), 'A rejected request must not consume the customer\'s remaining downloads.' );
+ } finally {
+ self::restore_download_handlers();
+ $_GET = array();
+ }
+ }
+
+ /**
+ * @testdox download_product() should authorize the current email download URL format.
+ */
+ public function test_download_product_accepts_current_email_download_url(): void {
+ list( $product, $order ) = $this->build_downloadable_product_and_order_one(
+ array(
+ array(
+ 'name' => 'Protected download',
+ 'file' => content_url( 'uploads/woocommerce_uploads/protected-download.pdf' ),
+ ),
+ )
+ );
+
+ $download_key = current( array_keys( $product->get_downloads() ) );
+ $order_item = current( $order->get_items() );
+ $download_url = $order_item->get_item_download_url( $download_key );
+ $query_args = array();
+
+ parse_str( wp_parse_url( $download_url, PHP_URL_QUERY ), $query_args );
+
+ $this->assertArrayHasKey( 'email', $query_args, 'The current email download URL should contain an email argument.' );
+ $this->assertArrayNotHasKey( 'uid', $query_args, 'The email download URL should exercise the email authorization path.' );
+ $this->assert_download_url_is_authorized( $download_url );
+ }
+
+ /**
+ * @testdox download_product() should authorize the current UID download URL format.
+ */
+ public function test_download_product_accepts_current_uid_download_url(): void {
+ list( $product, $order ) = $this->build_downloadable_product_and_order_one(
+ array(
+ array(
+ 'name' => 'Protected download',
+ 'file' => content_url( 'uploads/woocommerce_uploads/protected-download.pdf' ),
+ ),
+ )
+ );
+
+ $downloadable_items = $order->get_downloadable_items();
+ $download_url = current( $downloadable_items )['download_url'];
+ $query_args = array();
+
+ parse_str( wp_parse_url( $download_url, PHP_URL_QUERY ), $query_args );
+
+ $this->assertArrayHasKey( 'uid', $query_args, 'The current UID download URL should contain a UID argument.' );
+ $this->assertArrayNotHasKey( 'email', $query_args, 'The UID download URL should exercise the UID authorization path.' );
+ $this->assert_download_url_is_authorized( $download_url );
+ }
+
+ /**
+ * @testdox download_product() should reject a generated UID link when the order billing email is empty.
+ */
+ public function test_download_product_rejects_generated_uid_link_when_billing_email_is_empty(): void {
+ self::remove_download_handlers();
+
+ try {
+ list( $product, $order ) = $this->build_downloadable_product_and_order_one(
+ array(
+ array(
+ 'name' => 'Protected download',
+ 'file' => content_url( 'uploads/woocommerce_uploads/protected-download.pdf' ),
+ ),
+ )
+ );
+
+ $download = current( WC_Data_Store::load( 'customer-download' )->get_downloads( array( 'product_id' => $product->get_id() ) ) );
+ $download->set_downloads_remaining( 5 );
+ $download->save();
+
+ $order->set_customer_id( 0 );
+ $order->set_billing_email( '' );
+ $order->save();
+
+ $downloadable_items = $order->get_downloadable_items();
+ $download_url = current( $downloadable_items )['download_url'];
+ $query_args = array();
+
+ parse_str( wp_parse_url( $download_url, PHP_URL_QUERY ), $query_args );
+ $_GET = $query_args;
+
+ $wp_die_message = '';
+
+ try {
+ WC_Download_Handler::download_product();
+ } catch ( WPDieException $e ) {
+ $wp_die_message = $e->getMessage();
+ }
+
+ $this->assertStringContainsString( 'Invalid download link', $wp_die_message, 'A UID derived from an empty billing email should be rejected.' );
+
+ $download = new WC_Customer_Download( $download->get_id() );
+ $this->assertSame( 5, $download->get_downloads_remaining(), 'A rejected UID request must not consume the customer\'s remaining downloads.' );
+ } finally {
+ self::restore_download_handlers();
+ $_GET = array();
+ }
+ }
+
+ /**
+ * Values which are present in the request but empty after sanitization.
+ *
+ * @return array<string, array<string>>
+ */
+ public function provider_authorization_values_that_sanitize_to_empty(): array {
+ return array(
+ 'order key' => array( 'order', ' ' ),
+ 'email' => array( 'email', 'x' ),
+ );
+ }
+
/**
* Creates a downloadable product, and then places (and completes) an order for that
* object.
@@ -753,6 +916,38 @@ class WC_Download_Handler_Tests extends \WC_Unit_Test_Case {
);
}
+ /**
+ * Assert that a generated download URL passes authorization without serving a file.
+ *
+ * @param string $download_url Generated download URL.
+ */
+ private function assert_download_url_is_authorized( string $download_url ): void {
+ $downloads_dispatched = 0;
+ $download_method = function () {
+ return 'test';
+ };
+ $download_counter = function () use ( &$downloads_dispatched ) {
+ ++$downloads_dispatched;
+ };
+
+ add_filter( 'woocommerce_file_download_method', $download_method );
+ add_action( 'woocommerce_download_file_test', $download_counter );
+
+ try {
+ $query_args = array();
+ parse_str( wp_parse_url( $download_url, PHP_URL_QUERY ), $query_args );
+ $_GET = $query_args;
+
+ WC_Download_Handler::download_product();
+
+ $this->assertSame( 1, $downloads_dispatched, 'An authorized URL should reach the download dispatch without serving a file.' );
+ } finally {
+ remove_filter( 'woocommerce_file_download_method', $download_method );
+ remove_action( 'woocommerce_download_file_test', $download_counter );
+ $_GET = array();
+ }
+ }
+
/**
* Unregister download handlers to prevent unwanted output and side-effects.
*/