Skip to content

Commit 996adcf

Browse files
committed
Add comparison budget to OverlappingFieldsCanBeMerged
Inline fragments bypass the named-fragment PairSet cache because they have no name — they are flattened directly into the parent field map. This allows O(n^2 × m^2) pairwise comparisons, consuming minutes of CPU for a sub-megabyte query. Add a configurable comparison counter in findConflict() that caps worst-case CPU. Defaults to 100,000 comparisons, well above any realistic query. 🤖 Generated with Claude Code
1 parent ff116ea commit 996adcf

2 files changed

Lines changed: 39 additions & 0 deletions

File tree

src/Validator/Rules/OverlappingFieldsCanBeMerged.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
*/
3434
class OverlappingFieldsCanBeMerged extends ValidationRule
3535
{
36+
public const DEFAULT_MAX_COMPARISON_COUNT = 100_000;
37+
3638
/**
3739
* A memoization for when two fragments are compared "between" each other for
3840
* conflicts. Two fragments may be compared many times, so memoizing this can
@@ -49,10 +51,20 @@ class OverlappingFieldsCanBeMerged extends ValidationRule
4951
*/
5052
protected \SplObjectStorage $cachedFieldsAndFragmentNames;
5153

54+
protected int $comparisonCount;
55+
56+
protected int $comparisonLimit;
57+
58+
public function __construct(int $comparisonLimit = self::DEFAULT_MAX_COMPARISON_COUNT)
59+
{
60+
$this->comparisonLimit = $comparisonLimit;
61+
}
62+
5263
public function getVisitor(QueryValidationContext $context): array
5364
{
5465
$this->comparedFragmentPairs = new PairSet();
5566
$this->cachedFieldsAndFragmentNames = new \SplObjectStorage();
67+
$this->comparisonCount = 0;
5668

5769
return [
5870
NodeKind::SELECTION_SET => function (SelectionSetNode $selectionSet) use ($context): void {
@@ -399,6 +411,14 @@ protected function findConflict(
399411
array $field1,
400412
array $field2
401413
): ?array {
414+
if (++$this->comparisonCount > $this->comparisonLimit) {
415+
return [
416+
[$responseName, 'Too many field comparisons, query is too complex to validate'],
417+
[$field1[1]],
418+
[$field2[1]],
419+
];
420+
}
421+
402422
[$parentType1, $ast1, $def1] = $field1;
403423
[$parentType2, $ast2, $def2] = $field2;
404424

tests/Validator/OverlappingFieldsCanBeMergedTest.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use GraphQL\Type\Definition\ObjectType;
1111
use GraphQL\Type\Definition\Type;
1212
use GraphQL\Type\Schema;
13+
use GraphQL\Utils\BuildSchema;
1314
use GraphQL\Validator\DocumentValidator;
1415
use GraphQL\Validator\Rules\OverlappingFieldsCanBeMerged;
1516

@@ -1274,6 +1275,24 @@ public function testManyRepeatedFieldsWithConflictStillDetected(): void
12741275
);
12751276
}
12761277

1278+
public function testInlineFragmentsDontCauseQuadraticBlowup(): void
1279+
{
1280+
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
1281+
1282+
$innerFragments = implode(' ', array_fill(0, 100, '... on Node { x }'));
1283+
$outerFragments = implode(' ', array_fill(0, 100, "... on Node { f { {$innerFragments} } }"));
1284+
$query = "{ field { {$outerFragments} } }";
1285+
1286+
$rule = new OverlappingFieldsCanBeMerged();
1287+
$errors = DocumentValidator::validate($schema, Parser::parse($query), [$rule]);
1288+
1289+
self::assertNotEmpty($errors);
1290+
self::assertStringContainsString(
1291+
'Too many field comparisons',
1292+
$errors[0]->getMessage(),
1293+
);
1294+
}
1295+
12771296
/** @see it('find invalid case even with immediately recursive fragment') */
12781297
public function testFindInvalidCaseEvenWithImmediatelyRecursiveFragment(): void
12791298
{

0 commit comments

Comments
 (0)