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'] );
+ }
}