Commit f9c45a77251 for woocommerce

commit f9c45a7725110ec02c05f078f77dff0cbc82e03d
Author: Darren Ethier <darren@roughsmootheng.in>
Date:   Wed Sep 2 12:40:35 2026 -0400

    Fix PHP error on malformed download links and correct WPCS annotations (#68265)

diff --git a/plugins/woocommerce/changelog/fix-woo6-114-download-authorization-suppressions b/plugins/woocommerce/changelog/fix-woo6-114-download-authorization-suppressions
new file mode 100644
index 00000000000..8683a38b93d
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-woo6-114-download-authorization-suppressions
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Treat product download links whose query arguments arrive as arrays as invalid links instead of letting them raise a PHP error.
diff --git a/plugins/woocommerce/includes/class-wc-download-handler.php b/plugins/woocommerce/includes/class-wc-download-handler.php
index 61e3c67d2ef..e2362b021be 100644
--- a/plugins/woocommerce/includes/class-wc-download-handler.php
+++ b/plugins/woocommerce/includes/class-wc-download-handler.php
@@ -34,7 +34,7 @@ class WC_Download_Handler {
 	 * Hook in methods.
 	 */
 	public static function init() {
-		if ( isset( $_GET['download_file'], $_GET['order'] ) && ( isset( $_GET['email'] ) || isset( $_GET['uid'] ) ) ) { // WPCS: input var ok, CSRF ok.
+		if ( isset( $_GET['download_file'], $_GET['order'] ) && ( isset( $_GET['email'] ) || isset( $_GET['uid'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Emailed download links are bearer URLs; download_product() verifies the order key and email hash.
 			add_action( 'init', array( __CLASS__, 'download_product' ) );
 		}
 		add_action( 'woocommerce_download_file_redirect', array( __CLASS__, 'download_file_redirect' ), 10, 2 );
@@ -47,7 +47,15 @@ class WC_Download_Handler {
 	 * Check if we need to download a file and check validity.
 	 */
 	public static function download_product() {
-		// phpcs:disable WordPress.Security.NonceVerification.Recommended
+		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- Emailed download links are bearer URLs: authorization is the order key plus the billing email or its hash, both verified below, so no nonce can be issued for them.
+
+		// Download links only ever carry scalar values, so reject array input instead of passing it to the string handling below.
+		foreach ( array( 'download_file', 'order', 'key', 'email', 'uid' ) as $download_arg ) {
+			if ( isset( $_GET[ $download_arg ] ) && ! is_scalar( $_GET[ $download_arg ] ) ) {
+				self::download_error( __( 'Invalid download link.', 'woocommerce' ) );
+			}
+		}
+
 		$product_id = absint( $_GET['download_file'] ); // phpcs:ignore WordPress.VIP.SuperGlobalInputUsage.AccessDetected, WordPress.VIP.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotValidated
 		$product    = wc_get_product( $product_id );
 		$downloads  = $product ? $product->get_downloads() : array();
@@ -66,16 +74,15 @@ class WC_Download_Handler {
 		}

 		// Fallback, accept email address if it's passed.
-		if ( empty( $_GET['email'] ) && empty( $_GET['uid'] ) ) { // WPCS: input var ok, CSRF ok.
+		if ( empty( $_GET['email'] ) && empty( $_GET['uid'] ) ) {
 			self::download_error( __( 'Invalid download link.', 'woocommerce' ) );
 		}
-		// phpcs:enable WordPress.Security.NonceVerification.Recommended

-		$order_id = wc_get_order_id_by_order_key( wc_clean( wp_unslash( $_GET['order'] ) ) ); // WPCS: input var ok, CSRF ok.
+		$order_id = wc_get_order_id_by_order_key( wc_clean( wp_unslash( $_GET['order'] ) ) );
 		$order    = wc_get_order( $order_id );

-		if ( isset( $_GET['email'] ) ) { // WPCS: input var ok, CSRF ok.
-			$email_address = wp_unslash( $_GET['email'] ); // WPCS: input var ok, CSRF ok, sanitization ok.
+		if ( isset( $_GET['email'] ) ) {
+			$email_address = wp_unslash( $_GET['email'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- The raw value is needed so spaces can be restored to "+" below; sanitize_email() is applied before the lookup.
 		} else {
 			// Get email address from order to verify hash.
 			$email_address = is_a( $order, 'WC_Order' ) ? $order->get_billing_email() : null;
@@ -83,7 +90,7 @@ class WC_Download_Handler {
 			// Prepare email address hash.
 			$email_hash = function_exists( 'hash' ) ? hash( 'sha256', $email_address ) : sha1( $email_address );

-			if ( is_null( $email_address ) || ! hash_equals( wp_unslash( $_GET['uid'] ), $email_hash ) ) { // WPCS: input var ok, CSRF ok, sanitization ok.
+			if ( is_null( $email_address ) || ! hash_equals( wp_unslash( $_GET['uid'] ), $email_hash ) ) {
 				self::download_error( __( 'Invalid download link.', 'woocommerce' ) );
 			}
 		}
@@ -91,15 +98,16 @@ class WC_Download_Handler {
 		$download_ids = $data_store->get_downloads(
 			array(
 				'user_email'  => sanitize_email( str_replace( ' ', '+', $email_address ) ),
-				'order_key'   => wc_clean( wp_unslash( $_GET['order'] ) ), // WPCS: input var ok, CSRF ok.
+				'order_key'   => wc_clean( wp_unslash( $_GET['order'] ) ),
 				'product_id'  => $product_id,
-				'download_id' => wc_clean( preg_replace( '/\s+/', ' ', wp_unslash( $_GET['key'] ) ) ), // WPCS: input var ok, CSRF ok, sanitization ok.
+				'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',
 				'order'       => 'DESC',
 				'limit'       => 1,
 				'return'      => 'ids',
 			)
 		);
+		// phpcs:enable WordPress.Security.NonceVerification.Recommended

 		if ( empty( $download_ids ) ) {
 			self::download_error( __( 'Invalid download link.', 'woocommerce' ) );
diff --git a/plugins/woocommerce/src/Admin/ReportExporter.php b/plugins/woocommerce/src/Admin/ReportExporter.php
index 389b791613e..d2931de08a9 100644
--- a/plugins/woocommerce/src/Admin/ReportExporter.php
+++ b/plugins/woocommerce/src/Admin/ReportExporter.php
@@ -179,11 +179,11 @@ class ReportExporter {
 		if (
 			isset( $_GET['action'] ) &&
 			! empty( $_GET['filename'] ) &&
-			self::DOWNLOAD_EXPORT_ACTION === wp_unslash( $_GET['action'] ) && // WPCS: input var ok, sanitization ok.
+			self::DOWNLOAD_EXPORT_ACTION === wp_unslash( $_GET['action'] ) && // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Read-only export download reached from an emailed admin link and gated on the view_woocommerce_reports capability; the value is only compared verbatim against a fixed action name.
 			current_user_can( 'view_woocommerce_reports' )
 		) {
 			$exporter = new ReportCSVExporter();
-			$exporter->set_filename( wp_unslash( $_GET['filename'] ) ); // WPCS: input var ok, sanitization ok.
+			$exporter->set_filename( wp_unslash( $_GET['filename'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Read-only export download reached from an emailed admin link and gated on the view_woocommerce_reports capability; set_filename() applies sanitize_file_name(), which keeps the read inside the reports directory.
 			$exporter->export();
 		}
 	}
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 5b0f11384af..0992cfe5342 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
@@ -651,6 +651,62 @@ class WC_Download_Handler_Tests extends \WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox download_product() should treat array query args as an invalid download link.
+	 */
+	public function test_download_product_rejects_array_query_args(): void {
+		$string_args = array(
+			'download_file' => '1',
+			'order'         => 'wc_order_x',
+			'key'           => 'k',
+			'email'         => 'a@example.org',
+		);
+
+		$product_was_looked_up = false;
+		$lookup_watcher        = function ( $type ) use ( &$product_was_looked_up ) {
+			$product_was_looked_up = true;
+			return $type;
+		};
+
+		add_filter( 'woocommerce_product_type_query', $lookup_watcher );
+
+		try {
+			foreach ( array( 'download_file', 'order', 'key', 'email', 'uid' ) as $arg ) {
+				$_GET = $string_args;
+
+				if ( 'uid' === $arg ) {
+					// The UID is only consulted when no email address is supplied.
+					unset( $_GET['email'] );
+				}
+
+				$_GET[ $arg ]          = array( 'x' );
+				$wp_die_message        = '';
+				$product_was_looked_up = false;
+
+				// We do not use expectException() here because every argument is checked in turn.
+				try {
+					WC_Download_Handler::download_product();
+				} catch ( WPDieException $e ) {
+					$wp_die_message = $e->getMessage();
+				}
+
+				$this->assertStringContainsString(
+					'Invalid download link',
+					$wp_die_message,
+					"An array value for the \"$arg\" query argument should render the invalid download link error."
+				);
+
+				$this->assertFalse(
+					$product_was_looked_up,
+					"Array query arguments are rejected before any product lookup, but the \"$arg\" case reached one."
+				);
+			}
+		} finally {
+			remove_filter( 'woocommerce_product_type_query', $lookup_watcher );
+			$_GET = array();
+		}
+	}
+
 	/**
 	 * Creates a downloadable product, and then places (and completes) an order for that
 	 * object.