Commit a90e9cd8c83 for woocommerce

commit a90e9cd8c833a6689d4b86c6ee9876658c49f3cc
Author: Liam Sarsfield <43409125+LiamSarsfield@users.noreply.github.com>
Date:   Mon Aug 10 14:05:27 2026 +0100

    Fix SQL injection via report_args.orderby on Analytics CSV export (#67544)

    Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
    Co-authored-by: Lourens Schep <lourensschep@gmail.com>
    Co-authored-by: Chi-Hsuan Huang <chihsuan.tw@gmail.com>

diff --git a/plugins/woocommerce/changelog/woo6-87-validate-analytics-export-orderby b/plugins/woocommerce/changelog/woo6-87-validate-analytics-export-orderby
new file mode 100644
index 00000000000..38b149473ce
--- /dev/null
+++ b/plugins/woocommerce/changelog/woo6-87-validate-analytics-export-orderby
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Validate Analytics report export arguments orderby against the target report's schema.
diff --git a/plugins/woocommerce/src/Admin/API/Reports/Export/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Export/Controller.php
index f82aecbf0e5..dd4977b1ad6 100644
--- a/plugins/woocommerce/src/Admin/API/Reports/Export/Controller.php
+++ b/plugins/woocommerce/src/Admin/API/Reports/Export/Controller.php
@@ -12,6 +12,7 @@ namespace Automattic\WooCommerce\Admin\API\Reports\Export;
 defined( 'ABSPATH' ) || exit;

 use Automattic\WooCommerce\Admin\ReportExporter;
+use Automattic\WooCommerce\Admin\ReportCSVExporter;

 /**
  * Reports Export controller.
@@ -70,7 +71,7 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller {
 		$params['report_args'] = array(
 			'description'       => __( 'Parameters to pass on to the exported report.', 'woocommerce' ),
 			'type'              => 'object',
-			'validate_callback' => 'rest_validate_request_arg', // @todo: use each controller's schema?
+			'validate_callback' => array( $this, 'validate_report_args' ),
 		);
 		$params['email']       = array(
 			'description'       => __( 'When true, email a link to download the export to the requesting user.', 'woocommerce' ),
@@ -80,6 +81,43 @@ class Controller extends \Automattic\WooCommerce\Admin\API\Reports\Controller {
 		return $params;
 	}

+	/**
+	 * Validate report_args against the target report's own collection schema.
+	 *
+	 * The export route accepts report_args as a free-form object, so its keys
+	 * (e.g. orderby) must be validated against the schema of the report being
+	 * exported the same way the non-export report route validates them.
+	 *
+	 * @since 11.0.1
+	 * @param  mixed                                  $value   The report_args value.
+	 * @param  \WP_REST_Request<array<string, mixed>> $request The request.
+	 * @param  string                                 $param   The parameter name.
+	 * @return true|\WP_Error
+	 */
+	public function validate_report_args( $value, $request, $param ) {
+		$validity = rest_validate_request_arg( $value, $request, $param );
+		if ( true !== $validity ) {
+			return $validity;
+		}
+
+		$report_controller = ReportCSVExporter::get_report_controller( $request['type'] );
+		if ( ! $report_controller ) {
+			// Fail closed: an unresolved report type cannot be validated, and
+			// exporting it would otherwise run with unvalidated arguments.
+			return new \WP_Error(
+				'woocommerce_rest_invalid_report_type',
+				__( 'Invalid report type.', 'woocommerce' ),
+				array( 'status' => 400 )
+			);
+		}
+
+		$schema_check = new \WP_REST_Request();
+		$schema_check->set_attributes( array( 'args' => $report_controller->get_collection_params() ) );
+		$schema_check->set_query_params( is_array( $value ) ? $value : array() );
+
+		return $schema_check->has_valid_params();
+	}
+
 	/**
 	 * Get the Report Export's schema, conforming to JSON Schema.
 	 *
diff --git a/plugins/woocommerce/src/Admin/ReportCSVExporter.php b/plugins/woocommerce/src/Admin/ReportCSVExporter.php
index b8073724fe4..cafe730af05 100644
--- a/plugins/woocommerce/src/Admin/ReportCSVExporter.php
+++ b/plugins/woocommerce/src/Admin/ReportCSVExporter.php
@@ -135,21 +135,16 @@ class ReportCSVExporter extends \WC_CSV_Batch_Exporter {
 	 * @param array $args The report args.
 	 */
 	public function set_report_args( $args ) {
-		// Use our own internal limit and include all extended info.
-		$report_args = array_merge(
-			$args,
-			array(
-				'per_page'      => $this->get_limit(),
-				'extended_info' => true,
-			)
-		);
-
 		// Should this happen externally?
-		if ( isset( $report_args['page'] ) ) {
-			$this->set_page( $report_args['page'] );
+		if ( isset( $args['page'] ) ) {
+			$this->set_page( $args['page'] );
 		}

-		$this->report_args = $report_args;
+		// Store the caller's args as-is. Our internal export args (batch size and
+		// extended info) are applied in prepare_data_to_export() after the
+		// caller's args have been validated, so they are never checked against
+		// the user-facing report schema.
+		$this->report_args = $args;
 	}

 	/**
@@ -158,16 +153,31 @@ class ReportCSVExporter extends \WC_CSV_Batch_Exporter {
 	 * @return bool|WC_REST_Reports_Controller Report controller instance or boolean false on error.
 	 */
 	protected function map_report_controller() {
+		$controller_map = self::get_report_controller_map();
+
+		if ( isset( $controller_map[ $this->report_type ] ) ) {
+			// Load the controllers if accessing outside the REST API.
+			return new $controller_map[ $this->report_type ]();
+		}
+
+		// Should this do something else?
+		return false;
+	}
+
+	/**
+	 * Get the report type to report controller class map.
+	 *
+	 * @since 11.0.1
+	 * @return array Report type to report controller class map.
+	 */
+	private static function get_report_controller_map() {
 		/**
 		 * Used to add custom report controllers.
 		 *
-		 * @since x.x.x
-		 *
-		 * @params array $controller_map A report type to report controller class map.
-		 *
-		 * @returns array Report type to report controller class map.
+		 * @since 9.8.0
+		 * @param array $controller_map A report type to report controller class map.
 		 */
-		$controller_map = apply_filters(
+		return apply_filters(
 			'woocommerce_export_report_controller_map',
 			array(
 				'products'   => 'Automattic\WooCommerce\Admin\API\Reports\Products\Controller',
@@ -182,14 +192,25 @@ class ReportCSVExporter extends \WC_CSV_Batch_Exporter {
 				'revenue'    => 'Automattic\WooCommerce\Admin\API\Reports\Revenue\Stats\Controller',
 			)
 		);
+	}

-		if ( isset( $controller_map[ $this->report_type ] ) ) {
-			// Load the controllers if accessing outside the REST API.
-			return new $controller_map[ $this->report_type ]();
+	/**
+	 * Get a REST controller instance for a given report type, or false if unknown.
+	 *
+	 * @since 11.0.1
+	 * @param string $report_type Report type. E.g. 'orders'.
+	 * @return \WC_REST_Reports_Controller|false
+	 */
+	public static function get_report_controller( $report_type ) {
+		$controller_map = self::get_report_controller_map();
+
+		if ( ! isset( $controller_map[ $report_type ] ) ) {
+			return false;
 		}

-		// Should this do something else?
-		return false;
+		$controller = new $controller_map[ $report_type ]();
+
+		return $controller instanceof \WC_REST_Reports_Controller ? $controller : false;
 	}

 	/**
@@ -278,6 +299,30 @@ class ReportCSVExporter extends \WC_CSV_Batch_Exporter {
 		$request->set_query_params( $this->report_args );
 		$request->sanitize_params();

+		// Enforce the report's own schema on every export path, including direct
+		// ReportExporter::queue_report_export() callers that bypass the REST
+		// route's validation. Never pass unvalidated args (e.g. orderby) to the
+		// query.
+		$validity = $request->has_valid_params();
+		if ( is_wp_error( $validity ) ) {
+			wc_get_logger()->warning(
+				sprintf( 'Skipping %s report export: %s', $this->report_type, $validity->get_error_message() ),
+				array( 'source' => 'report-csv-exporter' )
+			);
+			$this->total_rows = 0;
+			$this->row_data   = array();
+			return;
+		}
+
+		// Apply our internal export args after validation. The batch size and
+		// extended info are ours, not user input, so they must not be validated
+		// against the user-facing schema (e.g. per_page's maximum). The batch
+		// size in particular is filterable via
+		// woocommerce_{$export_type}_export_batch_limit and can legitimately
+		// exceed the schema's per_page maximum.
+		$request->set_param( 'per_page', $this->get_limit() );
+		$request->set_param( 'extended_info', true );
+
 		// Does the controller have an export-specific item retrieval method?
 		// @todo - Potentially revisit. This is only for /revenue/stats/.
 		if ( is_callable( array( $this->controller, 'get_export_items' ) ) ) {
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-export.php b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-export.php
index 40ede744cea..1ef1c26df58 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-export.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/api/reports-export.php
@@ -159,4 +159,119 @@ class WC_Admin_Tests_API_Reports_Export extends WC_REST_Unit_Test_Case {
 		$this->assertStringMatchesFormat( '%s/wc-analytics/reports/taxes/export/%d/status', $status['_links']['self'][0]['href'] );
 		remove_filter( 'wc_tax_enabled', '__return_true' );
 	}
+
+	/**
+	 * @testdox Should reject an export whose report_args.orderby is outside the report's enum.
+	 */
+	public function test_export_rejects_orderby_outside_enum() {
+		wp_set_current_user( $this->user );
+
+		$request = new WP_REST_Request( 'POST', '/wc-analytics/reports/orders/export' );
+		$request->set_body_params( array( 'report_args' => array( 'orderby' => 'date,(SELECT SLEEP(3))' ) ) );
+		$response = $this->server->dispatch( $request );
+		$data     = $response->get_data();
+
+		$this->assertEquals( 400, $response->get_status(), 'An orderby outside the enum must be rejected.' );
+		$this->assertEquals( 'rest_invalid_param', $data['code'], 'The rejection must be a parameter validation error.' );
+		$this->assertArrayHasKey( 'report_args', $data['data']['params'], 'The report_args parameter must be flagged as invalid.' );
+		$this->assertStringContainsString( 'orderby', $data['data']['params']['report_args'], 'The failure must point at the orderby key.' );
+	}
+
+	/**
+	 * @testdox Should accept an export whose report_args.orderby is in the report's enum.
+	 */
+	public function test_export_accepts_orderby_in_enum() {
+		wp_set_current_user( $this->user );
+
+		$request = new WP_REST_Request( 'POST', '/wc-analytics/reports/orders/export' );
+		$request->set_body_params( array( 'report_args' => array( 'orderby' => 'net_total' ) ) );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 200, $response->get_status(), 'An orderby in the enum must be accepted.' );
+	}
+
+	/**
+	 * @testdox Should fail closed and reject an export for an unresolvable report type.
+	 */
+	public function test_export_rejects_unresolvable_report_type() {
+		wp_set_current_user( $this->user );
+
+		$request = new WP_REST_Request( 'POST', '/wc-analytics/reports/bogus/export' );
+		$request->set_body_params( array( 'report_args' => array( 'orderby' => 'date' ) ) );
+		$response = $this->server->dispatch( $request );
+
+		$this->assertEquals( 400, $response->get_status(), 'An unresolvable report type must be rejected.' );
+	}
+
+	/**
+	 * @testdox Should enforce the report schema when queue_report_export is called directly.
+	 */
+	public function test_direct_queue_report_export_enforces_schema() {
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$product = new WC_Product_Simple();
+		$product->set_name( 'Test Product' );
+		$product->set_regular_price( 25 );
+		$product->save();
+
+		$order = WC_Helper_Order::create_order( 1, $product );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		$valid_rows = \Automattic\WooCommerce\Admin\ReportExporter::queue_report_export(
+			'export-valid',
+			'orders',
+			array( 'orderby' => 'net_total' )
+		);
+		$this->assertGreaterThan( 0, $valid_rows, 'A valid orderby should export the available rows.' );
+
+		$injected_rows = \Automattic\WooCommerce\Admin\ReportExporter::queue_report_export(
+			'export-injected',
+			'orders',
+			array( 'orderby' => 'date,(SELECT SLEEP(3))' )
+		);
+		$this->assertEquals( 0, $injected_rows, 'An orderby outside the enum must not be exported, even off the REST route.' );
+	}
+
+	/**
+	 * @testdox Should still export when the batch limit filter exceeds the schema per_page maximum.
+	 */
+	public function test_export_allows_batch_limit_above_schema_maximum() {
+		WC_Helper_Reports::reset_stats_dbs();
+
+		$product = new WC_Product_Simple();
+		$product->set_name( 'Test Product' );
+		$product->set_regular_price( 25 );
+		$product->save();
+
+		$order = WC_Helper_Order::create_order( 1, $product );
+		$order->set_status( OrderStatus::COMPLETED );
+		$order->save();
+
+		WC_Helper_Queue::run_all_pending( 'wc-admin-data' );
+
+		// The report schema caps per_page at 100; the batch size is ours and may exceed it.
+		add_filter( 'woocommerce_admin_orders_report_export_batch_limit', array( $this, 'return_large_batch_limit' ) );
+
+		$rows = \Automattic\WooCommerce\Admin\ReportExporter::queue_report_export(
+			'export-big-batch',
+			'orders',
+			array( 'orderby' => 'net_total' )
+		);
+
+		remove_filter( 'woocommerce_admin_orders_report_export_batch_limit', array( $this, 'return_large_batch_limit' ) );
+
+		$this->assertGreaterThan( 0, $rows, 'A batch limit above the schema per_page maximum must still export.' );
+	}
+
+	/**
+	 * Batch limit filter callback returning a value above the schema per_page maximum.
+	 *
+	 * @return int
+	 */
+	public function return_large_batch_limit() {
+		return 500;
+	}
 }