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;
+ }
}