Commit 035a13ab945 for woocommerce

commit 035a13ab945d6532e8d8b50dcfb31b29dbeffac6
Author: Alba Rincón <albarin@users.noreply.github.com>
Date:   Tue Apr 28 09:30:07 2026 +0200

    Add configurable maximum query depth to GraphQL settings (#64325)

    * Add configurable maximum query depth to GraphQL settings

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Remove reflection-based test for compute_query_depth

    * Skip SettingsTest on PHP < 8.1 for GraphQLController constant refs

    * Rename compute_query_depth keys to tree_only / in_depth

    * Add configurable maximum query complexity to GraphQL settings (#64337)

    * Add configurable maximum query complexity to GraphQL settings

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Hide configured limit and computed score from query complexity error

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

    * Use generic "Maximum query depth exceeded" error message

    * Remove unused $enabled static property

    * Remove changelog

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/64325-graphql-settings-query-limits b/plugins/woocommerce/changelog/64325-graphql-settings-query-limits
new file mode 100644
index 00000000000..717f571f9d3
--- /dev/null
+++ b/plugins/woocommerce/changelog/64325-graphql-settings-query-limits
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a configurable maximum query depth to the GraphQL settings section.
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/Api/GraphQLController.php b/plugins/woocommerce/src/Internal/Api/GraphQLController.php
index 325733fa4be..33bcee34456 100644
--- a/plugins/woocommerce/src/Internal/Api/GraphQLController.php
+++ b/plugins/woocommerce/src/Internal/Api/GraphQLController.php
@@ -15,29 +15,28 @@ use Automattic\WooCommerce\Vendor\GraphQL\Type\Schema;
 use Automattic\WooCommerce\Vendor\GraphQL\Error\DebugFlag;
 use Automattic\WooCommerce\Vendor\GraphQL\Validator\DocumentValidator;
 use Automattic\WooCommerce\Vendor\GraphQL\Validator\Rules\DisableIntrospection;
-use Automattic\WooCommerce\Vendor\GraphQL\Validator\Rules\QueryComplexity;
-use Automattic\WooCommerce\Vendor\GraphQL\Validator\Rules\QueryDepth;

 /**
  * Handles incoming GraphQL requests over the WooCommerce REST API.
  */
 abstract class GraphQLController {
 	/**
-	 * Maximum nesting depth allowed in a GraphQL query.
+	 * Default nesting-depth limit applied when the option is unset or non-positive.
 	 *
-	 * Queries exceeding this depth are rejected during validation, before any
-	 * resolver runs. See {@see self::get_max_query_depth()} for the accessor.
+	 * Queries exceeding the configured limit are rejected during validation,
+	 * before any resolver runs. See {@see self::get_max_query_depth()} for the accessor.
 	 */
-	private const MAX_QUERY_DEPTH = 15;
+	public const DEFAULT_MAX_QUERY_DEPTH = 15;

 	/**
-	 * Maximum computed complexity score allowed for a GraphQL query.
+	 * Default complexity-score limit applied when the option is unset or non-positive.
 	 *
 	 * Complexity is the sum of per-field scores; connection fields multiply
-	 * their child score by the requested page size. Queries exceeding this
-	 * score are rejected during validation. See {@see self::get_max_query_complexity()}.
+	 * their child score by the requested page size. Queries exceeding the
+	 * configured limit are rejected during validation. See
+	 * {@see self::get_max_query_complexity()} for the accessor.
 	 */
-	private const MAX_QUERY_COMPLEXITY = 1000;
+	public const DEFAULT_MAX_QUERY_COMPLEXITY = 1000;

 	/**
 	 * Cached GraphQL schema instance.
@@ -66,21 +65,25 @@ abstract class GraphQLController {
 	/**
 	 * The maximum nesting depth allowed in a GraphQL query.
 	 *
-	 * Exposed as a method so the limit can become configurable — e.g. via a
-	 * filter or store option — without requiring call-site changes.
+	 * Reads the {@see Main::OPTION_MAX_QUERY_DEPTH} store option; falls back
+	 * to {@see self::DEFAULT_MAX_QUERY_DEPTH} when the option is unset, empty,
+	 * or non-positive.
 	 */
 	public static function get_max_query_depth(): int {
-		return self::MAX_QUERY_DEPTH;
+		$value = (int) get_option( Main::OPTION_MAX_QUERY_DEPTH, self::DEFAULT_MAX_QUERY_DEPTH );
+		return $value > 0 ? $value : self::DEFAULT_MAX_QUERY_DEPTH;
 	}

 	/**
 	 * The maximum computed complexity score allowed for a GraphQL query.
 	 *
-	 * Exposed as a method so the limit can become configurable — e.g. via a
-	 * filter or store option — without requiring call-site changes.
+	 * Reads the {@see Main::OPTION_MAX_QUERY_COMPLEXITY} store option; falls
+	 * back to {@see self::DEFAULT_MAX_QUERY_COMPLEXITY} when the option is
+	 * unset, empty, or non-positive.
 	 */
 	public static function get_max_query_complexity(): int {
-		return self::MAX_QUERY_COMPLEXITY;
+		$value = (int) get_option( Main::OPTION_MAX_QUERY_COMPLEXITY, self::DEFAULT_MAX_QUERY_COMPLEXITY );
+		return $value > 0 ? $value : self::DEFAULT_MAX_QUERY_COMPLEXITY;
 	}

 	/**
@@ -167,11 +170,11 @@ abstract class GraphQLController {
 		$schema = $this->get_schema();

 		// 6. Build validation rules.
-		// A single QueryComplexity instance is kept so its computed score can
+		// A single complexity-rule instance is kept so its computed score can
 		// be surfaced in the debug extensions after execution.
-		$complexity_rule    = new QueryComplexity( self::get_max_query_complexity() );
+		$complexity_rule    = new QueryComplexityRule( self::get_max_query_complexity() );
 		$validation_rules   = array_values( DocumentValidator::allRules() );
-		$validation_rules[] = new QueryDepth( self::get_max_query_depth() );
+		$validation_rules[] = new QueryDepthRule( self::get_max_query_depth() );
 		$validation_rules[] = $complexity_rule;
 		if ( ! $this->is_introspection_allowed( $request ) ) {
 			$validation_rules[] = new DisableIntrospection( DisableIntrospection::ENABLED );
@@ -521,19 +524,29 @@ abstract class GraphQLController {
 	}

 	/**
-	 * Compute the maximum nesting depth of the executing operation.
+	 * Compute the maximum nesting depth of the executing operation, under two
+	 * different metrics:
 	 *
-	 * Field selections add one level; inline fragments do not. Named-fragment
-	 * spreads are not expanded here — the depth returned is therefore a lower
-	 * bound when spreads are present. The webonyx QueryDepth validation rule
-	 * (which does expand spreads) remains the authoritative gate; this helper
-	 * only produces the metric surfaced in the debug extensions.
+	 * - `tree_only`: only fields whose own selection set is non-empty count
+	 *   toward depth; leaves are excluded. This is the number directly
+	 *   comparable to the "Maximum query depth" setting's limit, and matches
+	 *   what webonyx's QueryDepth validation rule measures for the enforcement
+	 *   decision.
+	 * - `in_depth`: counts every field in the deepest chain, leaves included.
+	 *   Useful as a shape metric when inspecting a query.
+	 *
+	 * Inline fragments pass through without incrementing either metric.
+	 * Named-fragment spreads are not expanded here, so both numbers are lower
+	 * bounds when spreads are present. The webonyx QueryDepth validation rule
+	 * (which does expand spreads) remains the authoritative gate.
 	 *
 	 * @param DocumentNode $document       The parsed GraphQL document.
 	 * @param ?string      $operation_name The requested operation name, if any.
+	 * @return array{tree_only: int, in_depth: int}
 	 */
-	private function compute_query_depth( DocumentNode $document, ?string $operation_name ): int {
-		$max = 0;
+	private function compute_query_depth( DocumentNode $document, ?string $operation_name ): array {
+		$tree_only = 0;
+		$in_depth  = 0;
 		foreach ( $document->definitions as $definition ) {
 			if ( ! $definition instanceof OperationDefinitionNode ) {
 				continue;
@@ -543,19 +556,52 @@ abstract class GraphQLController {
 				continue;
 			}

-			$max = max( $max, $this->walk_depth( $definition->selectionSet, 0 ) );
+			$tree_only = max( $tree_only, $this->walk_depth_tree_only( $definition->selectionSet, 0 ) );
+			$in_depth  = max( $in_depth, $this->walk_depth_in_depth( $definition->selectionSet, 0 ) );
+		}
+
+		return array(
+			'tree_only' => $tree_only,
+			'in_depth'  => $in_depth,
+		);
+	}
+
+	/**
+	 * Walk a selection set counting only fields with child selections, matching
+	 * webonyx's QueryDepth rule so the returned number is directly comparable
+	 * to the configured "Maximum query depth" limit.
+	 *
+	 * @param ?SelectionSetNode $selection_set The selection set to walk.
+	 * @param int               $depth         The depth at which fields in this selection set sit.
+	 */
+	private function walk_depth_tree_only( ?SelectionSetNode $selection_set, int $depth ): int {
+		if ( null === $selection_set ) {
+			return 0;
+		}
+
+		$max = 0;
+		foreach ( $selection_set->selections as $selection ) {
+			if ( $selection instanceof FieldNode ) {
+				if ( null !== $selection->selectionSet ) {
+					$max = max( $max, $depth, $this->walk_depth_tree_only( $selection->selectionSet, $depth + 1 ) );
+				}
+			} elseif ( $selection instanceof InlineFragmentNode ) {
+				$max = max( $max, $this->walk_depth_tree_only( $selection->selectionSet, $depth ) );
+			}
 		}

 		return $max;
 	}

 	/**
-	 * Recursively walk a selection set and return the maximum depth reached.
+	 * Walk a selection set counting every field in the deepest chain, leaves
+	 * included. Produces the "shape" metric surfaced alongside the enforcement
+	 * metric in debug output.
 	 *
 	 * @param ?SelectionSetNode $selection_set The selection set to walk, or null for a leaf.
 	 * @param int               $depth         The depth of the selection set's parent.
 	 */
-	private function walk_depth( ?SelectionSetNode $selection_set, int $depth ): int {
+	private function walk_depth_in_depth( ?SelectionSetNode $selection_set, int $depth ): int {
 		if ( null === $selection_set ) {
 			return $depth;
 		}
@@ -563,9 +609,9 @@ abstract class GraphQLController {
 		$max = $depth;
 		foreach ( $selection_set->selections as $selection ) {
 			if ( $selection instanceof FieldNode ) {
-				$max = max( $max, $this->walk_depth( $selection->selectionSet, $depth + 1 ) );
+				$max = max( $max, $this->walk_depth_in_depth( $selection->selectionSet, $depth + 1 ) );
 			} elseif ( $selection instanceof InlineFragmentNode ) {
-				$max = max( $max, $this->walk_depth( $selection->selectionSet, $depth ) );
+				$max = max( $max, $this->walk_depth_in_depth( $selection->selectionSet, $depth ) );
 			}
 		}

diff --git a/plugins/woocommerce/src/Internal/Api/Main.php b/plugins/woocommerce/src/Internal/Api/Main.php
index fe20cf7e2b4..7233afac85a 100644
--- a/plugins/woocommerce/src/Internal/Api/Main.php
+++ b/plugins/woocommerce/src/Internal/Api/Main.php
@@ -27,6 +27,21 @@ class Main {
 	 */
 	public const OPTION_GET_ENDPOINT_ENABLED = 'woocommerce_graphql_get_endpoint_enabled';

+	/**
+	 * Option name for the "Maximum query depth" setting.
+	 *
+	 * Caps how deep the selection tree of a GraphQL query may nest.
+	 */
+	public const OPTION_MAX_QUERY_DEPTH = 'woocommerce_graphql_max_query_depth';
+
+	/**
+	 * Option name for the "Maximum query complexity" setting.
+	 *
+	 * Caps the computed complexity score of a GraphQL query — connection
+	 * fields multiply their children's cost by the requested page size.
+	 */
+	public const OPTION_MAX_QUERY_COMPLEXITY = 'woocommerce_graphql_max_query_complexity';
+
 	/**
 	 * Check whether the Dual Code & GraphQL API feature is active.
 	 *
diff --git a/plugins/woocommerce/src/Internal/Api/QueryComplexityRule.php b/plugins/woocommerce/src/Internal/Api/QueryComplexityRule.php
new file mode 100644
index 00000000000..f52e2009895
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/QueryComplexityRule.php
@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\Api;
+
+use Automattic\WooCommerce\Vendor\GraphQL\Validator\Rules\QueryComplexity;
+
+/**
+ * QueryComplexity validation rule that returns a generic error message when the complexity is exceeded.
+ *
+ * Admins can still read both values via debug mode; see
+ * {@see GraphQLController} step 8.
+ */
+class QueryComplexityRule extends QueryComplexity {
+	/**
+	 * Override webonyx's default ("Max query complexity should be {max} but
+	 * got {count}.").
+	 *
+	 * @param int $max   The configured maximum complexity (unused).
+	 * @param int $count The computed query complexity (unused).
+	 */
+	public static function maxQueryComplexityErrorMessage( int $max, int $count ): string {
+		return 'Maximum query complexity exceeded.';
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/QueryDepthRule.php b/plugins/woocommerce/src/Internal/Api/QueryDepthRule.php
new file mode 100644
index 00000000000..3607f90383d
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Api/QueryDepthRule.php
@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\Api;
+
+use Automattic\WooCommerce\Vendor\GraphQL\Validator\Rules\QueryDepth;
+
+/**
+ * QueryDepth validation rule that returns a generic error message when the depth is exceeded.
+ *
+ * Admins can still read both values via debug mode; see
+ * {@see GraphQLController} step 8.
+ */
+class QueryDepthRule extends QueryDepth {
+	/**
+	 * Override webonyx's default ("Max query depth should be {max} but
+	 * got {count}.").
+	 *
+	 * @param int $max   The configured maximum depth (unused).
+	 * @param int $count The computed query depth (unused).
+	 */
+	public static function maxQueryDepthErrorMessage( int $max, int $count ): string {
+		return 'Maximum query depth exceeded.';
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/Api/Settings.php b/plugins/woocommerce/src/Internal/Api/Settings.php
index a40ff8345c8..383af7ae0f7 100644
--- a/plugins/woocommerce/src/Internal/Api/Settings.php
+++ b/plugins/woocommerce/src/Internal/Api/Settings.php
@@ -64,6 +64,22 @@ class Settings {
 				'default' => 'yes',
 				'type'    => 'checkbox',
 			),
+			array(
+				'title'             => __( 'Maximum query depth', 'woocommerce' ),
+				'desc'              => __( 'Reject queries whose selection nesting exceeds this depth.', 'woocommerce' ),
+				'id'                => Main::OPTION_MAX_QUERY_DEPTH,
+				'default'           => (string) GraphQLController::DEFAULT_MAX_QUERY_DEPTH,
+				'type'              => 'number',
+				'custom_attributes' => array( 'min' => '1' ),
+			),
+			array(
+				'title'             => __( 'Maximum query complexity', 'woocommerce' ),
+				'desc'              => __( 'Reject queries whose computed complexity score exceeds this value.', 'woocommerce' ),
+				'id'                => Main::OPTION_MAX_QUERY_COMPLEXITY,
+				'default'           => (string) GraphQLController::DEFAULT_MAX_QUERY_COMPLEXITY,
+				'type'              => 'number',
+				'custom_attributes' => array( 'min' => '1' ),
+			),
 			array(
 				'type' => 'sectionend',
 				'id'   => 'woocommerce_graphql_options',
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerTest.php
index 34fb2e5198c..6855f7ad747 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerTest.php
@@ -41,10 +41,12 @@ class GraphQLControllerTest extends WC_REST_Unit_Test_Case {
 	}

 	/**
-	 * Clean up the GET endpoint option between tests.
+	 * Clean up GraphQL options between tests.
 	 */
 	public function tearDown(): void {
 		delete_option( Main::OPTION_GET_ENDPOINT_ENABLED );
+		delete_option( Main::OPTION_MAX_QUERY_DEPTH );
+		delete_option( Main::OPTION_MAX_QUERY_COMPLEXITY );
 		parent::tearDown();
 	}

@@ -77,4 +79,83 @@ class GraphQLControllerTest extends WC_REST_Unit_Test_Case {
 		$this->assertTrue( $methods['GET'] ?? false );
 		$this->assertTrue( $methods['POST'] ?? false );
 	}
+
+	/**
+	 * @testdox get_max_query_depth returns the default when the option is unset.
+	 */
+	public function test_get_max_query_depth_returns_default_when_option_unset(): void {
+		delete_option( Main::OPTION_MAX_QUERY_DEPTH );
+		$this->assertSame(
+			GraphQLController::DEFAULT_MAX_QUERY_DEPTH,
+			GraphQLController::get_max_query_depth()
+		);
+	}
+
+	/**
+	 * @testdox get_max_query_depth returns the option value when it is a positive integer.
+	 */
+	public function test_get_max_query_depth_returns_option_value_when_positive(): void {
+		update_option( Main::OPTION_MAX_QUERY_DEPTH, '7' );
+		$this->assertSame( 7, GraphQLController::get_max_query_depth() );
+	}
+
+	/**
+	 * @testdox get_max_query_depth falls back to the default when the option is empty, zero, or negative.
+	 * @dataProvider provider_non_positive_option_values
+	 *
+	 * @param string $value The non-positive option value.
+	 */
+	public function test_get_max_query_depth_falls_back_on_non_positive( string $value ): void {
+		update_option( Main::OPTION_MAX_QUERY_DEPTH, $value );
+		$this->assertSame(
+			GraphQLController::DEFAULT_MAX_QUERY_DEPTH,
+			GraphQLController::get_max_query_depth()
+		);
+	}
+
+	/**
+	 * @testdox get_max_query_complexity returns the default when the option is unset.
+	 */
+	public function test_get_max_query_complexity_returns_default_when_option_unset(): void {
+		delete_option( Main::OPTION_MAX_QUERY_COMPLEXITY );
+		$this->assertSame(
+			GraphQLController::DEFAULT_MAX_QUERY_COMPLEXITY,
+			GraphQLController::get_max_query_complexity()
+		);
+	}
+
+	/**
+	 * @testdox get_max_query_complexity returns the option value when it is a positive integer.
+	 */
+	public function test_get_max_query_complexity_returns_option_value_when_positive(): void {
+		update_option( Main::OPTION_MAX_QUERY_COMPLEXITY, '500' );
+		$this->assertSame( 500, GraphQLController::get_max_query_complexity() );
+	}
+
+	/**
+	 * @testdox get_max_query_complexity falls back to the default when the option is empty, zero, or negative.
+	 * @dataProvider provider_non_positive_option_values
+	 *
+	 * @param string $value The non-positive option value.
+	 */
+	public function test_get_max_query_complexity_falls_back_on_non_positive( string $value ): void {
+		update_option( Main::OPTION_MAX_QUERY_COMPLEXITY, $value );
+		$this->assertSame(
+			GraphQLController::DEFAULT_MAX_QUERY_COMPLEXITY,
+			GraphQLController::get_max_query_complexity()
+		);
+	}
+
+	/**
+	 * Non-positive values that the getters should replace with the default.
+	 *
+	 * @return array<string, array{string}>
+	 */
+	public function provider_non_positive_option_values(): array {
+		return array(
+			'empty string' => array( '' ),
+			'zero'         => array( '0' ),
+			'negative'     => array( '-5' ),
+		);
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/SettingsTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/SettingsTest.php
index 455a302fe07..34a01815c51 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/SettingsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/SettingsTest.php
@@ -3,6 +3,7 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Tests\Internal\Api;

+use Automattic\WooCommerce\Internal\Api\GraphQLController;
 use Automattic\WooCommerce\Internal\Api\Main;
 use Automattic\WooCommerce\Internal\Api\Settings;
 use Automattic\WooCommerce\Internal\Features\FeaturesController;
@@ -21,10 +22,21 @@ class SettingsTest extends WC_Unit_Test_Case {

 	/**
 	 * Set up before each test.
+	 *
+	 * Skips on PHP < 8.1 because the settings fields reference
+	 * GraphQLController constants, and that class uses PHP 8.0+ syntax that
+	 * cannot be parsed on 7.4. In production the class is only loaded after
+	 * {@see Main::is_enabled()} gates on PHP 8.1+; these tests replicate the
+	 * same gate so the autoload never triggers a parse error.
 	 */
 	public function setUp(): void {
 		parent::setUp();
 		$this->enable_or_disable_feature( true );
+
+		if ( PHP_VERSION_ID < 80100 ) {
+			$this->markTestSkipped( 'GraphQL settings tests require PHP 8.1+.' );
+		}
+
 		$this->sut = new Settings();
 	}

@@ -121,4 +133,36 @@ class SettingsTest extends WC_Unit_Test_Case {

 		$this->assertSame( $input, $result );
 	}
+
+	/**
+	 * @testdox add_settings defines the max query depth field with min=1 and the default constant as default.
+	 */
+	public function test_add_settings_defines_max_query_depth_field(): void {
+		$fields = $this->sut->add_settings( array(), Settings::SECTION_ID );
+		$by_id  = array_column( $fields, null, 'id' );
+
+		$this->assertArrayHasKey( Main::OPTION_MAX_QUERY_DEPTH, $by_id );
+		$this->assertSame( 'number', $by_id[ Main::OPTION_MAX_QUERY_DEPTH ]['type'] );
+		$this->assertSame(
+			(string) GraphQLController::DEFAULT_MAX_QUERY_DEPTH,
+			$by_id[ Main::OPTION_MAX_QUERY_DEPTH ]['default']
+		);
+		$this->assertSame( '1', $by_id[ Main::OPTION_MAX_QUERY_DEPTH ]['custom_attributes']['min'] );
+	}
+
+	/**
+	 * @testdox add_settings defines the max query complexity field with min=1 and the default constant as default.
+	 */
+	public function test_add_settings_defines_max_query_complexity_field(): void {
+		$fields = $this->sut->add_settings( array(), Settings::SECTION_ID );
+		$by_id  = array_column( $fields, null, 'id' );
+
+		$this->assertArrayHasKey( Main::OPTION_MAX_QUERY_COMPLEXITY, $by_id );
+		$this->assertSame( 'number', $by_id[ Main::OPTION_MAX_QUERY_COMPLEXITY ]['type'] );
+		$this->assertSame(
+			(string) GraphQLController::DEFAULT_MAX_QUERY_COMPLEXITY,
+			$by_id[ Main::OPTION_MAX_QUERY_COMPLEXITY ]['default']
+		);
+		$this->assertSame( '1', $by_id[ Main::OPTION_MAX_QUERY_COMPLEXITY ]['custom_attributes']['min'] );
+	}
 }