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